Phase 12~13 전체 구현: Pro+ 피처 6종 + 음성 대화 + RAG + OS 자동화

Phase 12:
- FileTranscriptionService: ffmpeg PCM 변환 + 30초 청크 순차 STT
- MeetingSummaryService: 자막 세션 → LLM 자동 요약 + DB summaryText
- DictationTemplateService: 필드별 음성 입력 상태 머신 + 프리셋 3개

Phase 13.1:
- VoiceConversationService: STT→Ollama /api/chat→TTS 대화 루프 (10턴)
- TTSPlaybackService: Windows SAPI 문장 단위 큐 재생
- LocalLLMService.chatStream: Ollama /api/chat 스트리밍

Phase 13.2:
- RAGService: Ollama 임베딩 + SQLite 벡터 + 코사인 유사도 검색
- KnowledgeBasePage: 문서 관리 + 질문/답변 UI
- PDF 파서: zlib FlateDecode 해제 + BT/ET 텍스트 추출

Phase 13.3:
- VoiceActionService: LLM JSON 액션 플랜 생성 + 실행
- 프리셋 6개 (크롬/메모장/탐색기/볼륨), 위험 명령 차단

공통: IPC ~70채널, 에러코드 780-878, i18n 100+키
버그픽스: 라이선스 로컬 키 우선, i18n featureLabel, DOM 중첩
This commit is contained in:
Yun Chan 2026-04-05 23:52:14 +09:00
parent a31f96bbb8
commit eb83682269
38 changed files with 5678 additions and 19 deletions

View file

