diff --git a/web/index.tsx b/web/index.tsx
index 6ca5361..a43dad5 100644
--- a/web/index.tsx
+++ b/web/index.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
+import { QueryClientProvider } from '@tanstack/react-query';
import App from './App';
+import { queryClient } from './services/queryClient';
const rootElement = document.getElementById('root');
if (!rootElement) {
@@ -10,6 +12,8 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
-
+
+
+
-);
\ No newline at end of file
+);
diff --git a/web/package-lock.json b/web/package-lock.json
index a43031f..8693dc4 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -9,10 +9,12 @@
"version": "0.0.0",
"dependencies": {
"@google/genai": "^1.39.0",
+ "@tanstack/react-query": "^5.90.2",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
- "react-router-dom": "^7.13.0"
+ "react-router-dom": "^7.13.0",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@types/node": "^22.14.0",
@@ -1265,6 +1267,32 @@
"win32"
]
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.90.20",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+ "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.90.21",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
+ "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.90.20"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2772,6 +2800,35 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/web/package.json b/web/package.json
index f9faf9f..a4e6e55 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,7 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@tanstack/react-query": "^5.90.2",
"lucide-react": "^0.563.0",
+ "zustand": "^5.0.8",
"react-router-dom": "^7.13.0",
"@google/genai": "^1.39.0",
"react": "^19.2.4",
diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx
index 6c983a0..0826745 100644
--- a/web/pages/Assistants.tsx
+++ b/web/pages/Assistants.tsx
@@ -5,6 +5,7 @@ import { Plus, Search, Play, Square, Copy, Trash2, Mic, MessageSquare, Save, Vid
import { Button, Input, Badge, Drawer, Dialog, Switch } from '../components/UI';
import { ASRModel, Assistant, AssistantOpenerToolCall, KnowledgeBase, LLMModel, TabValue, Tool, Voice } from '../types';
import { createAssistant, deleteAssistant, fetchASRModels, fetchAssistantOpenerAudioPcmBuffer, fetchAssistants, fetchKnowledgeBases, fetchLLMModels, fetchTools, fetchVoices, generateAssistantOpenerAudio, previewVoice, updateAssistant as updateAssistantApi } from '../services/backendApi';
+import { useDebugPrefsStore } from '../stores/debugPrefsStore';
const isOpenAICompatibleVendor = (vendor?: string) => {
const normalized = String(vendor || '').trim().toLowerCase();
@@ -2365,12 +2366,17 @@ export const DebugDrawer: React.FC<{
const [dynamicVariables, setDynamicVariables] = useState([]);
const [dynamicVariablesError, setDynamicVariablesError] = useState('');
const dynamicVariableSeqRef = useRef(0);
- const [wsUrl, setWsUrl] = useState(() => {
- const fromStorage = localStorage.getItem('debug_ws_url');
- if (fromStorage) return fromStorage;
- const defaultHost = window.location.hostname || 'localhost';
- return `ws://${defaultHost}:8000/ws`;
- });
+ const wsUrl = useDebugPrefsStore((state) => state.wsUrl);
+ const setWsUrl = useDebugPrefsStore((state) => state.setWsUrl);
+ const aecEnabled = useDebugPrefsStore((state) => state.aecEnabled);
+ const setAecEnabled = useDebugPrefsStore((state) => state.setAecEnabled);
+ const nsEnabled = useDebugPrefsStore((state) => state.nsEnabled);
+ const setNsEnabled = useDebugPrefsStore((state) => state.setNsEnabled);
+ const agcEnabled = useDebugPrefsStore((state) => state.agcEnabled);
+ const setAgcEnabled = useDebugPrefsStore((state) => state.setAgcEnabled);
+ const clientToolEnabledMap = useDebugPrefsStore((state) => state.clientToolEnabledMap);
+ const setClientToolEnabled = useDebugPrefsStore((state) => state.setClientToolEnabled);
+ const hydrateClientToolDefaults = useDebugPrefsStore((state) => state.hydrateClientToolDefaults);
const nextDynamicVariableId = () => {
dynamicVariableSeqRef.current += 1;
return `var_${dynamicVariableSeqRef.current}`;
@@ -2432,16 +2438,6 @@ export const DebugDrawer: React.FC<{
const [selectedCamera, setSelectedCamera] = useState('');
const [selectedMic, setSelectedMic] = useState('');
const [isSwapped, setIsSwapped] = useState(false);
- const [aecEnabled, setAecEnabled] = useState(() => localStorage.getItem('debug_audio_aec') !== '0');
- const [nsEnabled, setNsEnabled] = useState(() => localStorage.getItem('debug_audio_ns') !== '0');
- const [agcEnabled, setAgcEnabled] = useState(() => localStorage.getItem('debug_audio_agc') !== '0');
- const [clientToolEnabledMap, setClientToolEnabledMap] = useState>(() => {
- const result: Record = {};
- for (const tool of DEBUG_CLIENT_TOOLS) {
- result[tool.id] = localStorage.getItem(`debug_client_tool_enabled_${tool.id}`) !== '0';
- }
- return result;
- });
const micAudioCtxRef = useRef(null);
const micSourceRef = useRef(null);
@@ -2557,8 +2553,8 @@ export const DebugDrawer: React.FC<{
}, [isOpen, assistant, mode]);
useEffect(() => {
- localStorage.setItem('debug_ws_url', wsUrl);
- }, [wsUrl]);
+ hydrateClientToolDefaults(DEBUG_CLIENT_TOOLS.map((tool) => tool.id));
+ }, [hydrateClientToolDefaults]);
useEffect(() => {
wsStatusRef.current = wsStatus;
@@ -2578,24 +2574,6 @@ export const DebugDrawer: React.FC<{
setDynamicVariablesError('');
}, [assistant.id, isOpen]);
- useEffect(() => {
- localStorage.setItem('debug_audio_aec', aecEnabled ? '1' : '0');
- }, [aecEnabled]);
-
- useEffect(() => {
- localStorage.setItem('debug_audio_ns', nsEnabled ? '1' : '0');
- }, [nsEnabled]);
-
- useEffect(() => {
- localStorage.setItem('debug_audio_agc', agcEnabled ? '1' : '0');
- }, [agcEnabled]);
-
- useEffect(() => {
- for (const tool of DEBUG_CLIENT_TOOLS) {
- localStorage.setItem(`debug_client_tool_enabled_${tool.id}`, isClientToolEnabled(tool.id) ? '1' : '0');
- }
- }, [clientToolEnabledMap]);
-
useEffect(() => {
clientToolEnabledMapRef.current = clientToolEnabledMap;
}, [clientToolEnabledMap]);
@@ -4223,7 +4201,7 @@ export const DebugDrawer: React.FC<{
setClientToolEnabledMap((prev) => ({ ...prev, [tool.id]: next }))}
+ onCheckedChange={(next) => setClientToolEnabled(tool.id, next)}
title={enabled ? '点击关闭' : '点击开启'}
aria-label={`${tool.name} ${enabled ? '开启' : '关闭'}`}
/>
diff --git a/web/pages/AutoTest.tsx b/web/pages/AutoTest.tsx
index 60d8bb1..17edf38 100644
--- a/web/pages/AutoTest.tsx
+++ b/web/pages/AutoTest.tsx
@@ -1,31 +1,19 @@
-import React, { useEffect, useState } from 'react';
+import React, { useState } from 'react';
import { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ClipboardCheck, X } from 'lucide-react';
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
import { mockAutoTestAssistants } from '../services/mockData';
-import { Assistant, AutoTestAssistant, TestType, TestMethod } from '../types';
-import { fetchAssistants } from '../services/backendApi';
+import { AutoTestAssistant, TestType, TestMethod } from '../types';
+import { useAssistantsQuery } from '../services/queries';
export const AutoTestPage: React.FC = () => {
const [testAssistants, setTestAssistants] = useState(mockAutoTestAssistants);
- const [assistants, setAssistants] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState(null);
const [copySuccess, setCopySuccess] = useState(false);
-
- useEffect(() => {
- const loadAssistants = async () => {
- try {
- const list = await fetchAssistants();
- setAssistants(list);
- } catch (error) {
- console.error(error);
- }
- };
- loadAssistants();
- }, []);
+ const { data: assistants = [] } = useAssistantsQuery();
const filteredTests = testAssistants.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase())
diff --git a/web/pages/Dashboard.tsx b/web/pages/Dashboard.tsx
index 4990bf8..9c40e96 100644
--- a/web/pages/Dashboard.tsx
+++ b/web/pages/Dashboard.tsx
@@ -4,17 +4,16 @@ import { useNavigate } from 'react-router-dom';
import { Phone, CheckCircle, Clock, UserCheck, ChevronDown, BarChart3, HelpCircle, Mail, ArrowDown, Bot, Zap, Rocket, LineChart, Layers, Fingerprint, Network, MonitorPlay, Plus } from 'lucide-react';
import { Card, Button } from '../components/UI';
import { getDashboardStats } from '../services/mockData';
-import { Assistant } from '../types';
-import { fetchAssistants } from '../services/backendApi';
+import { useAssistantsQuery } from '../services/queries';
export const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const [timeRange, setTimeRange] = useState<'week' | 'month' | 'year'>('week');
const [selectedAssistantId, setSelectedAssistantId] = useState('all');
- const [assistants, setAssistants] = useState([]);
const [topRailHeight, setTopRailHeight] = useState(0);
const [isOverviewVisible, setIsOverviewVisible] = useState(true);
const [scrollRootCenterX, setScrollRootCenterX] = useState(null);
+ const { data: assistants = [] } = useAssistantsQuery();
const scrollRootRef = useRef(null);
const topRailRef = useRef(null);
@@ -26,29 +25,6 @@ export const DashboardPage: React.FC = () => {
return getDashboardStats(timeRange, selectedAssistantId);
}, [timeRange, selectedAssistantId]);
- useEffect(() => {
- let disposed = false;
-
- const loadAssistants = async () => {
- try {
- const list = await fetchAssistants();
- if (!disposed) {
- setAssistants(list);
- }
- } catch (error) {
- if (!disposed) {
- console.error(error);
- }
- }
- };
-
- loadAssistants();
-
- return () => {
- disposed = true;
- };
- }, []);
-
useEffect(() => {
const updateTopRailHeight = () => {
const nextHeight = topRailRef.current?.offsetHeight ?? 0;
diff --git a/web/pages/VoiceLibrary.tsx b/web/pages/VoiceLibrary.tsx
index a133bec..b84f28c 100644
--- a/web/pages/VoiceLibrary.tsx
+++ b/web/pages/VoiceLibrary.tsx
@@ -2,7 +2,13 @@ import React, { useEffect, useMemo, useState, useRef } from 'react';
import { Search, Mic2, Play, Pause, Upload, Filter, Plus, Volume2, Pencil, Trash2 } from 'lucide-react';
import { Button, Input, Select, TableHeader, TableRow, TableHead, TableCell, Dialog, Badge, LibraryPageShell, TableStatusRow, LibraryActionCell } from '../components/UI';
import { Voice } from '../types';
-import { createVoice, deleteVoice, fetchVoices, previewVoice, updateVoice } from '../services/backendApi';
+import { previewVoice } from '../services/backendApi';
+import {
+ useCreateVoiceMutation,
+ useDeleteVoiceMutation,
+ useUpdateVoiceMutation,
+ useVoicesQuery,
+} from '../services/queries';
const OPENAI_COMPATIBLE_DEFAULT_MODEL = 'FunAudioLLM/CosyVoice2-0.5B';
const OPENAI_COMPATIBLE_DEFAULT_VOICE = 'FunAudioLLM/CosyVoice2-0.5B:anna';
@@ -19,7 +25,6 @@ const buildOpenAICompatibleVoiceKey = (rawId: string, model: string): string =>
};
export const VoiceLibraryPage: React.FC = () => {
- const [voices, setVoices] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState('all');
const [genderFilter, setGenderFilter] = useState<'all' | 'Male' | 'Female'>('all');
@@ -29,24 +34,15 @@ export const VoiceLibraryPage: React.FC = () => {
const [isCloneModalOpen, setIsCloneModalOpen] = useState(false);
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [editingVoice, setEditingVoice] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
const [playLoadingId, setPlayLoadingId] = useState(null);
const audioRef = useRef(null);
- useEffect(() => {
- const loadVoices = async () => {
- setIsLoading(true);
- try {
- setVoices(await fetchVoices());
- } catch (error) {
- console.error(error);
- setVoices([]);
- } finally {
- setIsLoading(false);
- }
- };
- loadVoices();
- }, []);
+ const voicesQuery = useVoicesQuery();
+ const voices = voicesQuery.data || [];
+ const isLoading = voicesQuery.isLoading;
+ const createVoiceMutation = useCreateVoiceMutation();
+ const updateVoiceMutation = useUpdateVoiceMutation();
+ const deleteVoiceMutation = useDeleteVoiceMutation();
const vendorOptions = useMemo(
() => Array.from(new Set(voices.map((v) => String(v.vendor || '').trim()).filter(Boolean))).sort(),
@@ -100,22 +96,19 @@ export const VoiceLibraryPage: React.FC = () => {
};
const handleAddSuccess = async (newVoice: Voice) => {
- const created = await createVoice(newVoice);
- setVoices((prev) => [created, ...prev]);
+ await createVoiceMutation.mutateAsync(newVoice);
setIsAddModalOpen(false);
setIsCloneModalOpen(false);
};
const handleUpdateSuccess = async (id: string, data: Voice) => {
- const updated = await updateVoice(id, data);
- setVoices((prev) => prev.map((voice) => (voice.id === id ? updated : voice)));
+ await updateVoiceMutation.mutateAsync({ id, data });
setEditingVoice(null);
};
const handleDelete = async (id: string) => {
if (!confirm('确认删除该声音吗?该操作不可恢复。')) return;
- await deleteVoice(id);
- setVoices((prev) => prev.filter((voice) => voice.id !== id));
+ await deleteVoiceMutation.mutateAsync(id);
};
return (
diff --git a/web/pages/WorkflowEditor.tsx b/web/pages/WorkflowEditor.tsx
index a1b3a4e..fe15a51 100644
--- a/web/pages/WorkflowEditor.tsx
+++ b/web/pages/WorkflowEditor.tsx
@@ -3,9 +3,10 @@ import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, Play, Save, Rocket, Plus, Bot, UserCheck, Wrench, Ban, Zap, X, Copy, MousePointer2 } from 'lucide-react';
import { Button, Input, Badge } from '../components/UI';
-import { Assistant, WorkflowNode, WorkflowEdge, Workflow } from '../types';
+import { WorkflowNode, WorkflowEdge, Workflow } from '../types';
import { DebugDrawer } from './Assistants';
-import { createWorkflow, fetchAssistants, fetchWorkflowById, updateWorkflow } from '../services/backendApi';
+import { createWorkflow, fetchWorkflowById, updateWorkflow } from '../services/backendApi';
+import { useAssistantsQuery } from '../services/queries';
const toWorkflowNodeType = (type: WorkflowNode['type']): WorkflowNode['type'] => {
if (type === 'conversation') return 'assistant';
@@ -80,7 +81,7 @@ export const WorkflowEditorPage: React.FC = () => {
const [nodes, setNodes] = useState(() => getTemplateNodes(templateType));
const [edges, setEdges] = useState([]);
const [createdAt, setCreatedAt] = useState('');
- const [assistants, setAssistants] = useState([]);
+ const { data: assistants = [] } = useAssistantsQuery();
const [selectedNodeName, setSelectedNodeName] = useState(null);
const [isAddMenuOpen, setIsAddMenuOpen] = useState(false);
@@ -215,18 +216,6 @@ export const WorkflowEditorPage: React.FC = () => {
};
}, [draggingNodeName, isPanning, zoom]);
- useEffect(() => {
- const loadData = async () => {
- try {
- const assistantList = await fetchAssistants();
- setAssistants(assistantList);
- } catch (error) {
- console.error(error);
- }
- };
- loadData();
- }, []);
-
useEffect(() => {
if (!id) return;
const loadWorkflow = async () => {
diff --git a/web/services/queries.ts b/web/services/queries.ts
new file mode 100644
index 0000000..b9af126
--- /dev/null
+++ b/web/services/queries.ts
@@ -0,0 +1,59 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Voice } from '../types';
+import {
+ createVoice,
+ deleteVoice,
+ fetchAssistants,
+ fetchVoices,
+ updateVoice,
+} from './backendApi';
+import { queryKeys } from './queryKeys';
+
+export const useAssistantsQuery = () =>
+ useQuery({
+ queryKey: queryKeys.assistants,
+ queryFn: fetchAssistants,
+ });
+
+export const useVoicesQuery = () =>
+ useQuery({
+ queryKey: queryKeys.voices,
+ queryFn: fetchVoices,
+ });
+
+export const useCreateVoiceMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (payload: Partial) => createVoice(payload),
+ onSuccess: (created) => {
+ queryClient.setQueryData(queryKeys.voices, (prev = []) => [created, ...prev]);
+ },
+ });
+};
+
+export const useUpdateVoiceMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, data }: { id: string; data: Partial }) => updateVoice(id, data),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(queryKeys.voices, (prev = []) =>
+ prev.map((voice) => (voice.id === updated.id ? updated : voice))
+ );
+ },
+ });
+};
+
+export const useDeleteVoiceMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (id: string) => {
+ await deleteVoice(id);
+ return id;
+ },
+ onSuccess: (deletedId) => {
+ queryClient.setQueryData(queryKeys.voices, (prev = []) =>
+ prev.filter((voice) => voice.id !== deletedId)
+ );
+ },
+ });
+};
diff --git a/web/services/queryClient.ts b/web/services/queryClient.ts
new file mode 100644
index 0000000..68e5872
--- /dev/null
+++ b/web/services/queryClient.ts
@@ -0,0 +1,12 @@
+import { QueryClient } from '@tanstack/react-query';
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
diff --git a/web/services/queryKeys.ts b/web/services/queryKeys.ts
new file mode 100644
index 0000000..12c9f41
--- /dev/null
+++ b/web/services/queryKeys.ts
@@ -0,0 +1,4 @@
+export const queryKeys = {
+ assistants: ['assistants'] as const,
+ voices: ['voices'] as const,
+};
diff --git a/web/stores/debugPrefsStore.ts b/web/stores/debugPrefsStore.ts
new file mode 100644
index 0000000..179ff5f
--- /dev/null
+++ b/web/stores/debugPrefsStore.ts
@@ -0,0 +1,65 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+type DebugPrefsState = {
+ wsUrl: string;
+ aecEnabled: boolean;
+ nsEnabled: boolean;
+ agcEnabled: boolean;
+ clientToolEnabledMap: Record;
+ setWsUrl: (value: string) => void;
+ setAecEnabled: (value: boolean) => void;
+ setNsEnabled: (value: boolean) => void;
+ setAgcEnabled: (value: boolean) => void;
+ setClientToolEnabled: (toolId: string, enabled: boolean) => void;
+ hydrateClientToolDefaults: (toolIds: string[]) => void;
+};
+
+const getDefaultWsUrl = (): string => {
+ if (typeof window === 'undefined') {
+ return 'ws://localhost:8000/ws';
+ }
+ const defaultHost = window.location.hostname || 'localhost';
+ return `ws://${defaultHost}:8000/ws`;
+};
+
+export const useDebugPrefsStore = create()(
+ persist(
+ (set, get) => ({
+ wsUrl: getDefaultWsUrl(),
+ aecEnabled: true,
+ nsEnabled: true,
+ agcEnabled: true,
+ clientToolEnabledMap: {},
+ setWsUrl: (value) => set({ wsUrl: value }),
+ setAecEnabled: (value) => set({ aecEnabled: value }),
+ setNsEnabled: (value) => set({ nsEnabled: value }),
+ setAgcEnabled: (value) => set({ agcEnabled: value }),
+ setClientToolEnabled: (toolId, enabled) =>
+ set((state) => ({
+ clientToolEnabledMap: {
+ ...state.clientToolEnabledMap,
+ [toolId]: enabled,
+ },
+ })),
+ hydrateClientToolDefaults: (toolIds) => {
+ const current = get().clientToolEnabledMap;
+ let changed = false;
+ const nextMap: Record = { ...current };
+ for (const toolId of toolIds) {
+ if (!(toolId in nextMap)) {
+ nextMap[toolId] = true;
+ changed = true;
+ }
+ }
+ if (changed) {
+ set({ clientToolEnabledMap: nextMap });
+ }
+ },
+ }),
+ {
+ name: 'assistants-debug-prefs',
+ version: 1,
+ }
+ )
+);