feat: release 1.0 - rename to Hearbit AI, fix timestamps, update UI
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Mic, Square, Settings as SettingsIcon, FileText, Sparkles, Copy, Check } from 'lucide-react';
|
||||
import { Mic, Square } from 'lucide-react';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import logo from '../assets/logo.png'; // Import logo
|
||||
import ReactMarkdown from 'react-markdown'; // Import Markdown renderer
|
||||
|
||||
interface PromptTemplate {
|
||||
id: string;
|
||||
@@ -22,7 +21,7 @@ interface RecorderProps {
|
||||
productId: string;
|
||||
prompts: PromptTemplate[];
|
||||
onOpenSettings: () => void;
|
||||
// Lifted State Props
|
||||
// Lifted State Props (still passed for state management, though unused in view)
|
||||
transcription: string;
|
||||
setTranscription: (val: string) => void;
|
||||
summary: string;
|
||||
@@ -32,6 +31,8 @@ interface RecorderProps {
|
||||
onSaveToHistory: (t?: string, s?: string) => void;
|
||||
onDeleteHistory: (id: string) => void;
|
||||
onLoadHistory: (item: HistoryItem) => void;
|
||||
savePath: string | null;
|
||||
onRecordingComplete: () => void;
|
||||
}
|
||||
|
||||
interface AudioDevice {
|
||||
@@ -39,30 +40,19 @@ interface AudioDevice {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const LLM_MODELS = [
|
||||
{ id: 'mixtral', name: 'Mixtral 8x7B (Best for Logic)' },
|
||||
{ id: 'llama3', name: 'Llama 3 (Balanced)' },
|
||||
{ id: 'llama3-70b', name: 'Llama 3 70B (High Quality)' },
|
||||
{ id: 'granite-code', name: 'Granite Code (Coding)' }
|
||||
];
|
||||
|
||||
const Recorder: React.FC<RecorderProps> = ({
|
||||
apiKey, productId, prompts, onOpenSettings,
|
||||
transcription, setTranscription, summary, setSummary,
|
||||
history, onSaveToHistory, onDeleteHistory, onLoadHistory
|
||||
apiKey, productId, prompts,
|
||||
setTranscription, setSummary,
|
||||
onSaveToHistory, savePath, onRecordingComplete
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [status, setStatus] = useState<string>('Ready to record');
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||
const [selectedModel, setSelectedModel] = useState<string>('mixtral');
|
||||
const [devices, setDevices] = useState<AudioDevice[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>(LLM_MODELS);
|
||||
const [showHistory, setShowHistory] = useState(false); // Toggle history view
|
||||
|
||||
// Local state for copy feedback only
|
||||
const [copiedTrans, setCopiedTrans] = useState(false);
|
||||
const [copiedSum, setCopiedSum] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<Array<{ id: string, name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
@@ -75,7 +65,6 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
try {
|
||||
const models = await invoke<Array<{ id: string, name: string }>>('get_available_models', { apiKey, productId });
|
||||
if (models && models.length > 0) {
|
||||
// Sort models alphabetically
|
||||
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setAvailableModels(models);
|
||||
}
|
||||
@@ -84,13 +73,11 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Set default prompt selection
|
||||
useEffect(() => {
|
||||
if (prompts.length > 0 && !selectedPromptId) {
|
||||
setSelectedPromptId(prompts[0].id);
|
||||
} else if (prompts.length > 0 && selectedPromptId) {
|
||||
// Check if selected still exists
|
||||
if (!prompts.find(p => p.id === selectedPromptId)) {
|
||||
setSelectedPromptId(prompts[0].id);
|
||||
}
|
||||
@@ -100,20 +87,42 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const devList = await invoke<AudioDevice[]>('get_input_devices');
|
||||
setDevices(devList);
|
||||
if (devList.length > 0 && !selectedDevice) {
|
||||
setSelectedDevice(devList[0].id);
|
||||
// Alias BlackHole
|
||||
const aliasedDevs = devList.map(d => ({
|
||||
...d,
|
||||
name: d.name.includes('BlackHole') ? 'Hearbit Virtual Mic (BlackHole)' : d.name
|
||||
}));
|
||||
setDevices(aliasedDevs);
|
||||
|
||||
// Select Hearbit mic by default if available and no selection made
|
||||
if (!selectedDevice) {
|
||||
const vb = aliasedDevs.find(d => d.name.includes('Hearbit Virtual Mic'));
|
||||
if (vb) {
|
||||
setSelectedDevice(vb.id);
|
||||
} else if (aliasedDevs.length > 0) {
|
||||
setSelectedDevice(aliasedDevs[0].id);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load devices', e);
|
||||
}
|
||||
};
|
||||
|
||||
const openAudioSetup = async () => {
|
||||
try {
|
||||
await invoke('open_audio_midi_setup');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setStatus('Failed to open Audio Setup');
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setStatus('Starting...');
|
||||
await invoke('start_recording', { deviceId: selectedDevice });
|
||||
await invoke('start_recording', { deviceId: selectedDevice, savePath: savePath || null });
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setTranscription('');
|
||||
setSummary('');
|
||||
setStatus('Recording...');
|
||||
@@ -124,9 +133,26 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
if (isPaused) {
|
||||
await invoke('resume_recording');
|
||||
setIsPaused(false);
|
||||
setStatus('Recording...');
|
||||
} else {
|
||||
await invoke('pause_recording');
|
||||
setIsPaused(true);
|
||||
setStatus('Paused');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Pause/Resume error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = async () => {
|
||||
try {
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
setStatus('Processing...');
|
||||
const filePath = await invoke<string>('stop_recording');
|
||||
|
||||
@@ -138,12 +164,9 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
});
|
||||
setTranscription(transText);
|
||||
|
||||
setTranscription(transText);
|
||||
|
||||
// Check if transcription is empty or just whitespace
|
||||
if (!transText || transText.trim().length === 0) {
|
||||
setStatus('Done (No speech detected)');
|
||||
// If empty, set a placeholder so the UI shows something, but DON'T summarize
|
||||
setTranscription('(No speech detected. Check your microphone settings.)');
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
return;
|
||||
@@ -167,6 +190,7 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
onSaveToHistory(transText, sumText);
|
||||
|
||||
setStatus('Done!');
|
||||
onRecordingComplete(); // Auto-switch tab
|
||||
setTimeout(() => setStatus('Ready to record'), 3000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -174,251 +198,127 @@ const Recorder: React.FC<RecorderProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const installDriver = async () => {
|
||||
try {
|
||||
setStatus('Installing driver...');
|
||||
const res = await invoke('install_driver');
|
||||
console.log(res);
|
||||
setStatus('Driver installed. Please restart app.');
|
||||
loadDevices();
|
||||
} catch (e) {
|
||||
setStatus(`Install failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, isSummary: boolean) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (isSummary) {
|
||||
setCopiedSum(true);
|
||||
setTimeout(() => setCopiedSum(false), 2000);
|
||||
} else {
|
||||
setCopiedTrans(true);
|
||||
setTimeout(() => setCopiedTrans(false), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy!', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full bg-background relative">
|
||||
{/* Fixed Header */}
|
||||
<div className="w-full flex justify-between items-center p-6 border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-10 sticky top-0 shrink-0">
|
||||
<div className="w-full flex justify-center items-center p-6 shrink-0">
|
||||
<img src={logo} alt="Logo" className="h-12 object-contain" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all ${showHistory
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} />
|
||||
{showHistory ? 'Recorder' : 'Records'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="p-2 text-muted-foreground hover:text-foreground hover:bg-secondary rounded-md transition-colors"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col items-center pb-20">
|
||||
|
||||
{showHistory ? (
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-xl font-bold mb-4">Saved Recordings</h2>
|
||||
{history.length === 0 && <p className="text-muted-foreground">No saved history.</p>}
|
||||
{history.map(item => (
|
||||
<div key={item.id} className="p-4 rounded-lg bg-card border border-border shadow-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<span className="text-xs text-muted-foreground">{item.date}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { onLoadHistory(item); setShowHistory(false); }}
|
||||
className="text-xs bg-primary text-primary-foreground px-2 py-1 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteHistory(item.id)}
|
||||
className="text-xs bg-destructive text-destructive-foreground px-2 py-1 rounded"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm truncate font-medium">{item.summary ? item.summary.slice(0, 50) + "..." : "No Summary"}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{item.transcription.slice(0, 50)}...</p>
|
||||
<div className="mb-6 relative shrink-0">
|
||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? (isPaused ? 'bg-yellow-500/10' : 'bg-red-500/10 animate-pulse') : 'bg-secondary'}`}>
|
||||
{isRecording ? (
|
||||
<div className={`w-24 h-24 rounded-full flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)] ${isPaused ? 'bg-yellow-500' : 'bg-red-500'}`}>
|
||||
<Mic size={40} className="text-white animate-bounce" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 relative shrink-0">
|
||||
<div className={`w-32 h-32 rounded-full flex items-center justify-center transition-all duration-300 ${isRecording ? 'bg-red-500/10 animate-pulse' : 'bg-secondary'}`}>
|
||||
{isRecording ? (
|
||||
<div className="w-24 h-24 rounded-full bg-red-500 flex items-center justify-center shadow-[0_0_20px_rgba(239,68,68,0.5)]">
|
||||
<Mic size={40} className="text-white animate-bounce" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={40} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2 text-foreground">
|
||||
{isRecording ? 'Listening...' : 'Ready to Record'}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!apiKey || !productId}
|
||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="w-full py-4 text-lg font-semibold bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Square size={20} fill="currentColor" />
|
||||
Stop Recording
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
AI Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedPromptId}
|
||||
onChange={(e) => setSelectedPromptId(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording || prompts.length === 0}
|
||||
>
|
||||
{prompts.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
{prompts.length === 0 && <option value="">No templates</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={installDriver}
|
||||
className="text-xs text-primary underline w-full text-center hover:text-primary/80 mt-1 block"
|
||||
>
|
||||
Install BlackHole Driver (for System Audio)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(transcription || summary) && (
|
||||
<div className="w-full space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500 pb-10">
|
||||
{/* Transcription Block (Source) */}
|
||||
{transcription && (
|
||||
<div className="p-5 bg-card rounded-xl border border-border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-blue-500 font-semibold">
|
||||
<FileText size={18} />
|
||||
<h3>Original Transcription</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(transcription, false)}
|
||||
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Copy Transcription"
|
||||
>
|
||||
{copiedTrans ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80 whitespace-pre-wrap max-h-[200px] overflow-y-auto pr-2 bg-secondary/30 p-3 rounded-lg border border-border/50 font-medium">
|
||||
{transcription}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Block (Result) */}
|
||||
{summary && (
|
||||
<div className="p-5 bg-card rounded-xl border border-border shadow-sm ring-1 ring-purple-500/20">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-purple-500 font-semibold">
|
||||
<Sparkles size={18} />
|
||||
<h3>AI Summary</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSaveToHistory()}
|
||||
className="text-xs bg-green-600 text-white px-3 py-1.5 rounded-md hover:bg-green-700 font-medium transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(summary, true)}
|
||||
className="p-1.5 hover:bg-secondary rounded-md transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Copy Summary"
|
||||
>
|
||||
{copiedSum ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground/90 leading-relaxed max-h-[400px] overflow-y-auto pr-2 custom-scrollbar prose prose-sm dark:prose-invert max-w-none prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-li:my-0.5">
|
||||
<ReactMarkdown>
|
||||
{summary}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-primary flex items-center justify-center">
|
||||
<Mic size={40} className="text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-2 text-foreground">
|
||||
{isRecording ? (isPaused ? 'Paused' : 'Listening...') : 'Ready to Record'}
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mb-6 text-center text-sm h-6">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm space-y-4 mb-6 shrink-0">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
disabled={!apiKey || !productId}
|
||||
className="w-full py-4 text-lg font-semibold bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md hover:shadow-lg"
|
||||
>
|
||||
{!apiKey ? 'Configure API Key First' : 'Start Recording'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2 w-full">
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className={`flex-1 py-4 text-lg font-semibold rounded-lg transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2 ${isPaused
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-yellow-500 text-white hover:bg-yellow-600'
|
||||
}`}
|
||||
>
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex-1 py-4 text-lg font-semibold bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-all shadow-md hover:shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Square size={20} fill="currentColor" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
Input Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
{devices.length === 0 && <option value="">Loading devices...</option>}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
LLM Model
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording}
|
||||
>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block mb-1">
|
||||
AI Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedPromptId}
|
||||
onChange={(e) => setSelectedPromptId(e.target.value)}
|
||||
className="w-full p-2 text-sm bg-secondary rounded border border-border outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={isRecording || prompts.length === 0}
|
||||
>
|
||||
{prompts.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
{prompts.length === 0 && <option value="">No templates</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-2 w-full">
|
||||
<button
|
||||
onClick={openAudioSetup}
|
||||
className="text-xs text-muted-foreground hover:text-foreground w-full text-center border border-dashed border-border/50 rounded p-1"
|
||||
>
|
||||
Open Audio MIDI Setup (Configure Multi-Output)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user