feat: Introduce DashScope agent configuration, a WAV client for duplex testing, and new UI components for assistants.

This commit is contained in:
Xin Wang
2026-03-10 02:25:52 +08:00
parent 312fe0cf31
commit e4ccec6cc1
3 changed files with 165 additions and 111 deletions

View File

@@ -206,10 +206,11 @@ interface DrawerProps {
isOpen: boolean;
onClose: () => void;
title: string;
className?: string;
children: React.ReactNode;
}
export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, children }) => {
export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, className, children }) => {
if (!isOpen) return null;
return (
@@ -218,7 +219,7 @@ export const Drawer: React.FC<DrawerProps> = ({ isOpen, onClose, title, children
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={onClose} />
{/* Drawer Content */}
<div className="relative ml-auto flex h-full w-full max-w-md flex-col overflow-y-auto bg-background/95 border-l border-white/10 p-6 shadow-2xl animate-in slide-in-from-right sm:max-w-lg">
<div className={`relative ml-auto flex h-full w-full flex-col overflow-y-auto bg-background/95 border-l border-white/10 p-6 shadow-2xl animate-in slide-in-from-right ${className || 'max-w-md sm:max-w-lg'}`}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<Button variant="ghost" size="icon" onClick={onClose}>

View File

@@ -1132,12 +1132,12 @@ export const AssistantsPage: React.FC = () => {
onChange={(e) => updateManualOpenerToolCall(idx, { toolName: e.target.value })}
>
<option value=""></option>
{normalizeToolId(call.toolName || '') && !openerToolOptions.some((tool) => tool.id === normalizeToolId(call.toolName || '')) && (
{normalizeToolId(call.toolName || '') && !openerToolOptions.some((tool: any) => tool.id === normalizeToolId(call.toolName || '')) && (
<option value={normalizeToolId(call.toolName || '')}>
{`${normalizeToolId(call.toolName || '')} (未启用/不存在)`}
</option>
)}
{openerToolOptions.map((tool) => (
{openerToolOptions.map((tool: any) => (
<option key={tool.id} value={tool.id}>
{tool.label}
</option>
@@ -2489,7 +2489,7 @@ export const DebugDrawer: React.FC<{
const selectedToolSchemas = useMemo(() => {
const ids = Array.from(new Set((assistant.tools || []).map((id) => normalizeToolId(id))));
const byId = new Map(tools.map((t) => [normalizeToolId(t.id), { ...t, id: normalizeToolId(t.id) }]));
return ids.map((id) => {
return ids.map((id: any) => {
const item = byId.get(id);
const toolId = item?.id || id;
const debugClientTool = DEBUG_CLIENT_TOOLS.find((tool) => tool.id === toolId);
@@ -4371,8 +4371,11 @@ export const DebugDrawer: React.FC<{
return (
<>
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`}>
<div className="relative flex flex-col h-full min-h-0 overflow-hidden">
<Drawer isOpen={isOpen} onClose={() => { handleHangup(); onClose(); }} title={`调试: ${assistant.name}`} className="w-[90vw] sm:w-[85vw] max-w-none">
<div className="relative flex h-full min-h-0 overflow-hidden gap-6">
{/* Left Column: Call Interface */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex items-center gap-2 mb-4 shrink-0">
<div className="flex-1 flex justify-center bg-white/5 p-1 rounded-lg">
{(['text', 'voice', 'video'] as const).map(m => (
@@ -4384,15 +4387,31 @@ export const DebugDrawer: React.FC<{
</button>
))}
</div>
<Button
variant="outline"
size="icon"
className="h-[36px] w-[36px] shrink-0 bg-white/5 border-white/10 hover:bg-white/10 hover:text-primary transition-colors"
onClick={() => setSettingsDrawerOpen(true)}
title="调试设置"
>
<Wrench className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 ${mode === 'text' && textSessionStarted ? 'mb-0' : 'mb-4'}`}>
<div className={`flex-1 overflow-hidden flex flex-col min-h-0 mb-4`}>
{mode === 'text' ? (
textSessionStarted ? (
<div className="flex-1 min-h-0 overflow-hidden animate-in fade-in flex flex-col">
<div className="h-[68vh] min-h-[420px] max-h-[68vh] w-full flex flex-col min-h-0 overflow-hidden">
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 h-full" />
<div className="flex-1 flex flex-col items-center justify-center space-y-6 border border-white/5 rounded-xl bg-black/20 animate-in fade-in zoom-in-95 px-6">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
<div className="relative h-24 w-24 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
<MessageSquare className="h-10 w-10 text-primary" />
</div>
</div>
<div className="text-center">
<h3 className="text-lg font-bold text-white mb-1"></h3>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
) : wsStatus === 'connecting' ? (
@@ -4468,41 +4487,57 @@ export const DebugDrawer: React.FC<{
</div>
)
) : (
<div className="flex-1 flex flex-col min-h-0 space-y-2">
<div className="flex-1 flex flex-col min-h-0 space-y-4">
{mode === 'voice' ? (
<div className="h-[68vh] min-h-[420px] max-h-[68vh] w-full flex flex-col min-h-0 overflow-hidden animate-in fade-in">
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 h-full" />
<div className="flex-1 flex flex-col items-center justify-center space-y-6 border border-white/5 rounded-xl bg-black/20 animate-in fade-in px-6 relative overflow-hidden">
<div className="absolute inset-0 bg-primary/5 rounded-full blur-3xl animate-pulse"></div>
<div className="relative z-10">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-2xl animate-pulse"></div>
<div className="relative h-32 w-32 rounded-full bg-card border border-white/10 flex items-center justify-center shadow-2xl">
<Mic className="h-12 w-12 text-primary" />
</div>
</div>
</div>
<div className="text-center z-10">
<h3 className="text-xl font-bold text-white mb-2 tracking-tight"></h3>
<div className="flex items-center justify-center gap-2">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
<p className="text-sm font-medium text-green-400"></p>
</div>
</div>
</div>
) : (
<div className="flex flex-col h-full min-h-0 space-y-2 animate-in fade-in">
<div className="h-3/5 shrink-0 flex flex-col gap-2">
<div className="flex flex-col h-full min-h-0 space-y-3 animate-in fade-in">
<div className="flex gap-2 shrink-0">
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground" value={selectedCamera} onChange={e => setSelectedCamera(e.target.value)}>
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-3 py-2 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>option]:bg-card [&>option]:text-foreground" value={selectedCamera} onChange={e => setSelectedCamera(e.target.value)}>
{devices.filter(d => d.kind === 'videoinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Camera'}</option>)}
</select>
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground" value={selectedMic} onChange={e => setSelectedMic(e.target.value)}>
<select className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-3 py-2 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>option]:bg-card [&>option]:text-foreground" value={selectedMic} onChange={e => setSelectedMic(e.target.value)}>
{devices.filter(d => d.kind === 'audioinput').map(d => <option key={d.deviceId} value={d.deviceId}>{d.label || 'Mic'}</option>)}
</select>
</div>
<div className="flex-1 relative rounded-lg overflow-hidden border border-white/10 bg-black min-h-0">
<div className="absolute inset-0">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
<div className="absolute bottom-2 right-2 w-24 h-36 z-10">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
<button className="absolute top-2 right-2 z-20 h-8 w-8 rounded-full bg-black/50 backdrop-blur flex items-center justify-center text-white border border-white/10 hover:bg-primary/80" onClick={() => setIsSwapped(!isSwapped)}><ArrowLeftRight className="h-3.5 w-3.5" /></button>
<div className="flex-1 flex items-center justify-center relative rounded-xl overflow-hidden border border-white/10 bg-black min-h-0 shadow-inner">
<div className="absolute inset-0 object-cover">{isSwapped ? renderLocalVideo(false) : renderRemoteVideo(false)}</div>
<div className="absolute bottom-4 right-4 w-32 h-44 z-10 rounded-lg overflow-hidden border border-white/20 shadow-2xl transition-transform hover:scale-[1.02]">{isSwapped ? renderRemoteVideo(true) : renderLocalVideo(true)}</div>
<button className="absolute top-4 right-4 z-20 h-10 w-10 rounded-full bg-black/50 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-primary/80 transition-all hover:scale-110 shadow-lg" onClick={() => setIsSwapped(!isSwapped)} title="切换视图"><ArrowLeftRight className="h-4 w-4" /></button>
</div>
</div>
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="flex-1 min-h-0 overflow-y-auto" />
</div>
)}
</div>
)}
</div>
<div className={mode === 'text' && textSessionStarted ? 'shrink-0 mt-3 px-1 mb-3' : mode === 'voice' ? 'shrink-0 space-y-3 mt-3 px-1 mb-3' : 'shrink-0 space-y-2 mt-2 px-1 mb-3'}>
{mode === 'voice' && (
<div className="w-full flex items-center gap-2 pb-1">
<span className="text-xs text-muted-foreground shrink-0"></span>
{/* Hangup / Mic Select area (Left Column Bottom) */}
<div className={'shrink-0 space-y-3 mt-auto px-1 mb-1'}>
{mode === 'voice' && callStatus === 'active' && (
<div className="w-full flex items-center gap-2 pb-2">
<span className="text-xs font-medium text-muted-foreground shrink-0 uppercase tracking-wider"></span>
<select
className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-2 py-1 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card [&>option]:text-foreground"
className="flex-1 text-xs rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-foreground appearance-none cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 transition-colors hover:bg-white/10 [&>option]:bg-card [&>option]:text-foreground"
value={selectedMic}
onChange={(e) => setSelectedMic(e.target.value)}
>
@@ -4512,44 +4547,71 @@ export const DebugDrawer: React.FC<{
</select>
</div>
)}
<div className="w-full flex items-center gap-2 min-w-0">
<div className="w-full flex justify-center items-center">
{mode === 'text' && textSessionStarted && (
<Button
variant="destructive"
size="sm"
className="h-9 px-3 font-bold shrink-0 whitespace-nowrap"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
onClick={closeWs}
>
<PhoneOff className="h-4 w-4" />
<span className="ml-1.5"></span>
<PhoneOff className="h-5 w-5 mr-2" />
</Button>
)}
{mode === 'voice' && callStatus === 'active' && (
{mode !== 'text' && callStatus === 'active' && (
<Button
variant="destructive"
size="sm"
className="h-9 px-3 font-bold shrink-0 whitespace-nowrap"
size="lg"
className="w-full font-bold shadow-lg shadow-destructive/20 hover:shadow-destructive/40 transition-all"
onClick={handleHangup}
>
<PhoneOff className="h-4 w-4" />
<span className="ml-1.5"></span>
<PhoneOff className="h-5 w-5 mr-2" />
</Button>
)}
</div>
</div>
</div>
</div>
{/* Right Column: Transcript */}
<div className="w-[380px] lg:w-[450px] shrink-0 border-l border-white/10 pl-6 flex flex-col min-h-0 h-full">
<div className="flex items-center gap-2 mb-4 shrink-0 px-1">
<MessageSquare className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-foreground tracking-tight">Transcript</h3>
</div>
<div className="flex-1 min-h-0 overflow-hidden flex flex-col bg-card/30 rounded-xl border border-white/5 p-2 mb-4">
<div className="flex-1 min-h-0 overflow-y-auto pr-1 custom-scrollbar">
{(messages.length === 0 && !isLoading) ? (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/60 space-y-3">
<MessageSquare className="h-8 w-8 opacity-20" />
<p className="text-xs"></p>
</div>
) : (
<TranscriptionLog scrollRef={scrollRef} messages={messages} isLoading={isLoading} className="pb-4" />
)}
</div>
</div>
{/* Input bar */}
<div className="shrink-0 flex items-center gap-2 mt-auto p-1 bg-card/50 rounded-lg border border-white/10 shadow-sm focus-within:border-primary/40 focus-within:ring-1 focus-within:ring-primary/20 transition-all">
<Input
value={inputText}
onChange={e => setInputText(e.target.value)}
placeholder={mode === 'text' && !textSessionStarted ? "请先发起呼叫后输入消息..." : (mode === 'text' ? "输入消息..." : "输入文本模拟交互...")}
onKeyDown={e => e.key === 'Enter' && handleSend()}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
className="flex-1 min-w-0"
className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 shadow-none px-3"
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
className="h-8 w-8 shrink-0 rounded-md bg-primary/90 hover:bg-primary shadow-sm mr-1"
onClick={handleSend}
disabled={mode === 'text' ? !textSessionStarted : (isLoading || callStatus !== 'active')}
>
<Send className="h-4 w-4" />
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</div>
@@ -4604,7 +4666,7 @@ export const DebugDrawer: React.FC<{
)}
</div>
<div className="space-y-2">
{choicePromptDialog.options.map((option) => (
{choicePromptDialog.options.map((option: any) => (
<Button
key={option.id}
variant="outline"
@@ -4625,27 +4687,18 @@ export const DebugDrawer: React.FC<{
</div>
</div>
)}
</div>
</Drawer>
{isOpen && (
<div className="fixed inset-y-0 z-[51] right-[min(100vw,32rem)]">
<button
className={`absolute inset-y-0 w-10 border-r border-white/10 bg-background/90 backdrop-blur-md text-muted-foreground hover:text-foreground hover:bg-background/95 transition-[right,color,background-color] duration-300 flex flex-col items-center justify-center gap-2 ${settingsDrawerOpen ? 'right-[min(78vw,28rem)]' : 'right-0'}`}
onClick={() => setSettingsDrawerOpen((v) => !v)}
title={settingsDrawerOpen ? '收起调试设置' : '展开调试设置'}
<Dialog
isOpen={settingsDrawerOpen}
onClose={() => setSettingsDrawerOpen(false)}
title="调试设置"
contentClassName="max-w-[90vw] md:max-w-[70vw] lg:max-w-2xl h-[85vh] flex flex-col"
>
<span className={`text-[10px] font-mono tracking-tight ${settingsDrawerOpen ? 'text-primary animate-pulse' : 'opacity-90 animate-pulse'}`}>
{settingsDrawerOpen ? '>>' : '<<'}
</span>
<Wrench className="h-4 w-4" />
<span className="text-[10px] tracking-widest [writing-mode:vertical-rl]"></span>
</button>
<div className="absolute inset-y-0 right-0 w-[78vw] max-w-md overflow-hidden pointer-events-none">
<div className={`h-full transition-transform duration-300 ease-out will-change-transform ${settingsDrawerOpen ? 'translate-x-0 pointer-events-auto' : 'translate-x-full pointer-events-none'}`}>
<div className="flex-1 min-h-0 overflow-y-auto">
{settingsPanel}
</div>
</div>
</div>
</Dialog>
)}
</>
);

View File

@@ -28,7 +28,7 @@ const normalizeToolIdList = (value: unknown): string[] => {
return result;
};
const normalizeManualOpenerToolCalls = (value: unknown): AnyRecord[] => {
const normalizeManualOpenerToolCalls = (value: unknown): any[] => {
if (!Array.isArray(value)) return [];
return value
.filter((item) => item && typeof item === 'object')
@@ -41,7 +41,7 @@ const normalizeManualOpenerToolCalls = (value: unknown): AnyRecord[] => {
toolName,
};
})
.filter(Boolean) as AnyRecord[];
.filter(Boolean) as any[];
};
const withLimit = (path: string, limit: number = DEFAULT_LIST_LIMIT): string =>