feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동

- npm workspaces 루트 (apps/*, packages/*) 세팅
- V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar,
  scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts,
  tsconfig.node.json, tsconfig.web.json)
- apps/desktop/package.json 신규 (name=@d3ro/desktop)
- productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로
  %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장)
- 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지
  (typescript, eslint, prettier)
- turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase)
- memory/project_status.md 생성 (규칙 13)

검증:
- npm run typecheck 통과
- npm run build 통과 (electron-vite main+preload+renderer)
- npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
yunchan8804 2026-04-08 14:04:41 +09:00
parent 3a160b9032
commit 45a580878a
178 changed files with 214 additions and 0 deletions

View file

@ -0,0 +1,583 @@
// src/renderer/pages/CommandsPage.tsx
// 커스텀 LLM 명령어 관리 + Phase 10: 음성 키워드 + LLM 체인
// 3 섹션: 명령어 목록 / 음성 키워드 / LLM 체인
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Chip, Select, MenuItem, FormControl, InputLabel } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import LinkIcon from '@mui/icons-material/Link'
import { MetalCard, PhosphorText, Led, ScreenPanel } from '../components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
import { useI18n } from '../i18n'
import { TemplateSection } from '../components/TemplateSection'
import type { VoiceCommandRule, VoiceCommandKeyword, KeywordMatchMode, LLMChain, ChainStep } from '@shared/types'
interface CustomInstruction {
id: string
name: string
description: string
prompt: string
icon: string
isBuiltin: boolean
order: number
}
export function CommandsPage(): React.ReactElement {
const { t } = useI18n()
const [instructions, setInstructions] = useState<CustomInstruction[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [formName, setFormName] = useState('')
const [formDesc, setFormDesc] = useState('')
const [formPrompt, setFormPrompt] = useState('')
const [activeId, setActiveId] = useState<string | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
const [instrResult, configResult] = await Promise.all([
window.electronAPI.instruction.getAll(),
window.electronAPI.config.getAll(),
])
if (instrResult.success) {
setInstructions(instrResult.data as CustomInstruction[])
}
if (configResult.success) {
const cfg = configResult.data as Record<string, unknown>
setActiveId((cfg['activeInstructionId'] as string) ?? null)
}
setLoading(false)
}, [])
useEffect(() => {
loadData()
const unsub = window.electronAPI.app.onDataChanged((data) => {
if (data.type === 'command-changed' && data.activeId) {
setActiveId(data.activeId)
}
loadData()
})
return unsub
}, [loadData])
const handleActivate = (id: string) => {
const newId = activeId === id ? null : id
setActiveId(newId)
if (newId) {
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: newId as never })
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' })
} else {
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: '' as never })
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
}
}
const openAdd = () => {
setEditId(null)
setFormName('')
setFormDesc('')
setFormPrompt(t('commands.defaultPrompt'))
setDialogOpen(true)
}
const openEdit = (inst: CustomInstruction) => {
setEditId(inst.id)
setFormName(inst.name)
setFormDesc(inst.description)
setFormPrompt(inst.prompt)
setDialogOpen(true)
}
const handleSave = async () => {
if (!formName.trim()) return
if (editId) {
await window.electronAPI.instruction.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
} else {
await window.electronAPI.instruction.create({
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
}
setDialogOpen(false)
loadData()
}
const handleDelete = async (id: string) => {
await window.electronAPI.instruction.delete({ id })
if (activeId === id) {
setActiveId(null)
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
}
loadData()
}
const activeInstruction = instructions.find((i) => i.id === activeId)
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<PageHeader
title={t('commands.title').toUpperCase()}
count={t('commands.count', { count: instructions.length }).toUpperCase()}
action={
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
{t('commands.add').toUpperCase()}
</Button>
}
/>
{/* 활성 명령어 — ScreenPanel로 극적 표시 */}
<ScreenPanel height={72}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%' }}>
<Led color={activeInstruction ? 'amber' : 'off'} size={8} pulse={!!activeInstruction} />
<Box>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('commands.activeCommand').toUpperCase()}
</PhosphorText>
<PhosphorText
variant="value"
sx={{
fontSize: d3roTypo.heading.size,
color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled,
}}
>
{activeInstruction ? activeInstruction.name : t('commands.none')}
</PhosphorText>
</Box>
</Box>
</ScreenPanel>
{/* 명령어 목록 */}
<Box sx={{ mt: 2 }}>
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
) : instructions.length === 0 ? (
<EmptyStateCard message={t('commands.noCommands').toUpperCase()} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{instructions.map((inst) => {
const isActive = inst.id === activeId
return (
<Box
key={inst.id}
onClick={() => handleActivate(inst.id)}
sx={{
cursor: 'pointer',
borderRadius: '22px',
border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent',
transition: 'border-color 0.15s ease',
}}
>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
{isActive ? (
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
) : (
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
)}
<Box>
<Box sx={{
fontSize: d3roTypo.body.size,
fontWeight: d3roTypo.heading.weight,
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary,
}}>
{inst.name}
</Box>
<Box sx={{
fontSize: d3roTypo.meta.size,
color: d3roPalette.text.inactive,
mt: 0.25,
}}>
{inst.description}
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => openEdit(inst)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{!inst.isBuiltin && (
<IconButton
size="small"
onClick={() => handleDelete(inst.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
</Box>
</MetalCard>
</Box>
)
})}
</Box>
)}
</Box>
{/* ── 음성 키워드 섹션 ── */}
<VoiceKeywordsSection instructions={instructions} />
{/* ── LLM 체인 섹션 ── */}
<ChainSection instructions={instructions} />
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
{editId ? t('commands.editTitle') : t('commands.addTitle')}
</DialogTitle>
<DialogContent>
<TextField label={t('commands.name')} value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label={t('commands.description')} value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
<TextField label={t('commands.promptTemplate')} value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText={t('commands.promptHelp')} />
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>{t('common.save')}</Button>
</DialogActions>
</Dialog>
</Box>
)
}
/*
섹션: 명령어별
*/
function VoiceKeywordsSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
const { t } = useI18n()
const [rules, setRules] = useState<VoiceCommandRule[]>([])
const [editingRule, setEditingRule] = useState<string | null>(null)
const [keywordInput, setKeywordInput] = useState('')
const loadRules = useCallback(async () => {
const result = await window.electronAPI.voiceCommand.getAll()
if (result.success) setRules(result.data)
}, [])
useEffect(() => { loadRules() }, [loadRules])
const handleAddKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[]) => {
const trimmed = keywordInput.trim()
if (!trimmed) return
const updated: VoiceCommandKeyword[] = [...existing, { keyword: trimmed, matchMode: 'prefix' as KeywordMatchMode }]
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
setKeywordInput('')
loadRules()
}, [keywordInput, loadRules])
const handleRemoveKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[], idx: number) => {
const updated = existing.filter((_, i) => i !== idx)
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
loadRules()
}, [loadRules])
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('voiceCommand.title').toUpperCase()}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
</Box>
{rules.length === 0 ? (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size }}>
{t('voiceCommand.noKeywords').toUpperCase()}
</PhosphorText>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{rules.map(rule => (
<MetalCard key={rule.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Led color={rule.enabled ? 'green' : 'off'} size={6} />
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight, minWidth: 100 }}>
{getInstructionName(rule.instructionId)}
</PhosphorText>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, flex: 1 }}>
{rule.keywords.map((kw, idx) => (
<Chip
key={`${kw.keyword}-${idx}`}
label={kw.keyword}
size="small"
onDelete={() => handleRemoveKeyword(rule.instructionId, rule.keywords, idx)}
sx={{
height: 20,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.orangeBg,
color: d3roPalette.tag.orange,
borderRadius: d3roRadius.small,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.orange, fontSize: 12 },
}}
/>
))}
{editingRule === rule.instructionId ? (
<Box
component="input"
value={keywordInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setKeywordInput(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) }
if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') }
}}
onBlur={() => { if (!keywordInput.trim()) setEditingRule(null) }}
autoFocus
placeholder={t('voiceCommand.keywordPlaceholder')}
sx={{
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
px: 1, py: 0.25,
borderRadius: d3roRadius.xs,
outline: 'none', width: 120,
'&:focus': { borderColor: d3roPalette.accent.amber },
}}
/>
) : (
<IconButton
size="small"
onClick={() => { setEditingRule(rule.instructionId); setKeywordInput('') }}
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
>
<AddIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
</Box>
)
}
/*
LLM 섹션: 멀티
*/
function ChainSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
const { t } = useI18n()
const [chains, setChains] = useState<LLMChain[]>([])
const [dialogOpen, setDialogOpen] = useState(false)
const [editChain, setEditChain] = useState<LLMChain | null>(null)
const [formName, setFormName] = useState('')
const [formSteps, setFormSteps] = useState<ChainStep[]>([])
const loadChains = useCallback(async () => {
const result = await window.electronAPI.chain.getAll()
if (result.success) setChains(result.data)
}, [])
useEffect(() => { loadChains() }, [loadChains])
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
const openAdd = () => {
setEditChain(null)
setFormName('')
setFormSteps([{ instructionId: instructions[0]?.id ?? '', inputSource: 'original' }])
setDialogOpen(true)
}
const openEdit = (chain: LLMChain) => {
setEditChain(chain)
setFormName(chain.name)
setFormSteps([...chain.steps])
setDialogOpen(true)
}
const handleSave = async () => {
if (!formName.trim() || formSteps.length === 0) return
const validSteps = formSteps.filter(s => s.instructionId)
if (validSteps.length === 0) return
if (editChain) {
await window.electronAPI.chain.update({ id: editChain.id, name: formName.trim(), steps: validSteps })
} else {
await window.electronAPI.chain.create({ name: formName.trim(), steps: validSteps })
}
setDialogOpen(false)
loadChains()
}
const handleDelete = async (id: string) => {
await window.electronAPI.chain.delete({ id })
loadChains()
}
const handleExecute = async (chainId: string) => {
const text = await navigator.clipboard.readText()
if (text) {
await window.electronAPI.chain.execute({ chainId, inputText: text })
}
}
const addStep = () => {
setFormSteps(prev => [...prev, {
instructionId: instructions[0]?.id ?? '',
inputSource: prev.length > 0 ? 'previous' : 'original',
}])
}
const removeStep = (idx: number) => {
setFormSteps(prev => prev.filter((_, i) => i !== idx))
}
const updateStep = (idx: number, field: keyof ChainStep, value: string) => {
setFormSteps(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
}
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('chain.title').toUpperCase()}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<Button variant="outlined" startIcon={<LinkIcon />} onClick={openAdd} size="small" sx={{ fontSize: d3roTypo.micro.size }}>
{t('chain.add').toUpperCase()}
</Button>
</Box>
{chains.length === 0 ? (
<EmptyStateCard message={t('chain.noChains').toUpperCase()} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{chains.map(chain => (
<MetalCard key={chain.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>
{chain.name}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
{chain.steps.map((step, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{idx > 0 && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size }}></PhosphorText>
)}
<Chip
label={getInstructionName(step.instructionId)}
size="small"
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.xs,
}}
/>
</Box>
))}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => handleExecute(chain.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.green } }}>
<PlayArrowIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => openEdit(chain)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(chain.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
{/* 체인 편집 다이얼로그 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
{editChain ? t('chain.editTitle') : t('chain.addTitle')}
</DialogTitle>
<DialogContent>
<TextField
label={t('chain.name')}
value={formName}
onChange={e => setFormName(e.target.value)}
fullWidth autoFocus sx={{ mt: 1 }}
/>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mt: 3, mb: 1, display: 'block' }}>
{t('chain.steps').toUpperCase()}
</PhosphorText>
{formSteps.map((step, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, minWidth: 24 }}>
{idx + 1}.
</PhosphorText>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel>{t('chain.selectInstruction')}</InputLabel>
<Select
value={step.instructionId}
label={t('chain.selectInstruction')}
onChange={e => updateStep(idx, 'instructionId', e.target.value)}
>
{instructions.map(inst => (
<MenuItem key={inst.id} value={inst.id}>{inst.name}</MenuItem>
))}
</Select>
</FormControl>
{idx > 0 && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>{t('chain.inputSource')}</InputLabel>
<Select
value={step.inputSource}
label={t('chain.inputSource')}
onChange={e => updateStep(idx, 'inputSource', e.target.value)}
>
<MenuItem value="original">{t('chain.inputSource.original')}</MenuItem>
<MenuItem value="previous">{t('chain.inputSource.previous')}</MenuItem>
</Select>
</FormControl>
)}
<IconButton size="small" onClick={() => removeStep(idx)} disabled={formSteps.length <= 1}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
<Button onClick={addStep} startIcon={<AddIcon />} size="small" sx={{ mt: 1 }}>
{t('chain.addStep')}
</Button>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formSteps.length === 0}>{t('common.save')}</Button>
</DialogActions>
</Dialog>
{/* ── Phase 12.3: 딕테이션 템플릿 ── */}
<TemplateSection />
</Box>
)
}

