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:
parent
a31f96bbb8
commit
eb83682269
38 changed files with 5678 additions and 19 deletions
294
src/renderer/components/TemplateSection.tsx
Normal file
294
src/renderer/components/TemplateSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue