d3ro-voice/apps/desktop/src/main/services/DictationTemplateService.ts
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

308 lines
9.7 KiB
TypeScript

// src/main/services/DictationTemplateService.ts
// Phase 12.3: 딕테이션 템플릿 서비스
// 템플릿 CRUD + 세션 상태 머신 (필드별 음성 입력)
import { EventEmitter } from 'events'
import Store from 'electron-store'
import { getLogger } from './LoggerService'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
DictationTemplate,
TemplateSessionInfo,
TemplateFieldCompletedEvent,
TemplateSessionCompletedEvent,
CreateTemplateParams,
UpdateTemplateParams,
} from '@d3ro/core/types'
const logger = getLogger('DictationTemplateService')
// ── 프리셋 템플릿 ──
const BUILTIN_TEMPLATES: DictationTemplate[] = [
{
id: 'builtin-email',
name: 'Email',
description: 'Email template with recipient, subject, and body',
fields: [
{ id: 'recipient', name: 'recipient', label: 'Recipient', promptText: 'Who is this email for?', required: true, maxDurationSec: 15 },
{ id: 'subject', name: 'subject', label: 'Subject', promptText: 'What is the subject?', required: true, maxDurationSec: 15 },
{ id: 'body', name: 'body', label: 'Body', promptText: 'Please dictate the email body.', required: true, maxDurationSec: 120 },
],
outputFormat: 'To: {{recipient}}\nSubject: {{subject}}\n\n{{body}}',
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'builtin-meeting-notes',
name: 'Meeting Notes',
description: 'Meeting notes template',
fields: [
{ id: 'title', name: 'title', label: 'Title', promptText: 'What is the meeting title?', required: true, maxDurationSec: 15 },
{ id: 'attendees', name: 'attendees', label: 'Attendees', promptText: 'Who attended?', required: false, maxDurationSec: 30 },
{ id: 'agenda', name: 'agenda', label: 'Agenda', promptText: 'What was discussed?', required: true, maxDurationSec: 120 },
{ id: 'decisions', name: 'decisions', label: 'Decisions', promptText: 'What decisions were made?', required: false, maxDurationSec: 60 },
],
outputFormat: '# {{title}}\n\nAttendees: {{attendees}}\n\n## Agenda\n{{agenda}}\n\n## Decisions\n{{decisions}}',
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'builtin-report',
name: 'Report',
description: 'Simple report template',
fields: [
{ id: 'title', name: 'title', label: 'Title', promptText: 'Report title?', required: true, maxDurationSec: 15 },
{ id: 'summary', name: 'summary', label: 'Summary', promptText: 'Summarize the key points.', required: true, maxDurationSec: 60 },
{ id: 'details', name: 'details', label: 'Details', promptText: 'Provide the details.', required: true, maxDurationSec: 180 },
],
outputFormat: '# {{title}}\n\n## Summary\n{{summary}}\n\n## Details\n{{details}}',
isBuiltin: true,
createdAt: Date.now(),
updatedAt: Date.now(),
},
]
type TemplateStoreSchema = {
templates: DictationTemplate[]
}
class DictationTemplateService extends EventEmitter {
private _store: Store<TemplateStoreSchema>
private _session: TemplateSessionInfo | null = null
constructor() {
super()
this._store = new Store<TemplateStoreSchema>({
name: 'dictation-templates',
defaults: {
templates: [...BUILTIN_TEMPLATES],
},
})
// 프리셋이 없으면 추가
this._ensureBuiltins()
}
// ── CRUD ──
getAll(): DictationTemplate[] {
return this._store.get('templates', [])
}
getById(id: string): DictationTemplate | null {
const templates = this.getAll()
return templates.find((t) => t.id === id) ?? null
}
create(params: CreateTemplateParams): DictationTemplate {
const template: DictationTemplate = {
id: crypto.randomUUID(),
name: params.name,
description: params.description,
fields: params.fields,
outputFormat: params.outputFormat,
isBuiltin: false,
createdAt: Date.now(),
updatedAt: Date.now(),
}
const templates = this.getAll()
templates.push(template)
this._store.set('templates', templates)
return template
}
update(params: UpdateTemplateParams): DictationTemplate {
const templates = this.getAll()
const idx = templates.findIndex((t) => t.id === params.id)
if (idx === -1) {
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${params.id}`)
}
const existing = templates[idx]
const updated: DictationTemplate = {
...existing,
...(params.name !== undefined && { name: params.name }),
...(params.description !== undefined && { description: params.description }),
...(params.fields !== undefined && { fields: params.fields }),
...(params.outputFormat !== undefined && { outputFormat: params.outputFormat }),
updatedAt: Date.now(),
}
templates[idx] = updated
this._store.set('templates', templates)
return updated
}
delete(id: string): void {
const templates = this.getAll()
const template = templates.find((t) => t.id === id)
if (!template) {
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${id}`)
}
if (template.isBuiltin) {
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Cannot delete builtin template')
}
this._store.set(
'templates',
templates.filter((t) => t.id !== id),
)
}
// ── 세션 관리 ──
getSessionState(): TemplateSessionInfo | null {
return this._session
}
startSession(templateId: string): void {
if (this._session) {
throw new D3ROError(ErrorCode.TemplateSessionAlreadyActive, 'Template session already active')
}
const template = this.getById(templateId)
if (!template) {
throw new D3ROError(ErrorCode.TemplateNotFound, `Template not found: ${templateId}`)
}
if (template.fields.length === 0) {
throw new D3ROError(ErrorCode.TemplateInvalidFormat, 'Template has no fields')
}
this._session = {
templateId,
templateName: template.name,
state: 'field-prompting',
currentFieldIndex: 0,
totalFields: template.fields.length,
currentField: template.fields[0],
fieldValues: {},
}
this._emitSessionState()
logger.info(`Template session started: ${template.name}`)
}
setFieldValue(fieldId: string, value: string): void {
if (!this._session) {
throw new D3ROError(ErrorCode.TemplateSessionNotActive, 'No active template session')
}
const template = this.getById(this._session.templateId)
if (!template) {
throw new D3ROError(ErrorCode.TemplateNotFound, 'Session template not found')
}
this._session.fieldValues[fieldId] = value
const currentField = this._session.currentField
const nextIndex = this._session.currentFieldIndex + 1
const nextField = nextIndex < template.fields.length ? template.fields[nextIndex] : null
// 필드 완료 이벤트
const fieldEvent: TemplateFieldCompletedEvent = {
fieldId,
fieldName: currentField?.name ?? fieldId,
value,
nextField,
}
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.FIELD_COMPLETED, fieldEvent)
this.emit('field-completed', fieldEvent)
if (nextField) {
// 다음 필드로 이동
this._session.currentFieldIndex = nextIndex
this._session.currentField = nextField
this._session.state = 'field-prompting'
this._emitSessionState()
} else {
// 모든 필드 완료 → 출력 생성
this._completeSession(template)
}
}
cancelSession(): void {
if (!this._session) return
logger.info('Template session cancelled')
this._session = null
this._emitSessionState()
}
private _completeSession(template: DictationTemplate): void {
if (!this._session) return
this._session.state = 'completing'
this._emitSessionState()
// 출력 포맷 적용 (mustache-like 치환)
let output = template.outputFormat
for (const [key, value] of Object.entries(this._session.fieldValues)) {
output = output.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
}
// 미입력 필드 자리표시자 제거
output = output.replace(/\{\{[^}]+\}\}/g, '')
const completedEvent: TemplateSessionCompletedEvent = {
templateId: template.id,
outputText: output.trim(),
fieldValues: { ...this._session.fieldValues },
}
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.SESSION_COMPLETED, completedEvent)
this.emit('session-completed', completedEvent)
this._session = null
logger.info(`Template session completed: ${template.name}`)
}
private _emitSessionState(): void {
this._sendToRenderer(IPC_CHANNELS.DICTATION_TEMPLATE.SESSION_STATE_CHANGED, this._session)
this.emit('session-state-changed', this._session)
}
private _sendToRenderer(channel: string, data: unknown): void {
try {
const mainWindow = getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data)
}
} catch {
// 윈도우 없으면 무시
}
}
private _ensureBuiltins(): void {
const templates = this.getAll()
for (const builtin of BUILTIN_TEMPLATES) {
if (!templates.find((t) => t.id === builtin.id)) {
templates.push(builtin)
}
}
this._store.set('templates', templates)
}
dispose(): void {
this.cancelSession()
this.removeAllListeners()
}
}
// ── 싱글톤 ──
let instance: DictationTemplateService | null = null
export function getDictationTemplateService(): DictationTemplateService {
if (!instance) {
instance = new DictationTemplateService()
}
return instance
}
export function resetDictationTemplateServiceForTests(): void {
instance = null
}