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, + } + ) +);