feat(desktop): sync knowledge, recordings and shared settings; play any recording
Knowledge documents travel as source-text chunks; each surface embeds them with its own model, the server index is requested through embed-chunks, and documents from the phone are stored without a file and indexed from their chunks. Chunk text is now kept when local embedding fails, so reindexing no longer needs the original file. Recordings upload to the mobile storage contract (audio bucket under the user's folder plus an audio_files row, 50 MiB cap, a Settings > Cloud toggle) and are removed with their record. The history card gains a play button that uses the local file or, for phone recordings, a signed URL. Language (ko/en), system/light/dark theme, auto-polish and the active user command follow the phone's user_settings with its revision rule; changes that arrive from the phone reach the open window.
This commit is contained in:
parent
cee4ab9317
commit
9a8f7e6aa6
34 changed files with 1405 additions and 64 deletions
|
|
@ -1,9 +1,9 @@
|
|||
// src/renderer/components/shared/HistoryEntryCard.tsx
|
||||
// High-End Agency Dictation History Card with Audio Telemetry & Instant Actions
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material'
|
||||
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2, Star } from 'lucide-react'
|
||||
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2, Star, Play, Square } from 'lucide-react'
|
||||
import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
|
@ -40,6 +40,56 @@ export function HistoryEntryCard({
|
|||
const [summaryLoading, setSummaryLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const hasSummary = !!entry.summaryText
|
||||
const [audioState, setAudioState] = useState<'idle' | 'loading' | 'playing' | 'unavailable'>('idle')
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const objectUrlRef = useRef<string | null>(null)
|
||||
|
||||
const stopAudio = useCallback((next: 'idle' | 'unavailable' = 'idle') => {
|
||||
audioRef.current?.pause()
|
||||
audioRef.current = null
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current)
|
||||
objectUrlRef.current = null
|
||||
}
|
||||
setAudioState(next)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopAudio(), [stopAudio])
|
||||
|
||||
// 이 기기 녹음은 파일 바이트로, 모바일·웹 녹음은 서명 URL로 재생한다.
|
||||
const handleToggleAudio = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (audioState === 'playing') {
|
||||
stopAudio()
|
||||
return
|
||||
}
|
||||
setAudioState('loading')
|
||||
try {
|
||||
const result = await window.electronAPI.history.getAudio({ id: entry.id })
|
||||
if (!result.success || !result.data) {
|
||||
stopAudio('unavailable')
|
||||
return
|
||||
}
|
||||
let src: string
|
||||
if (result.data.kind === 'local') {
|
||||
src = URL.createObjectURL(new Blob([result.data.bytes.slice()], { type: result.data.mimeType }))
|
||||
objectUrlRef.current = src
|
||||
} else {
|
||||
src = result.data.url
|
||||
}
|
||||
const audio = new Audio(src)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => stopAudio()
|
||||
audio.onerror = () => stopAudio('unavailable')
|
||||
await audio.play()
|
||||
setAudioState('playing')
|
||||
} catch {
|
||||
stopAudio('unavailable')
|
||||
}
|
||||
},
|
||||
[audioState, entry.id, stopAudio],
|
||||
)
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
if (!showTags) return
|
||||
|
|
@ -163,6 +213,39 @@ export function HistoryEntryCard({
|
|||
|
||||
{/* Quick Action Buttons */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
|
||||
{entry.duration > 0 && (
|
||||
<Tooltip
|
||||
title={
|
||||
audioState === 'playing'
|
||||
? t('history.stopAudio')
|
||||
: audioState === 'unavailable'
|
||||
? t('history.audioUnavailable')
|
||||
: t('history.playAudio')
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={audioState === 'playing' ? t('history.stopAudio') : t('history.playAudio')}
|
||||
disabled={audioState === 'loading'}
|
||||
onClick={(e) => void handleToggleAudio(e)}
|
||||
sx={{
|
||||
p: 0.6,
|
||||
color:
|
||||
audioState === 'playing'
|
||||
? d3roPalette.accent.main
|
||||
: audioState === 'unavailable'
|
||||
? d3roPalette.tag.red
|
||||
: d3roPalette.text.inactive,
|
||||
bgcolor: audioState === 'playing' ? d3roPalette.accent.dim : 'transparent',
|
||||
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
|
||||
}}
|
||||
>
|
||||
{audioState === 'playing' ? <Square size={13} /> : <Play size={14} />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggleFavorite && (
|
||||
<Tooltip title={entry.isFavorite ? t('history.unfavorite') : t('history.favorite')}>
|
||||
<IconButton
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue