diff --git a/apps/desktop/src/main/services/DictationTemplateService.ts b/apps/desktop/src/main/services/DictationTemplateService.ts index dc5061a..3267c13 100644 --- a/apps/desktop/src/main/services/DictationTemplateService.ts +++ b/apps/desktop/src/main/services/DictationTemplateService.ts @@ -14,6 +14,9 @@ import { type UserScopedCollectionFactory, } from './account-scope' import { getMainWindow } from '../windows/WindowManager' +import { registerVoiceTextSink } from '../voice-text-routing' +import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy' +import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { @@ -80,9 +83,15 @@ class DictationTemplateService extends EventEmitter { private _items: DictationTemplate[] = [] private readonly _unsubscribeScope: () => void private _session: TemplateSessionInfo | null = null + private readonly _output: TemplateOutputPort + private readonly _unregisterVoiceSink: () => void - constructor(factory: UserScopedCollectionFactory = collectionFactory) { + constructor( + factory: UserScopedCollectionFactory = collectionFactory, + output: TemplateOutputPort = outputPort, + ) { super() + this._output = output this._list = new AccountScopedList({ name: 'dictation-templates', factory, @@ -90,6 +99,8 @@ class DictationTemplateService extends EventEmitter { }) this._loadScope(currentAccountScope()) this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId)) + // 진행 중인 템플릿 세션이 있으면 받아쓰기 결과를 현재 필드 값으로 가져간다 + this._unregisterVoiceSink = registerVoiceTextSink(({ text }) => this.consumeDictatedText(text)) } // ── CRUD ── @@ -227,7 +238,7 @@ class DictationTemplateService extends EventEmitter { const currentField = this._session.currentField const nextIndex = this._session.currentFieldIndex + 1 - const nextField = nextIndex < template.fields.length ? template.fields[nextIndex] : null + const nextField = nextTemplateField(template, this._session.currentFieldIndex) // 필드 완료 이벤트 const fieldEvent: TemplateFieldCompletedEvent = { @@ -251,6 +262,17 @@ class DictationTemplateService extends EventEmitter { } } + /** + * 받아쓰기 결과를 현재 필드 값으로 받는다. 필드 입력 대기 중인 세션이 없거나 결과가 비었으면 + * 아무것도 하지 않고 false를 돌려준다 — 그 결과는 일반 삽입 경로로 간다. + */ + consumeDictatedText(text: string): boolean { + const target = dictatedFieldValue(this._session, text) + if (!target) return false + this.setFieldValue(target.fieldId, target.value) + return true + } + cancelSession(): void { if (!this._session) return logger.info('Template session cancelled') @@ -264,17 +286,10 @@ class DictationTemplateService extends EventEmitter { 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, '') - + // 출력 포맷 적용 (mustache-like 치환, 미입력 자리표시자 제거) const completedEvent: TemplateSessionCompletedEvent = { templateId: template.id, - outputText: output.trim(), + outputText: renderTemplateOutput(template.outputFormat, this._session.fieldValues), fieldValues: { ...this._session.fieldValues }, } @@ -283,6 +298,11 @@ class DictationTemplateService extends EventEmitter { this._session = null logger.info(`Template session completed: ${template.name}`) + + // 완성된 결과를 활성 앱에 전달한다 — 실패해도 세션은 이미 끝났다(렌더러가 결과를 보여 준다) + this._output.deliver(completedEvent.outputText).catch((err: unknown) => { + logger.warn(`Template output delivery failed: ${err instanceof Error ? err.message : String(err)}`) + }) } private _emitSessionState(): void { @@ -334,6 +354,7 @@ class DictationTemplateService extends EventEmitter { dispose(): void { this._unsubscribeScope() + this._unregisterVoiceSink() this.cancelSession() this.removeAllListeners() } @@ -351,6 +372,7 @@ function readLegacyTemplates(): DictationTemplate[] | null { } let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory +let outputPort: TemplateOutputPort = textInsertTemplateOutputPort // ── 싱글톤 ── let instance: DictationTemplateService | null = null @@ -363,9 +385,11 @@ export function getDictationTemplateService(): DictationTemplateService { } export function resetDictationTemplateServiceForTests( - factory: UserScopedCollectionFactory = electronStoreCollectionFactory + factory: UserScopedCollectionFactory = electronStoreCollectionFactory, + output: TemplateOutputPort = textInsertTemplateOutputPort, ): void { instance?.dispose() instance = null collectionFactory = factory + outputPort = output } diff --git a/apps/desktop/src/main/services/dictation-template-output.ts b/apps/desktop/src/main/services/dictation-template-output.ts new file mode 100644 index 0000000..09a188f --- /dev/null +++ b/apps/desktop/src/main/services/dictation-template-output.ts @@ -0,0 +1,19 @@ +// 받아쓰기 템플릿 결과 전달 포트. 기본 구현은 활성 앱에 텍스트를 삽입한다. +// 서비스는 이 인터페이스에만 기대고, 테스트는 가짜 포트를 넣는다. + +export interface TemplateOutputPort { + /** 완성된 템플릿 결과를 사용자에게 전달한다. 실패하면 reject한다. */ + deliver(text: string): Promise +} + +/** 일반 받아쓰기와 같은 설정(autoInsert, insertMethod)으로 TextInsertService를 통해 삽입한다. */ +export const textInsertTemplateOutputPort: TemplateOutputPort = { + async deliver(text: string): Promise { + if (text.length === 0) return + // 지연 import — 네이티브 키보드 모듈을 템플릿 CRUD 경로에서 끌어오지 않는다 + const { configGet } = await import('./ConfigService') + if (!configGet('autoInsert')) return + const { getTextInsertService } = await import('./TextInsertService') + await getTextInsertService().insertText(text, configGet('insertMethod')) + }, +} diff --git a/apps/desktop/src/main/services/dictation-template-policy.ts b/apps/desktop/src/main/services/dictation-template-policy.ts new file mode 100644 index 0000000..46eace7 --- /dev/null +++ b/apps/desktop/src/main/services/dictation-template-policy.ts @@ -0,0 +1,37 @@ +// 받아쓰기 템플릿 순수 정책 — IO 없음. DictationTemplateService와 테스트가 함께 쓴다. + +import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types' + +const PLACEHOLDER = /\{\{([^}]+)\}\}/g + +/** + * 출력 포맷의 {{필드 id}} 자리표시자를 값으로 바꾼다. 값이 없는 자리표시자는 지운다. + * 한 번의 치환으로 처리하므로 값 안의 `$&`, `{{...}}`, 정규식 특수문자가 든 필드 id도 그대로 다룬다. + */ +export function renderTemplateOutput(format: string, fieldValues: Readonly>): string { + return format + .replace(PLACEHOLDER, (_match, key: string) => + Object.prototype.hasOwnProperty.call(fieldValues, key) ? fieldValues[key] : '', + ) + .trim() +} + +/** 현재 필드 다음 필드. 마지막이면 null. */ +export function nextTemplateField(template: DictationTemplate, currentIndex: number): TemplateField | null { + const nextIndex = currentIndex + 1 + return nextIndex < template.fields.length ? template.fields[nextIndex] : null +} + +/** + * 받아쓰기 결과를 현재 필드 값으로 받을지 판단한다. 받을 수 없으면 null. + * 세션이 필드 입력 대기 중이고, 공백을 뺀 결과가 비어 있지 않을 때만 받는다. + */ +export function dictatedFieldValue( + session: TemplateSessionInfo | null, + text: string, +): { fieldId: string; value: string } | null { + if (!session || session.state !== 'field-prompting' || !session.currentField) return null + const value = text.trim() + if (value.length === 0) return null + return { fieldId: session.currentField.id, value } +} diff --git a/apps/desktop/src/main/voice-session-persist.ts b/apps/desktop/src/main/voice-session-persist.ts index a47eb62..5da1fe2 100644 --- a/apps/desktop/src/main/voice-session-persist.ts +++ b/apps/desktop/src/main/voice-session-persist.ts @@ -1,10 +1,12 @@ -// VoiceMode session-completed → History 저장. bootstrap과 테스트가 같은 함수를 호출한다. +// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장. +// bootstrap과 테스트가 같은 함수를 호출한다. import { join } from 'path' import { app } from 'electron' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { getHistoryService } from './services/HistoryService' import { getLogger } from './services/LoggerService' +import { routeCompletedVoiceText } from './voice-text-routing' import type { HistoryEntry } from '@d3ro/core/types' const logger = getLogger('voice-persist') @@ -37,11 +39,21 @@ export function persistCompletedVoiceSession( }) } +/** + * 완료된 받아쓰기 결과를 소비자(진행 중인 받아쓰기 템플릿 세션의 현재 필드 등)에게 넘긴다. + * 소비되면 true — 일반 삽입 경로는 isVoiceSessionClaimed(session.id)로 이를 확인한다. + */ +export function routeCompletedVoiceSession(session: PersistableVoiceSession, finalText: string): boolean { + return routeCompletedVoiceText(session.id, finalText) +} + export function persistCompletedVoiceSessionSafe( emitError: (error: D3ROError, session: PersistableVoiceSession) => void, session: PersistableVoiceSession, finalText: string, ): HistoryEntry | null { + // 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다 + routeCompletedVoiceSession(session, finalText) try { return persistCompletedVoiceSession(session, finalText) } catch (error) { diff --git a/apps/desktop/src/main/voice-text-routing.ts b/apps/desktop/src/main/voice-text-routing.ts new file mode 100644 index 0000000..3e95236 --- /dev/null +++ b/apps/desktop/src/main/voice-text-routing.ts @@ -0,0 +1,62 @@ +// 받아쓰기 결과 라우팅 포트. +// VoiceMode 세션이 끝나면 전사 결과를 여기로 넘긴다. 등록된 소비자(sink)가 결과를 가져가면 +// (예: 진행 중인 받아쓰기 템플릿 세션의 현재 필드) 그 세션은 "소비됨"으로 기록된다. +// 일반 삽입 경로는 isVoiceSessionClaimed(sessionId)로 소비 여부를 확인하고 삽입을 건너뛸 수 있다. +// 이 모듈은 소비자 구현을 모른다 — 소비자가 스스로 등록한다(의존성 역전). + +import { getLogger } from './services/LoggerService' + +const logger = getLogger('voice-text-routing') + +export interface VoiceTextOffer { + sessionId: string + text: string +} + +/** 결과를 가져가면 true. 가져가지 않으면 false — 다음 소비자에게 넘어간다. */ +export type VoiceTextSink = (offer: VoiceTextOffer) => boolean + +/** 소비 기록 보존 개수 — 최근 세션만 기억하면 된다. */ +const MAX_CLAIMED_SESSIONS = 32 + +const sinks = new Set() +const claimedSessions: string[] = [] + +export function registerVoiceTextSink(sink: VoiceTextSink): () => void { + sinks.add(sink) + return () => { + sinks.delete(sink) + } +} + +/** + * 완료된 받아쓰기 결과를 소비자에게 제안한다. 같은 세션을 두 번 넘겨도 한 번만 소비된다. + * @returns 소비자가 가져갔으면 true + */ +export function routeCompletedVoiceText(sessionId: string, text: string): boolean { + if (claimedSessions.includes(sessionId)) return true + for (const sink of [...sinks]) { + let claimed = false + try { + claimed = sink({ sessionId, text }) + } catch (err) { + logger.warn(`Voice text sink failed: ${err instanceof Error ? err.message : String(err)}`) + } + if (claimed) { + claimedSessions.push(sessionId) + if (claimedSessions.length > MAX_CLAIMED_SESSIONS) claimedSessions.shift() + return true + } + } + return false +} + +/** 이 세션의 결과를 소비자가 가져갔는지 — 일반 삽입을 건너뛸지 판단할 때 쓴다. */ +export function isVoiceSessionClaimed(sessionId: string): boolean { + return claimedSessions.includes(sessionId) +} + +export function resetVoiceTextRoutingForTests(): void { + sinks.clear() + claimedSessions.length = 0 +} diff --git a/apps/desktop/src/renderer/components/TemplateSection.tsx b/apps/desktop/src/renderer/components/TemplateSection.tsx index ededb74..332c944 100644 --- a/apps/desktop/src/renderer/components/TemplateSection.tsx +++ b/apps/desktop/src/renderer/components/TemplateSection.tsx @@ -21,6 +21,8 @@ export function TemplateSection(): React.ReactElement { const [formOutput, setFormOutput] = useState('') const [formFields, setFormFields] = useState([]) const [session, setSession] = useState(null) + /** 마지막으로 완성된 템플릿 결과 — 활성 앱 삽입이 꺼져 있거나 실패해도 복사할 수 있게 보여 준다 */ + const [lastOutput, setLastOutput] = useState(null) const [saving, setSaving] = useState(false) const isSavingRef = useRef(false) @@ -50,8 +52,9 @@ export function TemplateSection(): React.ReactElement { const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => { setSession(data) }) - const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => { + const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted((data) => { setSession(null) + setLastOutput(data.outputText.length > 0 ? data.outputText : null) loadData() }) return () => { unsub(); unsubComplete() } @@ -118,9 +121,14 @@ export function TemplateSection(): React.ReactElement { } const handleStartSession = async (templateId: string) => { + setLastOutput(null) await window.electronAPI.dictationTemplate.startSession({ templateId }) } + const handleCopyOutput = () => { + if (lastOutput) void navigator.clipboard.writeText(lastOutput) + } + const handleCancelSession = async () => { await window.electronAPI.dictationTemplate.cancelSession() setSession(null) @@ -176,6 +184,29 @@ export function TemplateSection(): React.ReactElement { {t('template.cancelSession')} + {/* 현재 필드의 음성 프롬프트 — 받아쓰기 결과가 이 필드 값으로 들어간다 */} + {session.currentField?.promptText && ( + + {session.currentField.promptText} + + )} + + )} + + {/* 마지막 완성 결과 */} + {!session && lastOutput && ( + + + {lastOutput} + + + + {t('common.copy')} + + setLastOutput(null)}> + {t('common.close')} + + )} @@ -311,7 +342,7 @@ export function TemplateSection(): React.ReactElement { {t('common.cancel')} diff --git a/apps/desktop/tests/red/templates-wiring-redteam-r2-15.test.ts b/apps/desktop/tests/red/templates-wiring-redteam-r2-15.test.ts new file mode 100644 index 0000000..30ffcd0 --- /dev/null +++ b/apps/desktop/tests/red/templates-wiring-redteam-r2-15.test.ts @@ -0,0 +1,186 @@ +// 받아쓰기 템플릿 세션 배선 회귀 테스트 (redteam r2-15) +// 버그: 템플릿 세션을 시작해도 받아쓰기 결과가 필드로 들어가지 않아 세션이 영구히 남았다. +// 이제 VoiceMode 완료 경로(persistCompletedVoiceSessionSafe)가 결과를 템플릿 세션으로 라우팅하고, +// 완성된 결과는 출력 포트로 전달된다. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getDictationTemplateService, + resetDictationTemplateServiceForTests, +} from '../../src/main/services/DictationTemplateService' +import type { TemplateOutputPort } from '../../src/main/services/dictation-template-output' +import { + dictatedFieldValue, + nextTemplateField, + renderTemplateOutput, +} from '../../src/main/services/dictation-template-policy' +import { + isVoiceSessionClaimed, + registerVoiceTextSink, + resetVoiceTextRoutingForTests, + routeCompletedVoiceText, +} from '../../src/main/voice-text-routing' +import { + persistCompletedVoiceSessionSafe, + type PersistableVoiceSession, +} from '../../src/main/voice-session-persist' +import { getHistoryService } from '../../src/main/services/HistoryService' +import { useRedHarness } from './harness' +import type { DictationTemplate, TemplateSessionInfo } from '@d3ro/core/types' + +useRedHarness() + +afterEach(() => { + resetVoiceTextRoutingForTests() +}) + +function voiceSession(id: string, text: string): PersistableVoiceSession { + return { id, transcription: text, processedText: null, mode: 'dictation', startedAt: Date.now() } +} + +function fakeOutput(): TemplateOutputPort & { delivered: string[] } { + const delivered: string[] = [] + return { + delivered, + deliver: vi.fn(async (text: string) => { + delivered.push(text) + }), + } +} + +function completeVoice(id: string, text: string): void { + persistCompletedVoiceSessionSafe(() => undefined, voiceSession(id, text), text) +} + +describe('받아쓰기 템플릿 세션 ← VoiceMode 완료 배선', () => { + it('세션 중 받아쓰기 결과가 필드를 차례로 채우고, 완성 결과가 출력 포트로 전달된다', async () => { + const output = fakeOutput() + resetDictationTemplateServiceForTests(undefined, output) + const svc = getDictationTemplateService() + const completed: string[] = [] + svc.on('session-completed', (e: { outputText: string }) => completed.push(e.outputText)) + + svc.startSession('builtin-email') + completeVoice('r2-15-a', ' kim@example.test ') + expect(svc.getSessionState()?.currentField?.id).toBe('subject') + completeVoice('r2-15-b', 'Quarterly plan') + expect(svc.getSessionState()?.currentField?.id).toBe('body') + completeVoice('r2-15-c', 'See attached.') + + expect(svc.getSessionState()).toBeNull() + expect(completed).toEqual(['To: kim@example.test\nSubject: Quarterly plan\n\nSee attached.']) + await vi.waitFor(() => expect(output.delivered).toEqual(completed)) + + // 소비된 세션은 일반 삽입 경로가 건너뛸 수 있도록 표시된다 + expect(isVoiceSessionClaimed('r2-15-a')).toBe(true) + expect(isVoiceSessionClaimed('r2-15-c')).toBe(true) + // 히스토리 저장은 그대로 일어난다 + expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(3) + }) + + it('템플릿 세션이 없으면 결과를 가져가지 않는다 (일반 삽입 경로 유지)', () => { + resetDictationTemplateServiceForTests(undefined, fakeOutput()) + getDictationTemplateService() + completeVoice('r2-15-none', 'plain dictation') + expect(isVoiceSessionClaimed('r2-15-none')).toBe(false) + expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(1) + }) + + it('빈 받아쓰기 결과는 필드를 채우지 않는다', () => { + resetDictationTemplateServiceForTests(undefined, fakeOutput()) + const svc = getDictationTemplateService() + svc.startSession('builtin-email') + expect(routeCompletedVoiceText('r2-15-empty', ' ')).toBe(false) + expect(svc.getSessionState()?.currentField?.id).toBe('recipient') + }) + + it('같은 VoiceMode 세션을 두 번 라우팅해도 필드는 한 번만 채워진다', () => { + resetDictationTemplateServiceForTests(undefined, fakeOutput()) + const svc = getDictationTemplateService() + svc.startSession('builtin-email') + expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true) + expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true) + expect(svc.getSessionState()?.currentField?.id).toBe('subject') + expect(svc.getSessionState()?.fieldValues).toEqual({ recipient: 'a@b.test' }) + }) + + it('서비스를 dispose하면 소비자 등록이 풀린다', () => { + resetDictationTemplateServiceForTests(undefined, fakeOutput()) + const svc = getDictationTemplateService() + svc.startSession('builtin-email') + resetDictationTemplateServiceForTests(undefined, fakeOutput()) + expect(routeCompletedVoiceText('r2-15-disposed', 'text')).toBe(false) + }) + + it('출력 전달이 실패해도 세션은 끝나고 예외가 새지 않는다', async () => { + const deliver = vi.fn(async () => { + throw new Error('insert failed') + }) + resetDictationTemplateServiceForTests(undefined, { deliver }) + const svc = getDictationTemplateService() + const created = svc.create({ + name: 'one', + description: '', + fields: [{ id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 10 }], + outputFormat: '[{{f1}}]', + }) + svc.startSession(created.id) + expect(svc.consumeDictatedText('hello')).toBe(true) + expect(svc.getSessionState()).toBeNull() + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith('[hello]')) + }) +}) + +describe('라우팅 포트', () => { + it('던지는 소비자는 건너뛰고 다음 소비자에게 넘긴다', () => { + registerVoiceTextSink(() => { + throw new Error('boom') + }) + const seen: string[] = [] + registerVoiceTextSink(({ text }) => { + seen.push(text) + return true + }) + expect(routeCompletedVoiceText('r2-15-sink', 'x')).toBe(true) + expect(seen).toEqual(['x']) + }) +}) + +describe('받아쓰기 템플릿 정책', () => { + it('자리표시자를 값으로 바꾸고 미입력 자리표시자는 지운다', () => { + expect(renderTemplateOutput('To: {{a}}\n{{b}}\n', { a: 'x' })).toBe('To: x') + }) + + it('값 안의 치환 패턴($&)과 중괄호는 그대로 남는다', () => { + expect(renderTemplateOutput('{{a}}|{{b}}', { a: 'cost $& more', b: '{{a}}' })).toBe('cost $& more|{{a}}') + }) + + it('정규식 특수문자가 든 필드 id도 치환된다', () => { + expect(renderTemplateOutput('<{{a.b(1)}}>', { 'a.b(1)': 'ok' })).toBe('') + }) + + it('다음 필드와 필드 값 판정', () => { + const template = { + fields: [ + { id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 1 }, + { id: 'f2', name: 'f2', label: 'F2', promptText: '', required: true, maxDurationSec: 1 }, + ], + } as unknown as DictationTemplate + expect(nextTemplateField(template, 0)?.id).toBe('f2') + expect(nextTemplateField(template, 1)).toBeNull() + + const session: TemplateSessionInfo = { + templateId: 't', + templateName: 'T', + state: 'field-prompting', + currentFieldIndex: 0, + totalFields: 2, + currentField: template.fields[0], + fieldValues: {}, + } + expect(dictatedFieldValue(session, ' hi ')).toEqual({ fieldId: 'f1', value: 'hi' }) + expect(dictatedFieldValue(session, ' ')).toBeNull() + expect(dictatedFieldValue({ ...session, state: 'completing' }, 'hi')).toBeNull() + expect(dictatedFieldValue(null, 'hi')).toBeNull() + }) +})