View file

@ -0,0 +1,399 @@
// src/renderer/pages/DashboardPage.tsx
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box } from '@mui/material'
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '../components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { FileDropZone } from '../components/FileDropZone'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@shared/types'
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
const { t, formatTime, formatRelativeDate } = useI18n()
const [stats, setStats] = useState<StatsSummary | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([])
const [ollamaConnected, setOllamaConnected] = useState(false)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
const [audioLevel, setAudioLevel] = useState(0)
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [licenseTier, setLicenseTier] = useState<LicenseTier>('free')
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
const loadData = useCallback(() => {
window.electronAPI.stats.getSummary().then((r) => {
if (r.success) setStats(r.data)
})
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
})
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
if (r.success) setHistory(r.data.entries)
})
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
if (r.success && r.data) setDictationBinding(r.data)
})
window.electronAPI.caption.getState().then((r) => {
if (r.success) setCaptionState(r.data)
})
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setLicenseTier(r.data.tier)
})
window.electronAPI.license.getAllUsage().then((r) => {
if (r.success) setUsageQuotas(r.data)
})
}, [])
useEffect(() => {
loadData()
const interval = setInterval(loadData, 30000)
const unsub = window.electronAPI.app.onDataChanged(() => { loadData() })
const unsubCaption = window.electronAPI.caption.onStateChanged((state) => {
setCaptionState(state)
})
const unsubAudio = window.electronAPI.voice.onAudioLevel((e) => {
setAudioLevel(e.level)
})
// 오디오 이벤트가 없을 때 서서히 감쇠
audioDecayRef.current = setInterval(() => {
setAudioLevel(prev => prev > 0.01 ? prev * 0.85 : 0)
}, 100)
return () => {
clearInterval(interval)
if (audioDecayRef.current) clearInterval(audioDecayRef.current)
unsub(); unsubCaption(); unsubAudio()
}
}, [loadData])
// 날짜별 그룹핑
const groupedHistory = useMemo(() => {
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
for (const entry of history) {
const key = getDateKey(entry.createdAt)
if (!groups[key]) {
groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] }
}
groups[key].entries.push(entry)
}
return Object.values(groups)
}, [history, formatRelativeDate])
const services = useMemo(() => [
{ name: t('service.sttEngine'), status: t('service.ready'), ok: true },
{ name: t('service.ollamaLlm'), status: ollamaConnected ? t('service.connected') : t('service.offline'), ok: ollamaConnected },
{ name: t('service.hotkeyHook'), status: t('service.active'), ok: true },
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
], [t, ollamaConnected])
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
<InstrumentPanel
engravingLeft="D3RO-VOICE"
engravingRight="v1.0.0"
engravingBottom="LOCAL AI VOICE ASSISTANT"
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 180px',
gap: 2.5,
alignItems: 'stretch',
}}
>
{/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
<ScreenPanel height={200}>
{/* 상단 라벨 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<PhosphorText variant="label">
{activeView === 'stats'
? t('dashboard.sessionOverview').toUpperCase()
: t('dashboard.systemStatus').toUpperCase()}
</PhosphorText>
<PhosphorText variant="label">
{new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
</PhosphorText>
</Box>
{/* 중앙: 큰 수치 or 서비스 상태 */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{activeView === 'stats' ? (
<>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<PhosphorText variant="hero">
{stats?.todaySessionCount ?? 0}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.sessionsToday').toUpperCase()}
</PhosphorText>
</Box>
<PhosphorText variant="label" sx={{ mt: 0.5, color: d3roPalette.text.dimLabel }}>
{dictationBinding
? t('dashboard.pressToRecord', { key: dictationBinding.displayLabel.toUpperCase() }).toUpperCase()
: t('dashboard.hotkeyNotSet').toUpperCase()}
</PhosphorText>
</>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{services.map((svc) => (
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={svc.ok ? 'green' : 'red'} size={6} />
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
<PhosphorText
variant="value"
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
>
{svc.status}
</PhosphorText>
</Box>
))}
</Box>
)}
</Box>
{/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
{activeView === 'stats' && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Box>
<PhosphorText variant="label">{t('dashboard.words').toUpperCase()}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<PhosphorText variant="value">
{formatNumber(stats?.totalWordCount ?? 0)}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.total').toUpperCase()}
</PhosphorText>
</Box>
</Box>
<Box sx={{ textAlign: 'right' }}>
<PhosphorText variant="label">{t('dashboard.streak').toUpperCase()}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, justifyContent: 'flex-end' }}>
<PhosphorText variant="value">
{stats?.streakDays ?? 0}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.days').toUpperCase()}
</PhosphorText>
</Box>
</Box>
</Box>
)}
</ScreenPanel>
{/* 우측: 컨트롤 패널 */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
{/* LED 상태 클러스터 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
<Led color={ollamaConnected ? 'green' : 'red'} size={8} />
<Led color="amber" pulse size={8} />
</Box>
{/* 버튼 그룹 */}
<ButtonGroup>
<PhysicalButton
selected={activeView === 'stats'}
onClick={() => setActiveView('stats')}
sx={{ minWidth: 0 }}
>
{t('dashboard.stat').toUpperCase()}
</PhysicalButton>
<PhysicalButton
selected={activeView === 'status'}
onClick={() => setActiveView('status')}
sx={{ minWidth: 0 }}
>
{t('dashboard.sys').toUpperCase()}
</PhysicalButton>
</ButtonGroup>
</Box>
</Box>
</InstrumentPanel>
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
mt: 3,
}}
>
{[
{ label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) },
{ label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() },
{ label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() },
{ label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() },
].map((card) => (
<MetalCard key={card.label}>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{card.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
<PhosphorText variant="value" sx={{ fontSize: d3roTypo.title.size, fontWeight: 300 }}>
{card.value}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{card.unit}
</PhosphorText>
</Box>
</Box>
</MetalCard>
))}
</Box>
{/* ── 2.4. 사용량 바 (Free 티어) ─────────────── */}
{licenseTier === 'free' && usageQuotas.length > 0 && (
<Box sx={{ mt: 2 }}>
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('license.usageToday').toUpperCase()}
</PhosphorText>
{usageQuotas.map((q) => (
<Box key={q.feature} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">
{t(`license.feature.${q.feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
{q.limit === -1
? t('license.unlimited')
: `${q.used}/${q.limit}`}
</PhosphorText>
</Box>
{q.limit > 0 && (
<Box sx={{
height: 3,
borderRadius: '2px',
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: `${Math.min(100, (q.used / q.limit) * 100)}%`,
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
borderRadius: '2px',
transition: 'width 0.3s ease',
}} />
</Box>
)}
</Box>
))}
</Box>
</MetalCard>
</Box>
)}
{/* ── 2.5. 실시간 자막 토글 ────────────────── */}
<Box sx={{ mt: 3 }}>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led
color={captionState === 'active' ? 'green' : captionState === 'starting' || captionState === 'stopping' ? 'amber' : 'off'}
size={8}
pulse={captionState === 'active'}
/>
<Box>
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight }}>
{t('dashboard.caption').toUpperCase()}
</PhosphorText>
{captionState === 'active' && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size, color: d3roPalette.tag.green }}>
{t('dashboard.captionActive').toUpperCase()}
</PhosphorText>
)}
</Box>
</Box>
<PhysicalButton
selected={captionState === 'active'}
onClick={async () => {
if (captionState === 'active') {
await window.electronAPI.caption.stop()
} else if (captionState === 'inactive') {
await window.electronAPI.caption.start()
}
}}
sx={{ minWidth: 100 }}
>
{captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()}
</PhysicalButton>
</Box>
</MetalCard>
</Box>
{/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
<Box sx={{ mt: 3 }}>
<CrtDisplay amplitude={0.05} frequency={6} height={100} audioLevel={audioLevel}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, px: 1 }}>
{services.map((svc) => (
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={svc.ok ? 'green' : 'red'} size={6} />
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
<PhosphorText
variant="value"
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
>
{svc.status}
</PhosphorText>
</Box>
))}
</Box>
</CrtDisplay>
</Box>
{/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('fileTranscription.title').toUpperCase()}
</PhosphorText>
<FileDropZone />
</Box>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
{t('dashboard.recentTranscriptions').toUpperCase()}
</PhosphorText>
{history.length === 0 ? (
<EmptyStateCard message={t('dashboard.noHistory')} />
) : (
groupedHistory.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('dashboard.entries', { count: group.entries.length })}
</PhosphorText>
</Box>
{/* 히스토리 항목 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{group.entries.map((entry) => (
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={(text) => navigator.clipboard.writeText(text)}
/>
))}
</Box>
</Box>
))
)}
</Box>
</Box>
)
}

View file

@ -0,0 +1,143 @@
// src/renderer/pages/DictionaryPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + 타이포 토큰
import { useState, useEffect, useCallback } from 'react'
import { Box, TextField, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { MetalCard, PhosphorText } from '../components/ds'
import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
const PAGE_SIZE = 50
export function DictionaryPage(): React.ReactElement {
const { t } = useI18n()
const [data, setData] = useState<DictPageData | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [formWord, setFormWord] = useState('')
const [formPronunciation, setFormPronunciation] = useState('')
const loadData = useCallback(async () => {
setLoading(true)
const result = search.trim()
? await window.electronAPI.dictionary.search({ query: search, page: 0, pageSize: PAGE_SIZE })
: await window.electronAPI.dictionary.getAll({ page: 0, pageSize: PAGE_SIZE })
if (result.success) setData(result.data)
setLoading(false)
}, [search])
useEffect(() => { loadData() }, [loadData])
const openAdd = () => {
setEditId(null)
setFormWord('')
setFormPronunciation('')
setDialogOpen(true)
}
const openEdit = (entry: DictionaryEntry) => {
setEditId(entry.id)
setFormWord(entry.word)
setFormPronunciation(entry.pronunciation ?? '')
setDialogOpen(true)
}
const handleSave = async () => {
if (!formWord.trim()) return
if (editId) {
await window.electronAPI.dictionary.update({
id: editId,
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
} else {
await window.electronAPI.dictionary.add({
word: formWord.trim(),
pronunciation: formPronunciation.trim() || undefined,
})
}
setDialogOpen(false)
loadData()
}
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<PageHeader
title={t('dictionary.title').toUpperCase()}
count={t('dictionary.words', { count: data?.total ?? 0 }).toUpperCase()}
action={
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
{t('dictionary.add').toUpperCase()}
</Button>
}
/>
<SearchInput
placeholder={t('dictionary.search').toUpperCase()}
value={search}
onChange={setSearch}
/>
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
) : !data || data.entries.length === 0 ? (
<EmptyStateCard message={search ? t('dictionary.noResults').toUpperCase() : t('dictionary.noWords').toUpperCase()} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{data.entries.map((entry: DictionaryEntry) => (
<MetalCard key={entry.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>{entry.word}</Box>
{entry.pronunciation && (
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
)}
</Box>
<Box sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
color: d3roPalette.text.inactive,
mt: 0.5,
letterSpacing: d3roTypo.label.spacing,
}}>
{entry.category.toUpperCase()} · {t('dictionary.used', { count: entry.usageCount })}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => openEdit(entry)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>{editId ? t('dictionary.editTitle') : t('dictionary.addTitle')}</DialogTitle>
<DialogContent>
<TextField label={t('dictionary.word')} value={formWord} onChange={(e) => setFormWord(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label={t('dictionary.pronunciation')} value={formPronunciation} onChange={(e) => setFormPronunciation(e.target.value)} fullWidth sx={{ mt: 2 }} />
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formWord.trim()}>{t('common.save')}</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View file

@ -0,0 +1,205 @@
// src/renderer/pages/HistoryPage.tsx
// 인스트루먼트 미학: MetalCard + PhosphorText + Led + 날짜 그룹핑
// Phase 10: 태그 필터링 + 태그 관리 통합
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Box, Chip, IconButton, Tooltip } from '@mui/material'
import FileDownloadIcon from '@mui/icons-material/FileDownload'
import { PhosphorText } from '../components/ds'
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius } from '../theme'
import { useI18n } from '../i18n'
import { getDateKey } from '../utils/formatters'
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'
import type { HistoryEntry, HistoryPage as HistoryPageData, TagCount } from '@shared/types'
const PAGE_SIZE = 50
export function HistoryPage(): React.ReactElement {
const { t, formatRelativeDate } = useI18n()
const [data, setData] = useState<HistoryPageData | null>(null)
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [allTags, setAllTags] = useState<TagCount[]>([])
const [activeTag, setActiveTag] = useState<string | null>(null)
const loadTags = useCallback(async () => {
const result = await window.electronAPI.memo.getAllTags()
if (result.success) setAllTags(result.data)
}, [])
const loadData = useCallback(async () => {
setLoading(true)
let result: { success: boolean; data: HistoryPageData } | { success: false; error: unknown }
if (activeTag) {
result = await window.electronAPI.memo.searchByTag({ tag: activeTag, page: 0, pageSize: PAGE_SIZE })
} else if (search.trim()) {
result = await window.electronAPI.history.search({ query: search, page: 0, pageSize: PAGE_SIZE })
} else {
result = await window.electronAPI.history.getAll({ page: 0, pageSize: PAGE_SIZE, sortOrder: 'desc' })
}
if (result.success) setData(result.data)
setLoading(false)
}, [search, activeTag])
useEffect(() => {
loadData()
loadTags()
const unsub = window.electronAPI.app.onDataChanged(() => { loadData(); loadTags() })
return unsub
}, [loadData, loadTags])
const groupedEntries = useMemo(() => {
if (!data) return []
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
for (const entry of data.entries) {
const key = getDateKey(entry.createdAt)
if (!groups[key]) {
groups[key] = { label: formatRelativeDate(entry.createdAt), entries: [] }
}
groups[key].entries.push(entry)
}
return Object.values(groups)
}, [data, formatRelativeDate])
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text)
}, [])
const handleDelete = useCallback((id: string) => {
window.electronAPI.history.delete({ id })
loadData()
}, [loadData])
const handleTagClick = useCallback((tag: string) => {
setActiveTag(prev => prev === tag ? null : tag)
setSearch('')
}, [])
const handleExport = useCallback(async () => {
await window.electronAPI.memo.export({
format: 'markdown' as const,
tag: activeTag ?? undefined,
})
}, [activeTag])
return (
<Box sx={{ mx: 'auto', p: 4, overflow: 'hidden' }}>
{/* 페이지 헤더 -- 각인 스타일 */}
<PageHeader
title={t('history.title').toUpperCase()}
action={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={t('memo.export')} arrow>
<IconButton
size="small"
onClick={handleExport}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<FileDownloadIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{t('history.entries', { count: data?.total ?? 0 }).toUpperCase()}
</PhosphorText>
</Box>
}
/>
{/* 태그 필터 바 */}
{allTags.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mb: 2 }}>
{allTags.map(tc => (
<Chip
key={tc.tag}
label={`#${tc.tag} (${tc.count})`}
size="small"
variant={activeTag === tc.tag ? 'filled' : 'outlined'}
onClick={() => handleTagClick(tc.tag)}
sx={{
height: 22,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
borderRadius: d3roRadius.small,
borderColor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.border.subtle,
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : 'transparent',
color: activeTag === tc.tag ? d3roPalette.bg.card : d3roPalette.text.secondary,
'&:hover': {
bgcolor: activeTag === tc.tag ? d3roPalette.accent.amber : d3roPalette.bg.cardHover,
},
}}
/>
))}
{activeTag && (
<Chip
label={t('memo.clearFilter')}
size="small"
onClick={() => setActiveTag(null)}
sx={{
height: 22,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
borderRadius: d3roRadius.small,
color: d3roPalette.text.muted,
'&:hover': { color: d3roPalette.tag.red },
}}
/>
)}
</Box>
)}
{!activeTag && (
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('history.search').toUpperCase()}
/>
)}
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
) : !data || data.entries.length === 0 ? (
<EmptyStateCard
message={search ? t('history.noResults').toUpperCase() : t('history.noHistory').toUpperCase()}
/>
) : (
groupedEntries.map((group) => (
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('history.count', { count: group.entries.length })}
</PhosphorText>
</Box>
{/* 항목 — 반응형 그리드 */}
<Box sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
},
gap: 1.5,
}}>
{group.entries.map((entry: HistoryEntry) => (
<HistoryEntryCard
key={entry.id}
entry={entry}
onCopy={handleCopy}
onDelete={handleDelete}
showTags
onTagClick={handleTagClick}
/>
))}
</Box>
</Box>
))
)}
</Box>
)
}