@ -7,12 +7,16 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import SettingsIcon from '@mui/icons-material/Settings'
import { Led } from './ds'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
@ -22,7 +26,7 @@ import { useI18n } from '../i18n'
import type { TranslationKey } from '../i18n'
import type { LicenseTier } from '@shared/types'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge'
interface NavItem {
route: Route
@ -36,6 +40,8 @@ const NAV_ITEMS: NavItem[] = [
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
]
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
@ -217,6 +223,8 @@ export function AppLayout(): React.ReactElement {
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
</Box>
</Box>
<StatusBar />

View file

@ -0,0 +1,272 @@
// src/renderer/components/FileDropZone.tsx
// Phase 12.1: 파일 전사 드래그앤드롭 UI
import { useState, useEffect, useCallback, useRef } from 'react'
import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import CloseIcon from '@mui/icons-material/Close'
import UploadFileIcon from '@mui/icons-material/UploadFile'
import { MetalCard, PhosphorText, Led } from './ds'
import { d3roPalette, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
FileTranscriptionProgress,
FileTranscriptionResult,
FileTranscriptionState,
} from '@shared/types'
const SUPPORTED_EXTENSIONS = [
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
'.mp4', '.mkv', '.webm', '.avi', '.mov',
]
export function FileDropZone(): React.ReactElement {
const { t } = useI18n()
const [dragging, setDragging] = useState(false)
const [state, setState] = useState<FileTranscriptionState>('idle')
const [progress, setProgress] = useState<FileTranscriptionProgress | null>(null)
const [result, setResult] = useState<FileTranscriptionResult | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const dropRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const unsubProgress = window.electronAPI.fileTranscription.onProgress((data) => {
setProgress(data)
setState('transcribing')
})
const unsubComplete = window.electronAPI.fileTranscription.onComplete((data) => {
setResult(data)
setState('completed')
setProgress(null)
})
const unsubError = window.electronAPI.fileTranscription.onError((data) => {
setError(data.message)
setState('error')
setProgress(null)
})
return () => {
unsubProgress()
unsubComplete()
unsubError()
}
}, [])
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
const file = e.dataTransfer.files[0]
if (!file) return
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!SUPPORTED_EXTENSIONS.includes(ext)) {
setError(t('fileTranscription.error.invalidFormat'))
setState('error')
return
}
setState('converting')
setError(null)
setResult(null)
const filePath = (file as unknown as { path: string }).path
const resp = await window.electronAPI.fileTranscription.start({ filePath })
if (!resp.success) {
setError(resp.error.message)
setState('error')
}
}, [t])
const handleBrowse = useCallback(async () => {
setState('converting')
setError(null)
setResult(null)
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
if (!resp.success) {
if (resp.error.message.includes('cancelled')) {
setState('idle')
} else {
setError(resp.error.message)
setState('error')
}
}
}, [])
const handleCancel = useCallback(async () => {
await window.electronAPI.fileTranscription.cancel()
setState('idle')
setProgress(null)
}, [])
const handleCopy = useCallback(() => {
if (result?.fullText) {
navigator.clipboard.writeText(result.fullText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [result])
const handleReset = useCallback(() => {
setState('idle')
setResult(null)
setError(null)
setProgress(null)
}, [])
// ── idle: 드래그 존 ──
if (state === 'idle') {
return (
<MetalCard>
<Box
ref={dropRef}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={handleBrowse}
sx={{
p: 4,
textAlign: 'center',
border: `2px dashed ${dragging ? d3roPalette.accent.amber : d3roPalette.border.subtle}`,
borderRadius: '8px',
cursor: 'pointer',
transition: 'border-color 0.2s',
'&:hover': { borderColor: d3roPalette.accent.amber },
}}
>
<UploadFileIcon sx={{ fontSize: 40, color: d3roPalette.text.inactive, mb: 1 }} />
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
{t('fileTranscription.dropZone')}
</PhosphorText>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{t('fileTranscription.dropZoneHint')}
</PhosphorText>
</Box>
</MetalCard>
)
}
// ── converting / transcribing: 진행률 ──
if (state === 'converting' || state === 'transcribing') {
return (
<MetalCard>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{state === 'converting'
? t('fileTranscription.converting')
: t('fileTranscription.processing')}
</PhosphorText>
</Box>
<PhysicalButton size="small" onClick={handleCancel}>
{t('fileTranscription.cancel')}
</PhysicalButton>
</Box>
{progress && (
<>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{
mb: 1,
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
}}
/>
<PhosphorText variant="dim">
{t('fileTranscription.progress', {
current: String(progress.currentChunk),
total: String(progress.totalChunks),
})}
</PhosphorText>
{progress.currentText && (
<PhosphorText variant="compact" sx={{ mt: 1, opacity: 0.7, fontStyle: 'italic' }}>
{progress.currentText.slice(0, 100)}...
</PhosphorText>
)}
</>
)}
</Box>
</MetalCard>
)
}
// ── completed: 결과 ──
if (state === 'completed' && result) {
return (
<MetalCard>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="green" />
<PhosphorText variant="body">
{t('fileTranscription.complete')}
</PhosphorText>
<PhosphorText variant="dim">
({result.fileName})
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={copied ? 'Copied!' : t('fileTranscription.copyAll')}>
<IconButton size="small" onClick={handleCopy}>
<ContentCopyIcon sx={{ fontSize: 16, color: copied ? d3roPalette.accent.amber : d3roPalette.text.inactive }} />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={handleReset}>
<CloseIcon sx={{ fontSize: 16, color: d3roPalette.text.inactive }} />
</IconButton>
</Box>
</Box>
<Box
sx={{
maxHeight: 200,
overflow: 'auto',
p: 2,
bgcolor: d3roPalette.bg.inset,
borderRadius: '6px',
fontSize: d3roTypo.compact.size,
lineHeight: d3roTypo.compact.line,
color: d3roPalette.text.primary,
whiteSpace: 'pre-wrap',
}}
>
{result.fullText}
</Box>
<PhosphorText variant="dim" sx={{ mt: 1 }}>
{Math.round(result.totalDurationSec)}s audio / {Math.round(result.processingTimeMs / 1000)}s processing
</PhosphorText>
</Box>
</MetalCard>
)
}
// ── error ──
if (state === 'error') {
return (
<MetalCard>
<Box sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Led color="red" />
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red }}>
{error ?? t('fileTranscription.error.unknown')}
</PhosphorText>
</Box>
<PhysicalButton size="small" onClick={handleReset}>
{t('fileTranscription.retry')}
</PhysicalButton>
</Box>
</MetalCard>
)
}
return <></>
}

View file

