Skip to content
This repository has been archived by the owner on Jan 25, 2025. It is now read-only.

Commit

Permalink
merge(#25): 支持构造聊天消息
Browse files Browse the repository at this point in the history
feat(debug): 支持构造聊天消息
  • Loading branch information
bietiaop authored Jan 2, 2025
2 parents c840d49 + 7b5255e commit ee09cb7
Show file tree
Hide file tree
Showing 31 changed files with 1,642 additions and 46 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@nextui-org/chip": "^2.2.4",
"@nextui-org/code": "2.2.4",
"@nextui-org/dropdown": "^2.3.7",
"@nextui-org/form": "^2.1.7",
"@nextui-org/image": "^2.2.3",
"@nextui-org/input": "2.4.6",
"@nextui-org/kbd": "2.2.4",
Expand Down Expand Up @@ -56,12 +57,14 @@
"motion": "^11.15.0",
"qface": "^1.4.1",
"qrcode.react": "^4.2.0",
"quill": "^2.0.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^5.4.0",
"react-quilljs": "^2.0.5",
"react-redux": "^9.2.0",
"react-responsive": "^10.0.0",
"react-router-dom": "7.1.0",
Expand All @@ -78,6 +81,7 @@
"@react-types/shared": "^3.26.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/event-source-polyfill": "^1.0.5",
"@types/fabric": "^5.3.9",
"@types/node": "22.10.2",
"@types/react": "19.0.2",
"@types/react-dom": "19.0.2",
Expand Down
254 changes: 254 additions & 0 deletions src/components/chat_input/components/audio_insert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { Button } from '@nextui-org/button'
import { Input } from '@nextui-org/input'
import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover'
import { Tooltip } from '@nextui-org/tooltip'
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import { FaMicrophone } from 'react-icons/fa6'
import { IoMic } from 'react-icons/io5'
import { MdEdit, MdUpload } from 'react-icons/md'

import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'

import { isURI } from '@/utils/url'

import type { OB11Segment } from '@/types/onebot'

const AudioInsert = () => {
const [audioUrl, setAudioUrl] = useState<string>('')
const audioInputRef = useRef<HTMLInputElement>(null)
const showStructuredMessage = useShowStructuredMessage()
const showAudioSegment = (file: string) => {
const messages: OB11Segment[] = [
{
type: 'record',
data: {
file: file
}
}
]
showStructuredMessage(messages)
}

const [isRecording, setIsRecording] = useState(false)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const [audioPreview, setAudioPreview] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const streamRef = useRef<MediaStream | null>(null)
const [recordingTime, setRecordingTime] = useState(0)
const recordingIntervalRef = useRef<NodeJS.Timeout | null>(null)

useEffect(() => {
if (isRecording) {
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
streamRef.current = stream
const recorder = new MediaRecorder(stream)
mediaRecorderRef.current = recorder
recorder.start()
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
recorder.onstop = () => {
if (audioChunksRef.current.length > 0) {
const audioBlob = new Blob(audioChunksRef.current, {
type: 'audio/wav'
})
const reader = new FileReader()
reader.readAsDataURL(audioBlob)
reader.onloadend = () => {
const base64Audio = reader.result as string
setAudioPreview(base64Audio)
setShowPreview(true)
}
audioChunksRef.current = []
}
stream.getTracks().forEach((track) => track.stop())
}
})
recordingIntervalRef.current = setInterval(() => {
setRecordingTime((prevTime) => prevTime + 1)
}, 1000)
} else {
mediaRecorderRef.current?.stop()
if (recordingIntervalRef.current) {
clearInterval(recordingIntervalRef.current)
recordingIntervalRef.current = null
}
}
}, [isRecording])

const startRecording = () => {
setAudioPreview(null)
setShowPreview(false)
setRecordingTime(0)
setIsRecording(true)
}

const stopRecording = () => {
setIsRecording(false)
}

const handleShowPreview = () => {
if (audioPreview) {
showAudioSegment(audioPreview)
}
}

const formatTime = (time: number) => {
const minutes = Math.floor(time / 60)
const seconds = time % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

return (
<>
<Popover>
<Tooltip content="发送音频">
<div className="max-w-fit">
<PopoverTrigger>
<Button color="danger" variant="flat" isIconOnly radius="full">
<IoMic className="text-xl" />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className="flex-row gap-2 p-4">
<Tooltip content="上传音频">
<Button
className="text-lg"
color="danger"
isIconOnly
variant="flat"
radius="full"
onPress={() => {
audioInputRef?.current?.click()
}}
>
<MdUpload />
</Button>
</Tooltip>
<Popover>
<Tooltip content="输入音频地址">
<div className="max-w-fit">
<PopoverTrigger tooltip="输入音频地址">
<Button
className="text-lg"
color="danger"
isIconOnly
variant="flat"
radius="full"
>
<MdEdit />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className="flex-row gap-1 p-2">
<Input
value={audioUrl}
onChange={(e) => setAudioUrl(e.target.value)}
placeholder="请输入音频地址"
/>
<Button
color="danger"
variant="flat"
isIconOnly
radius="full"
onPress={() => {
if (!isURI(audioUrl)) {
toast.error('请输入正确的音频地址')
return
}
showAudioSegment(audioUrl)
setAudioUrl('')
}}
>
<FaMicrophone />
</Button>
</PopoverContent>
</Popover>
<Popover>
<Tooltip content="录制音频">
<div className="max-w-fit">
<PopoverTrigger>
<Button
className="text-lg"
color="danger"
isIconOnly
variant="flat"
radius="full"
>
<IoMic />
</Button>
</PopoverTrigger>
</div>
</Tooltip>
<PopoverContent className="flex-col gap-2 p-4">
<div className="flex gap-2">
<Button
color={isRecording ? 'danger' : 'danger'}
variant="flat"
onPress={isRecording ? stopRecording : startRecording}
>
{isRecording ? '停止录制' : '开始录制'}
</Button>
{showPreview && audioPreview && (
<Button
color="danger"
variant="flat"
onPress={handleShowPreview}
>
查看消息
</Button>
)}
</div>
{(isRecording || audioPreview) && (
<div className="flex gap-1 items-center">
<span
className={clsx(
'w-4 h-4 rounded-full',
isRecording
? 'animate-pulse bg-danger-400'
: 'bg-success-400'
)}
></span>
<span>录制时长: {formatTime(recordingTime)}</span>
</div>
)}
{showPreview && audioPreview && (
<audio controls src={audioPreview} />
)}
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>

<input
type="file"
ref={audioInputRef}
hidden
accept="audio/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) {
return
}
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event) => {
const dataURL = event.target?.result
showAudioSegment(dataURL as string)
e.target.value = ''
}
}}
/>
</>
)
}

export default AudioInsert
31 changes: 31 additions & 0 deletions src/components/chat_input/components/dice_insert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Button } from '@nextui-org/button'
import { Tooltip } from '@nextui-org/tooltip'
import { BsDice3Fill } from 'react-icons/bs'

import useShowStructuredMessage from '@/hooks/use_show_strcuted_message'

const DiceInsert = () => {
const showStructuredMessage = useShowStructuredMessage()

return (
<Tooltip content="发送骰子">
<Button
color="danger"
variant="flat"
isIconOnly
radius="full"
onPress={() => {
showStructuredMessage([
{
type: 'dice'
}
])
}}
>
<BsDice3Fill className="text-lg" />
</Button>
</Tooltip>
)
}

export default DiceInsert
Loading

0 comments on commit ee09cb7

Please sign in to comment.