View file

@ -0,0 +1,262 @@
// src/renderer/pages/KnowledgeBasePage.tsx
// Phase 13.2: 로컬 RAG Knowledge Base UI
// 문서 관리 + 질문 입력 + 답변 표시
import { useState, useEffect, useCallback } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import RefreshIcon from '@mui/icons-material/Refresh'
import SearchIcon from '@mui/icons-material/Search'
import SendIcon from '@mui/icons-material/Send'
import DescriptionIcon from '@mui/icons-material/Description'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@shared/types'
export function KnowledgeBasePage(): React.ReactElement {
const { t } = useI18n()
const [documents, setDocuments] = useState<RAGDocument[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [querying, setQuerying] = useState(false)
const [result, setResult] = useState<RAGQueryResult | null>(null)
const [indexProgress, setIndexProgress] = useState<RAGIndexProgress | null>(null)
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState<string | null>(null)
const loadDocuments = useCallback(async () => {
setLoading(true)
const resp = await window.electronAPI.rag.getDocuments()
if (resp.success) setDocuments(resp.data)
setLoading(false)
}, [])
useEffect(() => { loadDocuments() }, [loadDocuments])
useEffect(() => {
const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => {
setIndexProgress(data)
})
const unsubComplete = window.electronAPI.rag.onIndexComplete(() => {
setIndexProgress(null)
loadDocuments()
})
return () => { unsubProgress(); unsubComplete() }
}, [loadDocuments])
const handleAddDocument = useCallback(async () => {
setAdding(true)
setAddError(null)
const resp = await window.electronAPI.rag.addDocument()
setAdding(false)
if (resp.success) {
loadDocuments()
} else {
if (!resp.error.message.includes('cancelled')) {
setAddError(resp.error.message)
}
}
}, [loadDocuments])
const handleRemoveDocument = useCallback(async (docId: string) => {
await window.electronAPI.rag.removeDocument({ documentId: docId })
loadDocuments()
}, [loadDocuments])
const handleReindex = useCallback(async (docId: string) => {
await window.electronAPI.rag.reindex(docId)
}, [])
const handleQuery = useCallback(async () => {
if (!query.trim()) return
setQuerying(true)
setResult(null)
const resp = await window.electronAPI.rag.query({ query: query.trim() })
if (resp.success) {
setResult(resp.data)
}
setQuerying(false)
}, [query])
const handleQueryKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleQuery()
}
}, [handleQuery])
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
<PageHeader
title={t('rag.title').toUpperCase()}
action={
<PhysicalButton size="small" onClick={handleAddDocument} disabled={adding}>
<AddIcon sx={{ fontSize: 14, mr: 0.5 }} /> {adding ? t('rag.adding') : t('rag.addDocument')}
</PhysicalButton>
}
/>
{/* 인덱싱 진행률 */}
{indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{t('rag.indexing')}: {indexProgress.fileName}
</PhosphorText>
</Box>
<LinearProgress
variant="determinate"
value={indexProgress.percent}
sx={{
height: 4,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{indexProgress.currentChunk}/{indexProgress.totalChunks}
</PhosphorText>
</MetalCard>
)}
{/* 에러 표시 */}
{addError && (
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.tag.red}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
{addError}
</PhosphorText>
</Box>
</MetalCard>
)}
{/* 추가 중 인디케이터 */}
{adding && !indexProgress && (
<MetalCard sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">{t('rag.parsing')}</PhosphorText>
</Box>
</MetalCard>
)}
{/* 문서 목록 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.documents').toUpperCase()} ({documents.length})
</PhosphorText>
{documents.length === 0 && !loading ? (
<EmptyStateCard message={t('rag.noDocuments')} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 3 }}>
{documents.map((doc) => (
<MetalCard key={doc.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<DescriptionIcon sx={{ fontSize: 20, color: d3roPalette.text.inactive }} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="body">{doc.fileName}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
<PhosphorText variant="dim">
{doc.fileType.toUpperCase()} · {doc.chunkCount} {t('rag.chunks')} ·
</PhosphorText>
<Led color={doc.indexed ? 'green' : 'amber'} size={6} />
<PhosphorText variant="dim">
{doc.indexed ? t('rag.indexed') : t('rag.pending')}
</PhosphorText>
</Box>
</Box>
<Tooltip title={t('rag.reindex')}>
<IconButton size="small" onClick={() => handleReindex(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<RefreshIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton size="small" onClick={() => handleRemoveDocument(doc.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</MetalCard>
))}
</Box>
)}
{/* 질문 입력 */}
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('rag.askQuestion').toUpperCase()}
</PhosphorText>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon sx={{ fontSize: 20, color: d3roPalette.text.inactive }} />
<TextField
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleQueryKeyDown}
placeholder={t('rag.queryPlaceholder')}
size="small"
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.amber },
},
'& .MuiOutlinedInput-input': { color: d3roPalette.text.primary },
}}
/>
<IconButton onClick={handleQuery} disabled={!query.trim() || querying}
sx={{ color: query.trim() ? d3roPalette.accent.amber : d3roPalette.text.inactive }}>
<SendIcon sx={{ fontSize: 20 }} />
</IconButton>
</Box>
</MetalCard>
{/* 답변 */}
{querying && (
<Box sx={{ mt: 2, textAlign: 'center' }}>
<Led color="amber" pulse />
<PhosphorText variant="dim" sx={{ ml: 1 }}>{t('rag.searching')}</PhosphorText>
</Box>
)}
{result && (
<Box sx={{ mt: 2 }}>
<ScreenPanel sx={{ p: 3 }}>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>
{result.answer}
</PhosphorText>
</ScreenPanel>
{result.results.length > 0 && (
<Box sx={{ mt: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1, display: 'block' }}>
{t('rag.sources').toUpperCase()}
</PhosphorText>
{result.results.slice(0, 3).map((r, i) => (
<MetalCard key={i} sx={{ mb: 1 }}>
<PhosphorText variant="dim">
[{i + 1}] {r.fileName} ({Math.round(r.similarity * 100)}%)
</PhosphorText>
<PhosphorText variant="compact" sx={{ mt: 0.5, opacity: 0.7 }}>
{r.content.slice(0, 150)}...
</PhosphorText>
</MetalCard>
))}
</Box>
)}
</Box>
)}
</Box>
)
}

