dify/web/app/components/base/voice-input/index.stories.tsx
非法操作 fd845c8b6c
chore: add more stories (#27142)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-20 18:30:52 +08:00

500 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Meta, StoryObj } from '@storybook/nextjs'
import { useState } from 'react'
// Mock component since VoiceInput requires browser APIs and service dependencies
const VoiceInputMock = ({ onConverted, onCancel }: any) => {
const [state, setState] = useState<'idle' | 'recording' | 'converting'>('recording')
const [duration, setDuration] = useState(0)
// Simulate recording
useState(() => {
const interval = setInterval(() => {
setDuration(d => d + 1)
}, 1000)
return () => clearInterval(interval)
})
const handleStop = () => {
setState('converting')
setTimeout(() => {
onConverted('This is simulated transcribed text from voice input.')
}, 2000)
}
const minutes = Math.floor(duration / 60)
const seconds = duration % 60
return (
<div className="relative h-16 w-full overflow-hidden rounded-xl border-2 border-primary-600">
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
{/* Waveform visualization placeholder */}
<div className="absolute bottom-0 left-0 flex h-4 w-full items-end gap-[3px] px-2">
{new Array(40).fill().map((_, i) => (
<div
key={i}
className="w-[2px] rounded-t bg-blue-200"
style={{
height: `${Math.random() * 100}%`,
animation: state === 'recording' ? 'pulse 1s infinite' : 'none',
}}
/>
))}
</div>
{state === 'converting' && (
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary-700 border-t-transparent" />
)}
<div className="z-10 grow">
{state === 'recording' && (
<div className="text-sm text-gray-500">Speaking...</div>
)}
{state === 'converting' && (
<div className="text-sm text-gray-500">Converting to text...</div>
)}
</div>
{state === 'recording' && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-primary-100"
onClick={handleStop}
>
<div className="h-5 w-5 rounded bg-primary-600" />
</div>
)}
{state === 'converting' && (
<div
className="mr-1 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-gray-200"
onClick={onCancel}
>
<span className="text-lg text-gray-500">×</span>
</div>
)}
<div className={`w-[45px] pl-1 text-xs font-medium ${duration > 500 ? 'text-red-600' : 'text-gray-700'}`}>
{`0${minutes}:${seconds >= 10 ? seconds : `0${seconds}`}`}
</div>
</div>
</div>
)
}
const meta = {
title: 'Base/VoiceInput',
component: VoiceInputMock,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Voice input component for recording audio and converting speech to text. Features waveform visualization, recording timer (max 10 minutes), and audio-to-text conversion using js-audio-recorder.\n\n**Note:** This is a simplified mock for Storybook. The actual component requires microphone permissions and audio-to-text API.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof VoiceInputMock>
export default meta
type Story = StoryObj<typeof meta>
// Basic demo
const VoiceInputDemo = () => {
const [isRecording, setIsRecording] = useState(false)
const [transcription, setTranscription] = useState('')
const handleStartRecording = () => {
setIsRecording(true)
setTranscription('')
}
const handleConverted = (text: string) => {
setTranscription(text)
setIsRecording(false)
}
const handleCancel = () => {
setIsRecording(false)
setTranscription('')
}
return (
<div style={{ width: '600px' }}>
{!isRecording && (
<button
className="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white hover:bg-blue-700"
onClick={handleStartRecording}
>
🎤 Start Voice Recording
</button>
)}
{isRecording && (
<VoiceInputMock
onConverted={handleConverted}
onCancel={handleCancel}
/>
)}
{transcription && (
<div className="mt-4 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-xs font-medium text-gray-600">Transcription:</div>
<div className="text-sm text-gray-800">{transcription}</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: () => <VoiceInputDemo />,
}
// Recording state
export const RecordingState: Story = {
render: () => (
<div style={{ width: '600px' }}>
<VoiceInputMock
onConverted={() => console.log('Converted')}
onCancel={() => console.log('Cancelled')}
/>
<div className="mt-3 text-xs text-gray-500">
Recording in progress with live waveform visualization
</div>
</div>
),
}
// Real-world example - Chat input with voice
const ChatInputWithVoiceDemo = () => {
const [message, setMessage] = useState('')
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Chat Interface</h3>
{/* Existing messages */}
<div className="mb-4 h-64 space-y-3 overflow-y-auto">
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-sm text-white">
U
</div>
<div className="flex-1">
<div className="rounded-lg bg-gray-100 p-3 text-sm">
Hello! How can I help you today?
</div>
</div>
</div>
<div className="flex gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-500 text-sm text-white">
A
</div>
<div className="flex-1">
<div className="rounded-lg bg-blue-50 p-3 text-sm">
I can assist you with various tasks. What would you like to know?
</div>
</div>
</div>
</div>
{/* Input area */}
<div className="space-y-3">
{!isRecording ? (
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-gray-300 px-4 py-3 text-sm"
placeholder="Type a message..."
value={message}
onChange={e => setMessage(e.target.value)}
/>
<button
className="rounded-lg bg-gray-100 px-4 py-3 hover:bg-gray-200"
onClick={() => setIsRecording(true)}
title="Voice input"
>
🎤
</button>
<button className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700">
Send
</button>
</div>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setMessage(text)
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
</div>
</div>
)
}
export const ChatInputWithVoice: Story = {
render: () => <ChatInputWithVoiceDemo />,
}
// Real-world example - Search with voice
const SearchWithVoiceDemo = () => {
const [searchQuery, setSearchQuery] = useState('')
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Voice Search</h3>
{!isRecording ? (
<div className="flex gap-2">
<div className="relative flex-1">
<input
type="text"
className="w-full rounded-lg border border-gray-300 px-4 py-3 pl-10 text-sm"
placeholder="Search or use voice..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
🔍
</span>
</div>
<button
className="rounded-lg bg-blue-600 px-4 py-3 text-white hover:bg-blue-700"
onClick={() => setIsRecording(true)}
>
🎤 Voice Search
</button>
</div>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setSearchQuery(text)
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
{searchQuery && !isRecording && (
<div className="mt-4 rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-xs font-medium text-blue-900">
Searching for: <strong>{searchQuery}</strong>
</div>
</div>
)}
</div>
)
}
export const SearchWithVoice: Story = {
render: () => <SearchWithVoiceDemo />,
}
// Real-world example - Note taking
const NoteTakingDemo = () => {
const [notes, setNotes] = useState<string[]>([])
const [isRecording, setIsRecording] = useState(false)
return (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Voice Notes</h3>
<span className="text-sm text-gray-500">{notes.length} notes</span>
</div>
<div className="mb-4">
{!isRecording ? (
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500 px-4 py-3 font-medium text-white hover:bg-red-600"
onClick={() => setIsRecording(true)}
>
<span className="text-xl">🎤</span>
Record Voice Note
</button>
) : (
<VoiceInputMock
onConverted={(text: string) => {
setNotes([...notes, text])
setIsRecording(false)
}}
onCancel={() => setIsRecording(false)}
/>
)}
</div>
<div className="max-h-80 space-y-2 overflow-y-auto">
{notes.length === 0 ? (
<div className="py-12 text-center text-gray-400">
No notes yet. Click the button above to start recording.
</div>
) : (
notes.map((note, index) => (
<div key={index} className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-1 text-xs text-gray-500">Note {index + 1}</div>
<div className="text-sm text-gray-800">{note}</div>
</div>
<button
className="text-gray-400 hover:text-red-500"
onClick={() => setNotes(notes.filter((_, i) => i !== index))}
>
×
</button>
</div>
</div>
))
)}
</div>
</div>
)
}
export const NoteTaking: Story = {
render: () => <NoteTakingDemo />,
}
// Real-world example - Form with voice
const FormWithVoiceDemo = () => {
const [formData, setFormData] = useState({
name: '',
description: '',
})
const [activeField, setActiveField] = useState<'name' | 'description' | null>(null)
return (
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Create Product</h3>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Product Name
</label>
{activeField === 'name' ? (
<VoiceInputMock
onConverted={(text: string) => {
setFormData({ ...formData, name: text })
setActiveField(null)
}}
onCancel={() => setActiveField(null)}
/>
) : (
<div className="flex gap-2">
<input
type="text"
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm"
placeholder="Enter product name..."
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
<button
className="rounded-lg bg-gray-100 px-3 py-2 hover:bg-gray-200"
onClick={() => setActiveField('name')}
>
🎤
</button>
</div>
)}
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
Description
</label>
{activeField === 'description' ? (
<VoiceInputMock
onConverted={(text: string) => {
setFormData({ ...formData, description: text })
setActiveField(null)
}}
onCancel={() => setActiveField(null)}
/>
) : (
<div className="space-y-2">
<textarea
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
rows={4}
placeholder="Enter product description..."
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
<button
className="w-full rounded-lg bg-gray-100 px-3 py-2 text-sm hover:bg-gray-200"
onClick={() => setActiveField('description')}
>
🎤 Use Voice Input
</button>
</div>
)}
</div>
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Create Product
</button>
</div>
</div>
)
}
export const FormWithVoice: Story = {
render: () => <FormWithVoiceDemo />,
}
// Features showcase
export const FeaturesShowcase: Story = {
render: () => (
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold">Voice Input Features</h3>
<div className="mb-6">
<VoiceInputMock
onConverted={() => undefined}
onCancel={() => undefined}
/>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900">🎤 Audio Recording</div>
<ul className="space-y-1 text-xs text-blue-800">
<li> Uses js-audio-recorder for browser-based recording</li>
<li> 16kHz sample rate, 16-bit, mono channel</li>
<li> Converts to MP3 format for transmission</li>
</ul>
</div>
<div className="rounded-lg bg-green-50 p-4">
<div className="mb-2 text-sm font-medium text-green-900">📊 Waveform Visualization</div>
<ul className="space-y-1 text-xs text-green-800">
<li> Real-time audio level display using Canvas API</li>
<li> Animated bars showing voice amplitude</li>
<li> Visual feedback during recording</li>
</ul>
</div>
<div className="rounded-lg bg-purple-50 p-4">
<div className="mb-2 text-sm font-medium text-purple-900"> Time Limits</div>
<ul className="space-y-1 text-xs text-purple-800">
<li> Maximum recording duration: 10 minutes (600 seconds)</li>
<li> Timer turns red after 8:20 (500 seconds)</li>
<li> Automatic stop at max duration</li>
</ul>
</div>
<div className="rounded-lg bg-orange-50 p-4">
<div className="mb-2 text-sm font-medium text-orange-900">🔄 Audio-to-Text Conversion</div>
<ul className="space-y-1 text-xs text-orange-800">
<li> Server-side speech-to-text processing</li>
<li> Optional word timestamps support</li>
<li> Loading state during conversion</li>
</ul>
</div>
</div>
</div>
),
}