@ -263,7 +263,7 @@ export function LicenseTab(): React.ReactElement {
<tbody>
{comparison.map((row) => (
<tr key={row.feature}>
<td>{t(`license.feature.${row.feature}`)}</td>
<td>{t(row.featureLabel as Parameters<typeof t>[0])}</td>
<td><TierCell value={row.free} /></td>
<td><TierCell value={row.pro} /></td>
<td><TierCell value={row.proPlus} /></td>

View file

@ -0,0 +1,294 @@
// src/renderer/components/TemplateSection.tsx
// Phase 12.3: 딕테이션 템플릿 관리 UI (CommandsPage 내 섹션)
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip } 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 PlayArrowIcon from '@mui/icons-material/PlayArrow'
import { MetalCard, PhosphorText, Led, PhysicalButton } from './ds'
import { PageHeader, EmptyStateCard } from './shared'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
import { useI18n } from '../i18n'
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@shared/types'
export function TemplateSection(): React.ReactElement {
const { t } = useI18n()
const [templates, setTemplates] = useState<DictationTemplate[]>([])
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 [formOutput, setFormOutput] = useState('')
const [formFields, setFormFields] = useState<TemplateField[]>([])
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
const result = await window.electronAPI.dictationTemplate.getAll()
if (result.success) setTemplates(result.data)
const sessionResult = await window.electronAPI.dictationTemplate.getSessionState()
if (sessionResult.success) setSession(sessionResult.data)
setLoading(false)
}, [])
useEffect(() => { loadData() }, [loadData])
useEffect(() => {
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
setSession(data)
})
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => {
setSession(null)
loadData()
})
return () => { unsub(); unsubComplete() }
}, [loadData])
const openCreate = () => {
setEditId(null)
setFormName('')
setFormDesc('')
setFormOutput('{{field1}}')
setFormFields([{ id: 'field1', name: 'field1', label: 'Field 1', promptText: '', required: true, maxDurationSec: 30 }])
setDialogOpen(true)
}
const openEdit = (template: DictationTemplate) => {
setEditId(template.id)
setFormName(template.name)
setFormDesc(template.description)
setFormOutput(template.outputFormat)
setFormFields([...template.fields])
setDialogOpen(true)
}
const handleSave = async () => {
if (editId) {
await window.electronAPI.dictationTemplate.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
} else {
await window.electronAPI.dictationTemplate.create({
name: formName.trim(),
description: formDesc.trim(),
fields: formFields,
outputFormat: formOutput,
})
}
setDialogOpen(false)
loadData()
}
const handleDelete = async (id: string) => {
await window.electronAPI.dictationTemplate.delete({ id })
loadData()
}
const handleStartSession = async (templateId: string) => {
await window.electronAPI.dictationTemplate.startSession({ templateId })
}
const handleCancelSession = async () => {
await window.electronAPI.dictationTemplate.cancelSession()
setSession(null)
}
const addField = () => {
const idx = formFields.length + 1
setFormFields([...formFields, {
id: `field${idx}`,
name: `field${idx}`,
label: `Field ${idx}`,
promptText: '',
required: true,
maxDurationSec: 30,
}])
}
const updateField = (index: number, updates: Partial<TemplateField>) => {
const updated = [...formFields]
updated[index] = { ...updated[index], ...updates }
setFormFields(updated)
}
const removeField = (index: number) => {
setFormFields(formFields.filter((_, i) => i !== index))
}
return (
<Box sx={{ mt: 5 }}>
<PageHeader
title={t('template.title').toUpperCase()}
action={
<PhysicalButton size="small" onClick={openCreate}>
<AddIcon sx={{ fontSize: 14, mr: 0.5 }} /> {t('template.create')}
</PhysicalButton>
}
/>
{/* 활성 세션 표시 */}
{session && (
<MetalCard sx={{ mb: 2, border: `1px solid ${d3roPalette.accent.amber}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse />
<PhosphorText variant="body">
{session.templateName}: {session.currentField?.label ?? '...'}
</PhosphorText>
<PhosphorText variant="dim">
({session.currentFieldIndex + 1}/{session.totalFields})
</PhosphorText>
</Box>
<PhysicalButton size="small" onClick={handleCancelSession}>
{t('template.cancelSession')}
</PhysicalButton>
</Box>
</MetalCard>
)}
{/* 템플릿 목록 */}
{templates.length === 0 && !loading ? (
<EmptyStateCard message={t('template.empty')} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{templates.map((tmpl) => (
<MetalCard key={tmpl.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.body.weight }}>
{tmpl.name}
{tmpl.isBuiltin && (
<Box component="span" sx={{ ml: 1, fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel }}>
PRESET
</Box>
)}
</PhosphorText>
<PhosphorText variant="dim" sx={{ mt: 0.25 }}>
{tmpl.fields.length} {t('template.fields').toLowerCase()} {tmpl.description}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title={t('template.startSession')}>
<IconButton
size="small"
onClick={() => handleStartSession(tmpl.id)}
disabled={!!session}
sx={{ color: d3roPalette.accent.amber, '&:hover': { opacity: 0.8 } }}
>
<PlayArrowIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => openEdit(tmpl)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{!tmpl.isBuiltin && (
<IconButton
size="small"
onClick={() => handleDelete(tmpl.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>{editId ? t('template.edit') : t('template.create')}</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
<TextField
label={t('template.name')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
size="small"
fullWidth
/>
<TextField
label={t('template.description')}
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
size="small"
fullWidth
/>
<PhosphorText variant="label" sx={{ mt: 1, color: d3roPalette.text.dimLabel }}>
{t('template.fields').toUpperCase()}
</PhosphorText>
{formFields.map((field, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label={t('template.fieldName')}
value={field.name}
onChange={(e) => updateField(idx, { name: e.target.value, id: e.target.value })}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label={t('template.fieldLabel')}
value={field.label}
onChange={(e) => updateField(idx, { label: e.target.value })}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label={t('template.fieldPrompt')}
value={field.promptText}
onChange={(e) => updateField(idx, { promptText: e.target.value })}
size="small"
sx={{ flex: 2 }}
/>
<IconButton
size="small"
onClick={() => removeField(idx)}
disabled={formFields.length <= 1}
sx={{ color: d3roPalette.text.inactive }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
<Button onClick={addField} startIcon={<AddIcon />} size="small" sx={{ alignSelf: 'flex-start' }}>
{t('template.addField')}
</Button>
<TextField
label={t('template.outputFormat')}
value={formOutput}
onChange={(e) => setFormOutput(e.target.value)}
size="small"
fullWidth
multiline
rows={3}
helperText="Use {{fieldName}} for placeholders"
/>
</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() || formFields.length === 0}>
{t('common.save')}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View file

@ -8,11 +8,13 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteIcon from '@mui/icons-material/Delete'
import LocalOfferIcon from '@mui/icons-material/LocalOffer'
import CloseIcon from '@mui/icons-material/Close'
import SummarizeIcon from '@mui/icons-material/Summarize'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { MetalCard, Led } from '../ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
import { useI18n } from '../../i18n'
import { formatDuration } from '../../utils/formatters'
import type { HistoryEntry, MemoTag } from '@shared/types'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@shared/types'
interface HistoryEntryCardProps {
entry: HistoryEntry
@ -28,6 +30,10 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
const [tags, setTags] = useState<MemoTag[]>([])
const [tagInput, setTagInput] = useState('')
const [showTagInput, setShowTagInput] = useState(false)
const [summaryExpanded, setSummaryExpanded] = useState(false)
const [summary, setSummary] = useState<MeetingSummaryResult | null>(null)
const [summaryLoading, setSummaryLoading] = useState(false)
const hasSummary = !!entry.summaryText
const loadTags = useCallback(async () => {
if (!showTags) return
@ -60,6 +66,35 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
}, [handleAddTag])
const handleToggleSummary = useCallback(async () => {
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
}
setSummaryLoading(false)
}
}, [summaryExpanded, summary, entry.id])
const handleGenerateSummary = useCallback(async () => {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.summarize({ historyId: entry.id })
if (result.success) {
setSummary(result.data)
}
setSummaryLoading(false)
}, [entry.id])
const handleExportSummary = useCallback(async () => {
await window.electronAPI.meetingSummary.exportMarkdown({ historyId: entry.id })
}, [entry.id])
return (
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
@ -94,6 +129,24 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
{hasSummary && (
<Chip
icon={<SummarizeIcon sx={{ fontSize: '12px !important' }} />}
label={t('meetingSummary.title')}
size="small"
onClick={handleToggleSummary}
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.small,
cursor: 'pointer',
'& .MuiChip-icon': { color: d3roPalette.tag.green },
}}
/>
)}
</Box>
{/* 태그 영역 */}
{showTags && (
@ -180,6 +233,82 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
)}
</Box>
</Box>
{/* Phase 12.2: 회의록 요약 확장 뷰 */}
{summaryExpanded && (
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
{summaryLoading ? (
<PhosphorText variant="dim">{t('meetingSummary.generating')}</PhosphorText>
) : summary ? (
<Box sx={{ fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{summary.summary && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.summary').toUpperCase()}
</PhosphorText>
<Box sx={{ whiteSpace: 'pre-wrap' }}>{summary.summary}</Box>
</Box>
)}
{summary.decisions.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.decisions').toUpperCase()}
</PhosphorText>
{summary.decisions.map((d, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {d}</Box>
))}
</Box>
)}
{summary.actionItems.length > 0 && (
<Box sx={{ mb: 1 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.actionItems').toUpperCase()}
</PhosphorText>
{summary.actionItems.map((a, i) => (
<Box key={i} sx={{ pl: 1.5 }}> {a}</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Tooltip title={t('meetingSummary.exportMarkdown')} arrow>
<IconButton size="small" onClick={handleExportSummary} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<SummarizeIcon sx={{ fontSize: 14 }} />
</IconButton>
</Tooltip>
</Box>
</Box>
) : (
<Box sx={{ textAlign: 'center' }}>
<PhosphorText variant="dim" sx={{ mb: 1 }}>{t('meetingSummary.noSummary')}</PhosphorText>
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && (
<Chip
label={t('meetingSummary.generate')}
size="small"
onClick={handleGenerateSummary}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.chassis,
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
)}
</Box>
)}
</Box>
)}
{/* 요약 토글 버튼 (caption/file-transcription 모드만) */}
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && !summaryExpanded && !hasSummary && (
<Box
sx={{ mt: 1, textAlign: 'center', cursor: 'pointer', color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
onClick={handleToggleSummary}
>
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</Box>
)}
</MetalCard>
)
}