View file

@ -0,0 +1,489 @@
// src/renderer/pages/MeetingModePage.tsx
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
LinearProgress,
TextField,
Snackbar,
Alert,
} from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import StopIcon from '@mui/icons-material/Stop'
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
MeetingSessionSummary,
MeetingSessionDetail,
MeetingModeStateInfo,
MeetingProcessingProgress,
CaptionSegment,
MeetingMemo,
} from '@shared/types'
type MeetingView = 'list' | 'recording' | 'detail'
export function MeetingModePage(): React.ReactElement {
const { t } = useI18n()
const [view, setView] = useState<MeetingView>('list')
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
const [totalSessions, setTotalSessions] = useState(0)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const pageSize = 20
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0)
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
const [audioLevel, setAudioLevel] = useState(0)
const transcriptRef = useRef<HTMLDivElement>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const recordingStartRef = useRef<number>(0)
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [snackbarMsg, setSnackbarMsg] = useState<string | null>(null)
// ── 세션 목록 로드 ──
const loadSessions = useCallback(async () => {
setLoading(true)
const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize })
if (resp.success) {
setSessions(resp.data.sessions)
setTotalSessions(resp.data.total)
}
setLoading(false)
}, [page])
useEffect(() => {
if (view === 'list') loadSessions()
}, [view, loadSessions])
// ── IPC 이벤트 구독 ──
useEffect(() => {
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
setStateInfo(info)
if (info.state === 'idle' && view === 'recording') {
// 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침
setProgress(null)
setView('list')
}
})
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
setSegments((prev) => [...prev, seg])
// 자동 스크롤
setTimeout(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
}
}, 50)
})
const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => {
setProgress(p)
})
const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => {
setProgress(null)
setView('list')
})
const unsubError = window.electronAPI.meetingMode.onError(() => {
setProgress(null)
setView('list')
})
const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => {
setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화
})
return () => {
unsubState()
unsubSegment()
unsubProgress()
unsubCompleted()
unsubError()
unsubAudioLevel()
}
}, [view])
// ── 경과 시간 타이머 ──
useEffect(() => {
if (view === 'recording') {
recordingStartRef.current = Date.now()
elapsedTimerRef.current = setInterval(() => {
setElapsedMs(Date.now() - recordingStartRef.current)
}, 1000)
}
return () => {
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current)
elapsedTimerRef.current = null
}
}
}, [view])
// ── 핸들러 ──
const handleStartRecording = useCallback(async () => {
const resp = await window.electronAPI.meetingMode.startRecording()
if (resp.success) {
setSegments([])
setMemos([])
setElapsedMs(0)
setProgress(null)
setView('recording')
} else {
setSnackbarMsg(t('meeting.startRecordingError'))
}
}, [t])
const handleStopRecording = useCallback(async () => {
await window.electronAPI.meetingMode.stopRecording()
}, [])
const handleAddMemo = useCallback(async () => {
if (!memoInput.trim()) return
const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
if (resp.success) {
setMemos((prev) => [...prev, resp.data])
setMemoInput('')
}
}, [memoInput])
const handleMemoKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddMemo()
}
},
[handleAddMemo],
)
const handleViewDetail = useCallback(async (sessionId: string) => {
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
if (resp.success) {
setDetail(resp.data)
setView('detail')
} else {
setSnackbarMsg(t('meeting.viewDetailError'))
}
}, [t])
// ── 유틸 ──
const formatTime = (ms: number): string => {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
const formatDate = (ts: number): string => {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => {
switch (status) {
case 'completed': return 'green'
case 'recording': return 'red'
case 'processing': return 'amber'
case 'error': return 'red'
default: return 'blue'
}
}
const snackbar = (
<Snackbar
open={snackbarMsg !== null}
autoHideDuration={4000}
onClose={() => setSnackbarMsg(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSnackbarMsg(null)} sx={{ width: '100%' }}>
{snackbarMsg}
</Alert>
</Snackbar>
)
// ── 렌더: 목록 뷰 ──
if (view === 'list') {
return (
<Box sx={{ p: 3 }}>
<PageHeader
title={t('meeting.title')}
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
action={
<PhysicalButton size="small" onClick={handleStartRecording}>
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.newMeeting')}
</PhysicalButton>
}
/>
{loading ? (
<LinearProgress sx={{ mt: 2 }} />
) : sessions.length === 0 ? (
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
md: 'repeat(3, 1fr)',
lg: 'repeat(4, 1fr)',
},
gap: 1.5,
}}
>
{sessions.map((session) => (
<Box
key={session.id}
onClick={() => handleViewDetail(session.id)}
sx={{
cursor: 'pointer',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
'&:hover': { transform: 'translateY(-2px)' },
}}
>
<MetalCard>
{/* 제목 — 여러 줄 허용 */}
<PhosphorText
variant="heading"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
mb: 1.5,
}}
>
{session.title ?? t('meeting.untitled')}
</PhosphorText>
{/* 정보 그리드 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{formatDate(session.startedAt)}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{session.durationMs != null
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
: '—'}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
{t('meeting.memos')}: {session.memoCount}
</PhosphorText>
</Box>
</Box>
{/* 하단 — 상태 표시 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
<Led color={statusColor(session.status)} size={6} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, textTransform: 'uppercase', letterSpacing: d3roTypo.label.spacing }}>
{session.status === 'completed' ? t('meeting.completed')
: session.status === 'error' ? t('meeting.error')
: session.status === 'processing' ? t('meeting.processing')
: t('meeting.recording')}
</PhosphorText>
</Box>
{/* 프로세싱 진행 바 */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{
height: 3,
borderRadius: 2,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: d3roTypo.nano.size }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
</PhosphorText>
</Box>
)}
</MetalCard>
</Box>
))}
</Box>
)}
{snackbar}
</Box>
)
}
// ── 렌더: 녹음 뷰 ──
if (view === 'recording') {
const isProcessing = stateInfo?.state === 'processing'
return (
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 상단 바 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FiberManualRecordIcon sx={{ color: d3roPalette.tag.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
{formatTime(elapsedMs)}
</PhosphorText>
<PhosphorText variant="dim">
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
</PhosphorText>
</Box>
{!isProcessing && (
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
{/* 프로세싱 진행률 */}
{isProcessing && progress && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</Box>
)}
{/* 메인 패널: 전사 + 메모 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
{/* 좌: 실시간 전사 */}
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ScreenPanel>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.transcript')}
</PhosphorText>
<Box
ref={transcriptRef}
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
fontSize: d3roTypo.body.size,
color: d3roPalette.text.primary,
lineHeight: 1.8,
}}
>
{segments.map((seg) => (
<EditableSegment
key={seg.id}
segmentId={seg.id}
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
text={seg.text}
edited={false}
onEdit={() => { /* no-op: readOnly=true */ }}
readOnly
/>
))}
</Box>
</ScreenPanel>
</Box>
{/* 우: 메모 입력 */}
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.memos')}
</PhosphorText>
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
{memos.map((memo) => (
<Box
key={memo.id}
sx={{
mb: 0.5,
p: 1,
borderRadius: 1,
bgcolor: d3roPalette.bg.chassis,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<PhosphorText variant="body" sx={{ fontSize: 12 }}>
{memo.content}
</PhosphorText>
</Box>
))}
</Box>
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder={t('meeting.memoPlaceholder')}
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
},
}}
/>
)}
</Box>
</Box>
{/* 오디오 레벨 미터 */}
{!isProcessing && (
<Box sx={{ mt: 1.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={audioLevel > 0.02 ? 'green' : 'amber'} size={8} />
<Box sx={{
flex: 1,
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: `${Math.max(audioLevel * 100, 0)}%`,
bgcolor: audioLevel > 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green,
borderRadius: 3,
transition: 'width 0.1s ease-out',
}} />
</Box>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono, minWidth: 30 }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
</Box>
)}
{snackbar}
</Box>
)
}
// ── 렌더: 상세 뷰 ──
if (view === 'detail' && detail) {
return (
<MeetingDetailTabs
detail={detail}
onBack={() => { setDetail(null); setView('list') }}
/>
)
}
return <Box />
}

