mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-10 07:42:39 +02:00
feat: add voice note recording to Knowledge sidebar
Add a VoiceNoteButton to the Knowledge section quick actions bar that records audio from the microphone using MediaRecorder API and saves recordings as .m4a files in ~/.rowboat/voice_memos/. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
69c44e1033
commit
5c4b7ca182
1 changed files with 91 additions and 0 deletions
|
|
@ -12,8 +12,10 @@ import {
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
@ -141,6 +143,94 @@ export function SidebarContentPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voice Note Recording Button
|
||||||
|
function VoiceNoteButton() {
|
||||||
|
const [isRecording, setIsRecording] = React.useState(false)
|
||||||
|
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
|
||||||
|
const chunksRef = React.useRef<Blob[]>([])
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('audio/mp4')
|
||||||
|
? 'audio/mp4'
|
||||||
|
: 'audio/webm'
|
||||||
|
const recorder = new MediaRecorder(stream, { mimeType })
|
||||||
|
chunksRef.current = []
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunksRef.current.push(e.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onstop = async () => {
|
||||||
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
|
const blob = new Blob(chunksRef.current, { type: mimeType })
|
||||||
|
const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm'
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const filename = `voice-memo-${timestamp}.${ext}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.ipc.invoke('workspace:mkdir', {
|
||||||
|
path: 'voice_memos',
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const arrayBuffer = await blob.arrayBuffer()
|
||||||
|
const base64 = btoa(
|
||||||
|
new Uint8Array(arrayBuffer).reduce(
|
||||||
|
(data, byte) => data + String.fromCharCode(byte),
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await window.ipc.invoke('workspace:writeFile', {
|
||||||
|
path: `voice_memos/${filename}`,
|
||||||
|
data: base64,
|
||||||
|
opts: { encoding: 'base64' },
|
||||||
|
})
|
||||||
|
toast('Voice memo saved', 'success')
|
||||||
|
} catch {
|
||||||
|
toast('Failed to save voice memo', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.start()
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
setIsRecording(true)
|
||||||
|
} catch {
|
||||||
|
toast('Could not access microphone', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopRecording = () => {
|
||||||
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
mediaRecorderRef.current = null
|
||||||
|
setIsRecording(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={isRecording ? stopRecording : startRecording}
|
||||||
|
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Square className="size-4 fill-red-500 text-red-500 animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<Mic className="size-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{isRecording ? 'Stop Recording' : 'Voice Note'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Knowledge Section
|
// Knowledge Section
|
||||||
function KnowledgeSection({
|
function KnowledgeSection({
|
||||||
tree,
|
tree,
|
||||||
|
|
@ -181,6 +271,7 @@ function KnowledgeSection({
|
||||||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
|
<VoiceNoteButton />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue