Integrate React Query for data management and enhance Debug Preferences

- Added React Query for managing API calls related to assistants and voices.
- Introduced `useAssistantsQuery` and `useVoicesQuery` hooks for fetching data.
- Implemented mutations for creating, updating, and deleting voices using React Query.
- Integrated a global `QueryClient` for managing query states and configurations.
- Refactored components to utilize the new query hooks, improving data handling and performance.
- Added a Zustand store for managing debug preferences, including WebSocket URL and audio settings.
This commit is contained in:
Xin Wang
2026-03-02 22:50:57 +08:00
parent 70b4043f9b
commit eecde9f0fb
12 changed files with 247 additions and 120 deletions

View File

@@ -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<DynamicVariableEntry[]>([]);
const [dynamicVariablesError, setDynamicVariablesError] = useState('');
const dynamicVariableSeqRef = useRef(0);
const [wsUrl, setWsUrl] = useState<string>(() => {
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<string>('');
const [selectedMic, setSelectedMic] = useState<string>('');
const [isSwapped, setIsSwapped] = useState(false);
const [aecEnabled, setAecEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_aec') !== '0');
const [nsEnabled, setNsEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_ns') !== '0');
const [agcEnabled, setAgcEnabled] = useState<boolean>(() => localStorage.getItem('debug_audio_agc') !== '0');
const [clientToolEnabledMap, setClientToolEnabledMap] = useState<Record<string, boolean>>(() => {
const result: Record<string, boolean> = {};
for (const tool of DEBUG_CLIENT_TOOLS) {
result[tool.id] = localStorage.getItem(`debug_client_tool_enabled_${tool.id}`) !== '0';
}
return result;
});
const micAudioCtxRef = useRef<AudioContext | null>(null);
const micSourceRef = useRef<MediaStreamAudioSourceNode | null>(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<{
</div>
<Switch
checked={enabled}
onCheckedChange={(next) => setClientToolEnabledMap((prev) => ({ ...prev, [tool.id]: next }))}
onCheckedChange={(next) => setClientToolEnabled(tool.id, next)}
title={enabled ? '点击关闭' : '点击开启'}
aria-label={`${tool.name} ${enabled ? '开启' : '关闭'}`}
/>

View File

@@ -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<AutoTestAssistant[]>(mockAutoTestAssistants);
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(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())

View File

@@ -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<string>('all');
const [assistants, setAssistants] = useState<Assistant[]>([]);
const [topRailHeight, setTopRailHeight] = useState(0);
const [isOverviewVisible, setIsOverviewVisible] = useState(true);
const [scrollRootCenterX, setScrollRootCenterX] = useState<number | null>(null);
const { data: assistants = [] } = useAssistantsQuery();
const scrollRootRef = useRef<HTMLDivElement>(null);
const topRailRef = useRef<HTMLDivElement>(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;

View File

@@ -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<Voice[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [vendorFilter, setVendorFilter] = useState<string>('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<Voice | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [playLoadingId, setPlayLoadingId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(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 (

View File

@@ -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<WorkflowNode[]>(() => getTemplateNodes(templateType));
const [edges, setEdges] = useState<WorkflowEdge[]>([]);
const [createdAt, setCreatedAt] = useState('');
const [assistants, setAssistants] = useState<Assistant[]>([]);
const { data: assistants = [] } = useAssistantsQuery();
const [selectedNodeName, setSelectedNodeName] = useState<string | null>(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 () => {