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:
Yun Chan 2026-09-27 14:44:56 +09:00
parent cee4ab9317
commit 9a8f7e6aa6
34 changed files with 1405 additions and 64 deletions

View file

@ -9,7 +9,7 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
import { getTheme } from '@d3ro/ui/theme'
import { I18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
import { I18nProvider, useI18n, type I18nStorage, type Locale } from '@d3ro/i18n'
import { AppLayout } from './components/AppLayout'
import { UpgradePromptModal } from './components/UpgradePromptModal'
import { startSystemAudioCapture, stopSystemAudioCapture } from './utils/systemAudioCapture'
@ -26,6 +26,21 @@ const electronI18nStorage: I18nStorage = {
}
}
/** 다른 기기(모바일)에서 바꾼 언어가 동기화로 들어오면 화면 언어도 바꾼다. */
function SyncedLocale(): null {
const { locale, setLocale } = useI18n()
useEffect(
() =>
window.electronAPI.config.onChanged((e: ConfigChangedEvent) => {
if (e.key === 'language' && typeof e.value === 'string' && e.value !== locale) {
setLocale(e.value as Locale)
}
}),
[locale, setLocale],
)
return null
}
export function App(): React.ReactElement {
const [themeMode, setThemeMode] = useState<ThemeMode>('auto')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
@ -76,6 +91,7 @@ export function App(): React.ReactElement {
return (
<I18nProvider storage={electronI18nStorage}>
<SyncedLocale />
<ThemeProvider theme={theme}>
<CssBaseline />
<AppLayout />

View file

@ -3,7 +3,7 @@
// 로그인 상태 / 지금 동기화 / 마지막 동기화 / 올릴 변경·거부된 변경 건수
import { useCallback, useEffect, useState } from 'react'
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
import { Box, Button, Stack, Alert, CircularProgress, FormControlLabel, Switch } from '@mui/material'
import { Cloud, CloudCheck, Globe, GitBranch, RefreshCw } from 'lucide-react'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
@ -32,6 +32,7 @@ export function CloudSyncSection(): React.ReactElement {
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [audioSync, setAudioSync] = useState(true)
const refreshState = useCallback(async () => {
const r = await window.electronAPI.cloudSync.getState()
@ -41,6 +42,9 @@ export function CloudSyncSection(): React.ReactElement {
// 초기 상태 로드 + 이벤트 구독
useEffect(() => {
void refreshState()
void window.electronAPI.config.get({ key: 'cloudSyncAudio' }).then((r) => {
if (r.success) setAudioSync(r.data !== false)
})
const unsubAuth = window.electronAPI.cloudSync.onAuthChanged(() => {
void refreshState()
@ -60,6 +64,11 @@ export function CloudSyncSection(): React.ReactElement {
}
}, [refreshState])
function handleAudioSyncChange(enabled: boolean): void {
setAudioSync(enabled)
void window.electronAPI.config.set({ key: 'cloudSyncAudio', value: enabled })
}
async function handleSignIn(provider: 'google' | 'github'): Promise<void> {
setError(null)
setBusy(true)
@ -174,6 +183,16 @@ export function CloudSyncSection(): React.ReactElement {
)}
</Stack>
<Box>
<FormControlLabel
control={<Switch checked={audioSync} onChange={(e) => handleAudioSyncChange(e.target.checked)} />}
label={t('cloudSync.audio')}
/>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, lineHeight: 1.6, pl: 0.5 }}>
{t('cloudSync.audioDesc')}
</Box>
</Box>
<Stack direction="row" spacing={1}>
<Button
variant="contained"

View file

@ -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