View file

@ -0,0 +1,291 @@
// src/renderer/pages/VoiceConversationPage.tsx
// Phase 13.1: 음성 대화 모드 UI
// STT→LLM→TTS 대화 루프. 채팅 메시지 목록 + 녹음 버튼.
import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, IconButton, TextField, Tooltip } from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import StopIcon from '@mui/icons-material/Stop'
import SendIcon from '@mui/icons-material/Send'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import CancelIcon from '@mui/icons-material/Cancel'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, InstrumentPanel } from '../components/ds'
import { PageHeader } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
ConversationState,
ConversationMessage,
ConversationAssistantDelta,
} from '@shared/types'
export function VoiceConversationPage(): React.ReactElement {
const { t } = useI18n()
const [state, setState] = useState<ConversationState>('idle')
const [messages, setMessages] = useState<ConversationMessage[]>([])
const [isActive, setIsActive] = useState(false)
const [streamingText, setStreamingText] = useState('')
const [streamingMsgId, setStreamingMsgId] = useState<string | null>(null)
const [textInput, setTextInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [])
// IPC 이벤트 구독
useEffect(() => {
const unsubState = window.electronAPI.voiceConversation.onStateChanged((info) => {
setState(info.state)
setMessages(info.messages)
setIsActive(info.isActive)
})
const unsubUser = window.electronAPI.voiceConversation.onUserMessage((msg) => {
setMessages((prev) => [...prev, msg])
setStreamingText('')
setStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubDelta = window.electronAPI.voiceConversation.onAssistantDelta((data: ConversationAssistantDelta) => {
setStreamingMsgId(data.messageId)
setStreamingText(data.accumulated)
setTimeout(scrollToBottom, 50)
})
const unsubComplete = window.electronAPI.voiceConversation.onAssistantMessage((msg) => {
setMessages((prev) => [...prev, { id: msg.messageId, role: 'assistant', content: msg.content, timestamp: Date.now() }])
setStreamingText('')
setStreamingMsgId(null)
setTimeout(scrollToBottom, 50)
})
const unsubError = window.electronAPI.voiceConversation.onError(() => {
// 에러 시 자동 복구 (서비스에서 listening으로 전환)
})
// 초기 상태 로드
window.electronAPI.voiceConversation.getState().then((r) => {
if (r.success) {
setState(r.data.state)
setMessages(r.data.messages)
setIsActive(r.data.isActive)
}
})
return () => {
unsubState()
unsubUser()
unsubDelta()
unsubComplete()
unsubError()
}
}, [scrollToBottom])
const handleStartSession = useCallback(async () => {
await window.electronAPI.voiceConversation.startSession()
}, [])
const handleStopSession = useCallback(async () => {
await window.electronAPI.voiceConversation.stopSession()
}, [])
const handleFinishListening = useCallback(async () => {
await window.electronAPI.voiceConversation.finishListening()
}, [])
const handleSendText = useCallback(async () => {
if (!textInput.trim()) return
const text = textInput.trim()
setTextInput('')
if (!isActive) {
await window.electronAPI.voiceConversation.startSession()
}
await window.electronAPI.voiceConversation.sendMessage({ text })
}, [textInput, isActive])
const handleClearHistory = useCallback(async () => {
await window.electronAPI.voiceConversation.clearHistory()
setMessages([])
}, [])
const handleCancelResponse = useCallback(async () => {
await window.electronAPI.voiceConversation.cancelResponse()
}, [])
const handleTextKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendText()
}
}, [handleSendText])
const stateLabel = {
idle: t('conversation.idle'),
listening: t('conversation.listening'),
thinking: t('conversation.thinking'),
speaking: t('conversation.speaking'),
}
const stateLedColor = {
idle: 'amber' as const,
listening: 'red' as const,
thinking: 'amber' as const,
speaking: 'green' as const,
}
return (
<Box sx={{ maxWidth: 800, mx: 'auto', p: 4, pb: 8, display: 'flex', flexDirection: 'column', height: '100%' }}>
<PageHeader
title={t('conversation.title').toUpperCase()}
action={
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Led color={stateLedColor[state]} pulse={state === 'listening' || state === 'thinking'} size={8} />
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{stateLabel[state].toUpperCase()}
</PhosphorText>
{messages.length > 0 && (
<Tooltip title={t('conversation.clearHistory')}>
<IconButton size="small" onClick={handleClearHistory} sx={{ color: d3roPalette.text.inactive }}>
<DeleteSweepIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
)}
</Box>
}
/>
{/* 메시지 목록 */}
<Box
sx={{
flex: 1,
overflow: 'auto',
mt: 2,
mb: 2,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
{messages.length === 0 && !streamingText && (
<Box sx={{ textAlign: 'center', mt: 8 }}>
<PhosphorText variant="heading" sx={{ color: d3roPalette.text.inactive, mb: 1 }}>
{t('conversation.empty')}
</PhosphorText>
<PhosphorText variant="dim">
{t('conversation.emptyHint')}
</PhosphorText>
</Box>
)}
{messages.map((msg) => (
<Box
key={msg.id}
sx={{
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<MetalCard
sx={{
maxWidth: '75%',
...(msg.role === 'user' && {
bgcolor: d3roPalette.accent.amber,
'& *': { color: `${d3roPalette.bg.chassis} !important` },
}),
}}
>
<PhosphorText
variant="compact"
sx={{
whiteSpace: 'pre-wrap',
lineHeight: 1.6,
}}
>
{msg.content}
</PhosphorText>
</MetalCard>
</Box>
))}
{/* 스트리밍 중인 어시스턴트 메시지 */}
{streamingText && streamingMsgId && (
<Box sx={{ display: 'flex', justifyContent: 'flex-start' }}>
<MetalCard sx={{ maxWidth: '75%' }}>
<PhosphorText variant="compact" sx={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{streamingText}
<Box component="span" sx={{ animation: 'blink 1s infinite', color: d3roPalette.accent.amber }}>
{'▌'}
</Box>
</PhosphorText>
</MetalCard>
</Box>
)}
<div ref={messagesEndRef} />
</Box>
{/* 하단 컨트롤 바 */}
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
{/* 녹음 버튼 */}
{!isActive ? (
<PhysicalButton onClick={handleStartSession} sx={{ minWidth: 48, px: 2 }}>
<MicIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
) : state === 'listening' ? (
<PhysicalButton selected onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
<StopIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
) : state === 'thinking' || state === 'speaking' ? (
<PhysicalButton onClick={handleCancelResponse} sx={{ minWidth: 48, px: 2 }}>
<CancelIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
) : (
<PhysicalButton onClick={handleFinishListening} sx={{ minWidth: 48, px: 2 }}>
<MicIcon sx={{ fontSize: 20 }} />
</PhysicalButton>
)}
{/* 텍스트 입력 */}
<TextField
value={textInput}
onChange={(e) => setTextInput(e.target.value)}
onKeyDown={handleTextKeyDown}
placeholder={t('conversation.inputPlaceholder')}
size="small"
fullWidth
sx={{
'& .MuiOutlinedInput-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
bgcolor: d3roPalette.bg.inset,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.amber },
'&.Mui-focused fieldset': { borderColor: d3roPalette.accent.amber },
},
'& .MuiOutlinedInput-input': {
color: d3roPalette.text.primary,
},
}}
/>
{/* 전송 버튼 */}
<IconButton
onClick={handleSendText}
disabled={!textInput.trim()}
sx={{
color: textInput.trim() ? d3roPalette.accent.amber : d3roPalette.text.inactive,
}}
>
<SendIcon sx={{ fontSize: 20 }} />
</IconButton>
{/* 세션 종료 */}
{isActive && (
<PhysicalButton onClick={handleStopSession} sx={{ minWidth: 48, px: 1 }}>
<PhosphorText variant="micro">{t('conversation.end')}</PhosphorText>
</PhysicalButton>
)}
</Box>
</MetalCard>
</Box>
)
}