From 83cf9133ac29fea05bf0c7819632ec5a550d9785 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:43 +0900 Subject: [PATCH] refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes --- apps/desktop/src/main/bootstrap.ts | 230 +++--- apps/desktop/src/main/lifecycle.ts | 55 +- .../src/main/services/AudioCaptureService.ts | 16 + .../src/main/services/CaptionService.ts | 140 +++- .../src/main/services/LocalSTTService.ts | 167 +++-- .../src/main/services/MeetingModeService.ts | 317 ++++---- .../main/services/MeetingSummaryService.ts | 15 +- apps/desktop/src/main/services/RAGService.ts | 24 +- .../src/main/services/ScreenContextService.ts | 43 +- .../src/main/services/UpdateService.ts | 634 +++++++++------- .../main/services/VoiceConversationService.ts | 39 +- .../src/main/services/VoiceModeService.ts | 674 ++++++++++-------- .../src/main/services/llm/LlmGateway.ts | 236 ++++++ .../src/main/services/rag/docx-text.ts | 116 +++ .../src/main/services/stt/STTManager.ts | 67 +- apps/desktop/src/main/services/stt/types.ts | 25 + apps/desktop/src/main/shutdown-registry.ts | 113 +++ apps/desktop/src/main/update-policy.ts | 81 +++ .../src/main/windows/popup-navigation.ts | 157 ++++ .../main/windows/recording-tip-presenter.ts | 133 ++++ .../components/meeting/MeetingChatPanel.tsx | 34 +- .../audio-capture-redteam-r1-3.test.ts | 75 ++ .../caption-owner-redteam-r1-3.test.ts | 134 ++++ .../tests/main/services/llm-gateway.test.ts | 131 ++++ .../services/rag-docx-redteam-r1-3.test.ts | 43 ++ .../screen-context-redteam-r1-3.test.ts | 70 ++ .../stt-fallback-redteam-r1-3.test.ts | 171 +++++ .../update-service-redteam-r1-3.test.ts | 232 ++++++ .../services/voice-mode-redteam-r1-3.test.ts | 389 ++++++++++ .../tests/main/shutdown-registry.test.ts | 72 ++ .../main/windows/popup-navigation.test.ts | 162 +++++ .../windows/recording-tip-presenter.test.ts | 94 +++ .../red/meeting-mode-redteam-r1-3.test.ts | 226 ++++++ .../red/meeting-summary-redteam-r1-3.test.ts | 32 + .../core/__tests__/meeting-markdown.test.ts | 99 +++ .../meeting-transcript-segments.test.ts | 99 +++ packages/core/src/meeting-transcript.ts | 107 +++ packages/core/src/utils/markdown-to-docx.ts | 103 ++- packages/core/src/utils/meeting-markdown.ts | 293 ++++++-- 39 files changed, 4787 insertions(+), 1061 deletions(-) create mode 100644 apps/desktop/src/main/services/llm/LlmGateway.ts create mode 100644 apps/desktop/src/main/services/rag/docx-text.ts create mode 100644 apps/desktop/src/main/shutdown-registry.ts create mode 100644 apps/desktop/src/main/windows/popup-navigation.ts create mode 100644 apps/desktop/src/main/windows/recording-tip-presenter.ts create mode 100644 apps/desktop/tests/main/services/audio-capture-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/caption-owner-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/llm-gateway.test.ts create mode 100644 apps/desktop/tests/main/services/rag-docx-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/screen-context-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/stt-fallback-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/main/shutdown-registry.test.ts create mode 100644 apps/desktop/tests/main/windows/popup-navigation.test.ts create mode 100644 apps/desktop/tests/main/windows/recording-tip-presenter.test.ts create mode 100644 apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts create mode 100644 apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts create mode 100644 packages/core/__tests__/meeting-markdown.test.ts create mode 100644 packages/core/__tests__/meeting-transcript-segments.test.ts diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index aa22194..07ef082 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -9,7 +9,12 @@ import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingServ import { acquireGlobalInputHook } from './services/global-input-hook' import { trackModifierKeys } from './services/modifier-state' import { getVoiceModeService } from './services/VoiceModeService' -import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService' +import { + startLocalLLMAvailability, + getLocalLLMService, + disposeLocalLLMService, +} from './services/LocalLLMService' +import { disposeLocalSTTServiceIfStarted } from './services/LocalSTTService' import { getHistoryService } from './services/HistoryService' import { persistCompletedVoiceSessionSafe } from './voice-session-persist' import { getTextInsertService } from './services/TextInsertService' @@ -34,14 +39,16 @@ import { hideCommandPopup, sendKeyToCommandPopup, isCommandPopupVisible, - hideRecordingTip, - updateRecordingTipState, showSuggestionOverlay, updateSuggestionOverlay, hideSuggestionOverlay, isSuggestionOverlayVisible, } from './windows/WindowManager' import { createTray } from './windows/TrayManager' +import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter' +import { PopupNavigationController } from './windows/popup-navigation' +import { setupLifecycle } from './lifecycle' +import { getShutdownRegistry } from './shutdown-registry' import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy' import { registerAllIpcHandlers } from './ipc' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' @@ -56,6 +63,11 @@ interface BootstrapStep { } export async function bootstrap(): Promise { + // 종료 훅을 가장 먼저 건다 — 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우고 + // 등록된 자원을 정리해야 한다. (index.ts 의 뒤늦은 호출은 멱등이라 아무것도 하지 않는다) + setupLifecycle() + registerCoreShutdownSteps() + const steps: BootstrapStep[] = [ { name: 'logger', critical: false, fn: initLogger }, { name: 'config', critical: false, fn: initConfig }, @@ -102,6 +114,16 @@ export async function bootstrap(): Promise { } } +/** + * 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다. + * (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다) + */ +function registerCoreShutdownSteps(): void { + const registry = getShutdownRegistry() + registry.register({ name: 'local-llm', dispose: () => disposeLocalLLMService() }) + registry.register({ name: 'local-stt', dispose: () => disposeLocalSTTServiceIfStarted() }) +} + async function initLogger(): Promise { initLoggerService() } @@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise { }) telemetry.start() + + // 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다 + const registry = getShutdownRegistry() + registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() }) + registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() }) } /** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */ @@ -285,107 +312,111 @@ async function initAutoLaunch(): Promise { async function initAutoUpdate(): Promise { const { getUpdateService } = await import('./services/UpdateService') - getUpdateService().initialize() + const updates = getUpdateService() + updates.initialize() + getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() }) } async function initPopupWindows(): Promise { preloadPopupWindows() - setupHistoryPopupIPC() // 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환) getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ }) + const popups = createPopupNavigationController() + getShutdownRegistry().register({ name: 'popup-navigation', dispose: () => popups.dispose() }) + // 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다. getKeyBindingService().on('triggered', (payload) => { if (payload.type !== 'pressed') return if (payload.actionId === 'history-popup') { - toggleHistoryPopup() + popups.toggle('history') } else if (payload.actionId === 'command-popup') { - toggleCommandPopup() + popups.toggle('command') } }) - setupCommandPopupIPC() + setupPopupIPC(popups) } -function toggleHistoryPopup(): void { - if (isHistoryPopupVisible()) { - hideHistoryPopup() - unregisterPopupNavKeys() - return - } - const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries - showHistoryPopup(entries as unknown as Array>) - registerPopupNavKeys() +/** 팝업 컨트롤러를 Electron 창·전역 단축키·서비스 어댑터로 조립한다. */ +function createPopupNavigationController(): PopupNavigationController { + return new PopupNavigationController({ + windows: { + history: { + show: () => { + const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries + showHistoryPopup(entries as unknown as Array>) + }, + hide: () => hideHistoryPopup(), + isVisible: () => isHistoryPopupVisible(), + sendKey: (key) => sendKeyToHistoryPopup(key), + }, + command: { + show: () => { + const instructions = getCustomInstructionService().getAll() + const activeId = configGet('activeInstructionId') as string | null + showCommandPopup(instructions as unknown as Array>, activeId || null) + }, + hide: () => hideCommandPopup(), + isVisible: () => isCommandPopupVisible(), + sendKey: (key) => sendKeyToCommandPopup(key), + }, + }, + shortcuts: { + register: (accelerator, callback) => globalShortcut.register(accelerator, callback), + unregister: (accelerator) => globalShortcut.unregister(accelerator), + }, + insertText: async (text) => { + await getTextInsertService().insertText(text) + }, + applyCommandSelection: ({ id, name }) => { + if (id) { + // 명령어 선택 → 활성 명령어로 설정 + configSet('activeInstructionId', id) + configSet('defaultLLMAction', 'custom') + logger.info(`Active command set: ${name} (${id})`) + } else { + // 선택 해제 → 명령어 없음 (원본 삽입) + configSet('activeInstructionId', '') + configSet('defaultLLMAction', 'none') + logger.info('Active command cleared (none)') + } + // CMD 페이지 UI 갱신 알림 + notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: id }) + }, + warn: (message) => logger.warn(message), + }) } -function toggleCommandPopup(): void { - if (isCommandPopupVisible()) { - hideCommandPopup() - unregisterPopupNavKeys() - return - } - const instructions = getCustomInstructionService().getAll() - const activeId = configGet('activeInstructionId') as string | null - showCommandPopup(instructions as unknown as Array>, activeId || null) - registerPopupNavKeys('command') -} - -// 히스토리 팝업 키 네비게이션 등록/해제 -const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [ - { accel: 'Up', key: 'ArrowUp' }, - { accel: 'Down', key: 'ArrowDown' }, - { accel: 'Return', key: 'Enter' }, - { accel: 'Escape', key: 'Escape' }, - { accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' }, - { accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' }, - { accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, -] - -function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void { - for (const { accel, key } of POPUP_NAV_KEYS) { - try { - globalShortcut.register(accel, () => { - if (target === 'history' && isHistoryPopupVisible()) { - sendKeyToHistoryPopup(key) - } else if (target === 'command' && isCommandPopupVisible()) { - sendKeyToCommandPopup(key) - } - }) - } catch { - // 일부 키는 globalShortcut으로 등록 불가할 수 있음 - } - } -} - -function unregisterPopupNavKeys(): void { - for (const { accel } of POPUP_NAV_KEYS) { - try { - globalShortcut.unregister(accel) - } catch { - // 이미 해제된 경우 무시 - } - } -} - - async function initVoiceMode(): Promise { const voiceMode = getVoiceModeService() voiceMode.connectKeyBindings() const soundEffect = getSoundEffectService() - // 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리) + // RecordingTip·결과 팝업 표시는 프레젠터 한 곳이 담당한다 (취소 가능한 에러 숨김 타이머 하나) + const tip = new RecordingTipPresenter(createWindowRecordingTipView()) + getShutdownRegistry().register({ name: 'recording-tip', dispose: () => tip.dispose() }) + getShutdownRegistry().register({ name: 'voice-mode', dispose: () => voiceMode.dispose() }) + voiceMode.on('phase', ({ phase }) => tip.onPhase(phase)) + voiceMode.on('audio-level', ({ level }) => tip.onAudioLevel(level)) + voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text)) + voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text)) + + // 효과음 연동 voiceMode.on('session-started', () => { + tip.onSessionStarted() soundEffect.play('recording-start') }) voiceMode.on('session-completed', ({ session, finalText }) => { + tip.onSessionEnded() soundEffect.play('recording-stop') persistCompletedVoiceSessionSafe( - (error, failedSession) => { - voiceMode.emit('error', { error, session: failedSession }) + (error) => { + voiceMode.emit('error', { error, session }) }, session, finalText, @@ -398,13 +429,13 @@ async function initVoiceMode(): Promise { if (reason !== 'too-short') { soundEffect.play('cancel') } - hideRecordingTip() + tip.onSessionEnded() }) - voiceMode.on('error', ({ error }) => { - soundEffect.play('error') - updateRecordingTipState('error', { errorMessage: error.message }) - setTimeout(() => hideRecordingTip(), 3000) + voiceMode.on('error', ({ error, severity }) => { + // 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음 + if (severity !== 'warning') soundEffect.play('error') + tip.onError(error.message, severity) }) } @@ -447,7 +478,7 @@ async function initMeetingSummaryWiring(): Promise { const captionService = getCaptionService() const summaryService = getMeetingSummaryService() - captionService.on('session-saved', (summary: { sessionId: string }) => { + captionService.on('session-saved', (summary) => { // 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기 import('./services/MeetingModeService').then(({ getMeetingModeService }) => { if (getMeetingModeService().isMeetingModeActive()) { @@ -478,62 +509,31 @@ async function initMeetingMode(): Promise { getMeetingDocTemplateService() } -// ── HistoryPopup IPC 연동 ──────────────────────────── +// ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ───── -function setupHistoryPopupIPC(): void { - // 히스토리 팝업 열기 (핫키에서 호출) +function setupPopupIPC(popups: PopupNavigationController): void { + // 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다) ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => { - const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries - showHistoryPopup(entries as unknown as Array>) + popups.show('history') }) // 아이템 선택 → 텍스트 삽입 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => { - hideHistoryPopup() - unregisterPopupNavKeys() - setTimeout(async () => { - try { - await getTextInsertService().insertText(data.text) - } catch (error) { - logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`) - } - }, 200) + popups.selectHistoryItem(data.text) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => { - hideHistoryPopup() - unregisterPopupNavKeys() + popups.dismiss('history') }) -} -// ── CommandPopup IPC 연동 ──────────────────────────── - -function setupCommandPopupIPC(): void { // 명령어 선택 → 활성 명령어로 설정 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => { - hideCommandPopup() - unregisterPopupNavKeys() - - if (data.id) { - // 명령어 선택 → 활성 명령어로 설정 - configSet('activeInstructionId', data.id) - configSet('defaultLLMAction', 'custom') - logger.info(`Active command set: ${data.name} (${data.id})`) - } else { - // 선택 해제 → 명령어 없음 (원본 삽입) - configSet('activeInstructionId', '') - configSet('defaultLLMAction', 'none') - logger.info('Active command cleared (none)') - } - - // CMD 페이지 UI 갱신 알림 - notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: data.id }) + popups.selectCommand({ id: data.id, name: data.name }) }) // 팝업 닫기 ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => { - hideCommandPopup() - unregisterPopupNavKeys() + popups.dismiss('command') }) } diff --git a/apps/desktop/src/main/lifecycle.ts b/apps/desktop/src/main/lifecycle.ts index f168eb1..534bdf7 100644 --- a/apps/desktop/src/main/lifecycle.ts +++ b/apps/desktop/src/main/lifecycle.ts @@ -1,13 +1,23 @@ // src/main/lifecycle.ts — 종료 시퀀스 +// +// 종료 시 정리 대상은 ShutdownRegistry 에 서비스가 스스로 등록한다. lifecycle 은 개별 서비스를 +// import 하지 않고 등록부만 비운다. setupLifecycle 은 멱등이다 — bootstrap 이 가장 먼저 불러 +// 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우게 한다(그래야 창의 +// close-to-tray 가 종료를 가로채지 않는다). index.ts 의 뒤늦은 호출은 아무것도 하지 않는다. import { app } from 'electron' import { getLogger } from './services/LoggerService' import { getConfigService } from './services/ConfigService' -import { disposeLocalLLMService } from './services/LocalLLMService' +import { getShutdownRegistry } from './shutdown-registry' const logger = getLogger('lifecycle') +/** 종료 정리 전체 상한 — STT 사이드카 /shutdown 요청(최대 3초) + SIGKILL 여유 */ +export const SHUTDOWN_TIMEOUT_MS = 4000 + let isQuitting = false +let lifecycleInstalled = false +let shutdownComplete = false export function getIsQuitting(): boolean { return isQuitting @@ -18,22 +28,41 @@ export function setIsQuitting(value: boolean): void { } export function setupLifecycle(): void { + if (lifecycleInstalled) return + lifecycleInstalled = true + app.on('before-quit', () => { logger.info('[lifecycle] before-quit') isQuitting = true }) - app.on('will-quit', () => { - logger.info('[lifecycle] will-quit — flushing config') - disposeLocalLLMService() - try { - const configService = getConfigService() - if (configService) { - // electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요 - logger.info('[lifecycle] config flush complete') - } - } catch (error) { - logger.warn('[lifecycle] config flush failed:', error) - } + app.on('will-quit', (event) => { + if (shutdownComplete) return + // 첫 will-quit 에서만 종료를 미루고 등록된 자원을 정리한 뒤 다시 종료한다 + event.preventDefault() + logger.info('[lifecycle] will-quit — disposing registered resources') + void getShutdownRegistry() + .disposeAll(SHUTDOWN_TIMEOUT_MS) + .then((report) => { + logger.info( + `[lifecycle] shutdown: completed=${report.completed.length}, failed=${report.failed.join(',') || '-'}, timedOut=${report.timedOut.join(',') || '-'}`, + ) + }) + .catch((error: unknown) => { + logger.warn('[lifecycle] shutdown registry failed:', error) + }) + .finally(() => { + try { + const configService = getConfigService() + if (configService) { + // electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요 + logger.info('[lifecycle] config flush complete') + } + } catch (error) { + logger.warn('[lifecycle] config flush failed:', error) + } + shutdownComplete = true + app.quit() + }) }) } diff --git a/apps/desktop/src/main/services/AudioCaptureService.ts b/apps/desktop/src/main/services/AudioCaptureService.ts index 08066f4..a2debe8 100644 --- a/apps/desktop/src/main/services/AudioCaptureService.ts +++ b/apps/desktop/src/main/services/AudioCaptureService.ts @@ -66,6 +66,15 @@ class AudioCaptureService extends EventEmitter { /** RMS 계산용 누적 버퍼 (100ms 간격 emit) */ private _levelAccumulator: Buffer[] = [] + constructor() { + super() + // EventEmitter 는 'error' 리스너가 없으면 emit('error') 에서 ERR_UNHANDLED_ERROR 를 던진다. + // 예전엔 SoX 가 장치를 못 열고 끝나면 'close' 콜백 안에서 이 예외가 나 뒤이은 'stopped' 가 + // 영영 나가지 않았고, 받아쓰기는 녹음 팁만 띄운 채 조용히 아무것도 하지 않았다. + // 기본 sink 로 막고, 소비자는 'stopped'(reason) 로 캡처 종료를 안다. 로깅은 호출 지점에서 한다. + this.on('error', () => { /* default sink */ }) + } + get state(): CaptureState { return this._state } @@ -560,3 +569,10 @@ export function getAudioCaptureService(): AudioCaptureService { } return instance } + +export function resetAudioCaptureServiceForTests(): void { + if (instance) instance.removeAllListeners() + instance = null +} + +export { AudioCaptureService } diff --git a/apps/desktop/src/main/services/CaptionService.ts b/apps/desktop/src/main/services/CaptionService.ts index 6e36740..46a070f 100644 --- a/apps/desktop/src/main/services/CaptionService.ts +++ b/apps/desktop/src/main/services/CaptionService.ts @@ -53,11 +53,44 @@ const DEFAULT_CONFIG: CaptionConfig = { audioSource: 'mic', } +/** + * 자막 세션의 소유자. 회의 모드가 시작한 세션은 사용자 핫키/IPC 가 끌 수 없다 + * (예전엔 핫키가 회의의 마이크 캡처를 조용히 끊고 회의 UI 는 계속 '녹음 중'이었다). + */ +export type CaptionOwner = 'user' | 'meeting' + +/** 자막 세션 시작 옵션 — 생략하면 기존(사용자 자막) 동작과 같다 */ +export interface CaptionStartOptions { + /** 기본 'user' */ + owner?: CaptionOwner + /** 이번 세션에만 쓸 오디오 소스. 생략 시 설정값(captionAudioSource). 전역 설정은 건드리지 않는다. */ + audioSource?: CaptionConfig['audioSource'] + /** 자막 오버레이 표시 여부 (기본 true) */ + showOverlay?: boolean + /** 세션을 'caption' 히스토리로 저장하고 session-saved 를 알릴지 (기본 true) */ + persistHistory?: boolean +} + +/** 종료된 세션 요약 + 저장된 히스토리 행 id (저장하지 않았으면 null) */ +export type CaptionSessionResult = CaptionSessionSummary & { historyId: string | null } + +interface ResolvedStartOptions { + owner: CaptionOwner + showOverlay: boolean + persistHistory: boolean +} + +const DEFAULT_START_OPTIONS: ResolvedStartOptions = { + owner: 'user', + showOverlay: true, + persistHistory: true, +} + interface CaptionServiceEvents { 'state-changed': (state: CaptionState) => void 'segment': (segment: CaptionSegment) => void 'delta': (data: { text: string; isFinal: boolean }) => void - 'session-saved': (summary: CaptionSessionSummary) => void + 'session-saved': (summary: CaptionSessionResult) => void 'error': (error: D3ROError) => void } @@ -81,6 +114,8 @@ class CaptionService extends EventEmitter { private _refining = false private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null + /** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */ + private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS } // ── 공개 접근자 ── @@ -88,6 +123,11 @@ class CaptionService extends EventEmitter { return this._state } + /** 현재 세션 소유자 (세션이 없으면 null) */ + getOwner(): CaptionOwner | null { + return this._state === 'inactive' ? null : this._session.owner + } + getConfig(): CaptionConfig { return { ...this._config } } @@ -102,7 +142,7 @@ class CaptionService extends EventEmitter { // ── 시작 ── - async start(): Promise { + async start(options: CaptionStartOptions = {}): Promise { if (this._disposed) { throw new D3ROError( ErrorCode.CaptionStartFailed, @@ -117,12 +157,19 @@ class CaptionService extends EventEmitter { ) } + this._session = { + owner: options.owner ?? DEFAULT_START_OPTIONS.owner, + showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay, + persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory, + } this._setState('starting') - // 오버레이를 즉시 표시 (로딩 상태) - showCaptionOverlay() - sendToCaptionOverlay('caption:config', this._config) - sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' }) + // 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다 + if (this._session.showOverlay) { + showCaptionOverlay() + sendToCaptionOverlay('caption:config', this._config) + sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' }) + } try { // STT 초기화 (모델 로딩 — 시간 소요) @@ -142,13 +189,19 @@ class CaptionService extends EventEmitter { const lang = configGet('sttLanguage') as string | undefined const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : '' - // ConfigService에서 저장된 오디오 소스 읽기 - const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string - if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) { - this._config.audioSource = savedSource as 'mic' | 'system' | 'both' + // 오디오 소스: 호출자 지정값(이번 세션 한정) → 저장된 설정 순. + // 호출자 지정값은 this._config 에 쓰지 않는다 — 다음 사용자 세션으로 새지 않게. + let audioSource: CaptionConfig['audioSource'] + if (options.audioSource) { + audioSource = options.audioSource + } else { + const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string + if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) { + this._config.audioSource = savedSource as 'mic' | 'system' | 'both' + } + audioSource = this._config.audioSource } - const { audioSource } = this._config - logger.info(`Caption audioSource: ${audioSource}`) + logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`) // 마이크 캡처 (mic 또는 both) if (audioSource === 'mic' || audioSource === 'both') { @@ -190,6 +243,7 @@ class CaptionService extends EventEmitter { await audioCaptureService.stop().catch(() => {}) } hideCaptionOverlay() + this._session = { ...DEFAULT_START_OPTIONS } this._setState('inactive') const d3roErr = err instanceof D3ROError @@ -205,10 +259,21 @@ class CaptionService extends EventEmitter { // ── 중지 ── - async stop(): Promise { + /** + * 자막 세션을 멈추고 요약을 돌려준다(말하던 마지막 문장 flush 포함). + * + * @param owner 생략하면 강제 정지(앱 종료·로그아웃·회의 강제 초기화). + * 지정하면 그 소유자의 세션일 때만 멈추고, 아니면 false 를 돌려준다. + * @returns 세션 요약(세그먼트가 없으면 null), 다른 소유자라 거부했으면 false + */ + async stop(owner?: CaptionOwner): Promise { if (this._state !== 'active' && this._state !== 'starting') { logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`) - return + return null + } + if (owner && owner !== this._session.owner) { + logger.warn(`캡션 중지 거부: 세션 소유자=${this._session.owner}, 요청=${owner}`) + return false } this._setState('stopping') @@ -238,22 +303,25 @@ class CaptionService extends EventEmitter { // 오버레이 숨김 hideCaptionOverlay() - // 세션을 히스토리에 저장 - const summary = this._saveSession() + // 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다) + const persist = this._session.persistHistory + const summary = this._saveSession(persist) this._sessionId = null this._sessionStartedAt = null this._refineQueue = [] this._sessionLanguage = null + this._session = { ...DEFAULT_START_OPTIONS } this._setState('inactive') getSoundEffectService().play('recording-stop') logger.info('Live Caption 중지') - if (summary) { + if (summary && persist) { this.emit('session-saved', summary) this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary) } + return summary } // ── dispose ── @@ -411,7 +479,7 @@ class CaptionService extends EventEmitter { // ── 내부: 세션 저장 ── - private _saveSession(): CaptionSessionSummary | null { + private _saveSession(persist: boolean): CaptionSessionResult | null { if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) { return null } @@ -421,10 +489,27 @@ class CaptionService extends EventEmitter { const fullText = this._segments.map((s) => s.text).join(' ') const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length - // 히스토리에 저장 (mode: 'caption') + // 히스토리에 저장 (mode: 'caption'). 만들어진 행 id 를 요약에 싣는다 — + // 자동 요약은 이 id 로 히스토리를 찾는다(예전엔 자막 세션 id 로 찾아 항상 실패했다). + const historyId = persist ? this._persistHistory(fullText, totalDurationMs, wordCount) : null + + const summary: CaptionSessionResult = { + sessionId: this._sessionId, + historyId, + segments: [...this._segments], + startedAt: this._sessionStartedAt, + endedAt, + totalDurationMs, + } + + this._segments = [] + return summary + } + + /** 'caption' 히스토리 행을 만들고 id 를 돌려준다. 실패하면 로그만 남기고 null. */ + private _persistHistory(fullText: string, totalDurationMs: number, wordCount: number): string | null { try { - const historyService = getHistoryService() - historyService.create({ + const entry = getHistoryService().create({ originalText: fullText, polishedText: null, focusedApp: null, @@ -447,22 +532,13 @@ class CaptionService extends EventEmitter { logger.info( `캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`, ) + return entry.id } catch (err) { logger.error( `캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`, ) + return null } - - const summary: CaptionSessionSummary = { - sessionId: this._sessionId, - segments: [...this._segments], - startedAt: this._sessionStartedAt, - endedAt, - totalDurationMs, - } - - this._segments = [] - return summary } // ── 내부: 상태 전이 ── diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 9e338ec..429cbf3 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -214,11 +214,14 @@ class LocalSTTService extends EventEmitter { private _disposed: boolean = false private _gpuAccelerated: boolean = false - // ── 이중 조건 플러시 (Speakly 패턴) ── + // ── 준비 상태 ── + // 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고 + // initialize()가 풀어 주기를 기다렸다. 아무도 initialize()를 부르지 않으면(클라우드 STT 실패 → + // 로컬 폴백) 영원히 끝나지 않았고, 두 번째 호출은 첫 호출을 덮어썼으며, 옵션도 버려졌다. + // 이제 transcribe()는 진행 중인 초기화를 기다리거나 직접 초기화한 뒤 전사한다. private _modelReady: boolean = false - private _audioBuffer: Buffer[] = [] - private _pendingResolve: ((result: TranscriptionResult) => void) | null = null - private _pendingReject: ((error: D3ROError) => void) | null = null + /** 진행 중인 초기화(크래시 재시작 포함) — transcribe()가 이것을 기다린다 */ + private _initializing: { modelId: string; promise: Promise } | null = null // errorEmitted 플래그로 이벤트 중복 방지 private _errorEmitted: boolean = false @@ -253,15 +256,36 @@ class LocalSTTService extends EventEmitter { ) } - // 이미 같은 모델이 로딩된 상태면 무시 + // 이미 같은 모델이 로딩된 상태면 무시 (전사 중이어도 모델은 올라가 있다) if ( - this._state === STTState.Ready && + this._modelReady && + (this._state === STTState.Ready || this._state === STTState.Transcribing) && this._currentModelId === targetModel ) { logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`) return } + // 같은 모델을 올리는 중이면 그 작업에 합류한다 (워밍업 + 첫 받아쓰기 동시 호출 등) + if (this._initializing && this._initializing.modelId === targetModel) { + return this._initializing.promise + } + + return this._trackInitialization(targetModel, this._loadInitial(targetModel)) + } + + /** 진행 중인 초기화를 기록한다 — 끝나면(성공/실패) 스스로 지운다. */ + private _trackInitialization(modelId: string, promise: Promise): Promise { + const entry = { modelId, promise } + this._initializing = entry + const clear = (): void => { + if (this._initializing === entry) this._initializing = null + } + promise.then(clear, clear) + return promise + } + + private async _loadInitial(targetModel: string): Promise { this._setState(STTState.Loading) this._errorEmitted = false @@ -275,9 +299,6 @@ class LocalSTTService extends EventEmitter { this._modelReady = true this._setState(STTState.Ready) - // 이중 조건 플러시 시도 - this._tryFlushAll() - logger.info(`STT 초기화 완료: 모델=${targetModel}`) } catch (err) { this._setState(STTState.Error) @@ -313,23 +334,37 @@ class LocalSTTService extends EventEmitter { throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다') } - // 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기 + // 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다. + // 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다. if (!this._modelReady) { - logger.debug('모델 로딩 중, 오디오 버퍼에 적재') - this._audioBuffer.push(audioBuffer) - - return new Promise((resolve, reject) => { - this._pendingResolve = resolve - this._pendingReject = reject - // 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음) - this._tryFlushAll() - }) + await this._ensureModelReady() } - // 모델 준비 완료 상태: 직접 전사 + // 호출자 옵션(language/initialPrompt/modelId)을 그대로 전달한다 return this._sendToSidecar(audioBuffer, options) } + /** + * 모델이 준비될 때까지 기다린다. + * - 초기화(또는 크래시 재시작)가 진행 중이면 그것을 기다린다. + * - 아무도 초기화하지 않았다면 설정된 모델로 직접 초기화한다 + * (클라우드 STT 실패 → 로컬 폴백, 파일 전사 등 초기화 없이 들어오는 호출). + */ + private async _ensureModelReady(): Promise { + if (this._initializing) { + await this._initializing.promise + } + if (!this._modelReady) { + await this.initialize() + } + if (!this._modelReady) { + throw new D3ROError( + ErrorCode.STTSidecarCommunicationFailed, + 'STT 모델이 준비되지 않았습니다', + ) + } + } + /** * 앱 시작 시 sidecar와 모델을 미리 데운다. * 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로 @@ -542,16 +577,7 @@ class LocalSTTService extends EventEmitter { logger.info('LocalSTTService dispose 시작') - // pending promise를 reject - if (this._pendingReject) { - this._pendingReject( - new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'), - ) - this._pendingResolve = null - this._pendingReject = null - } - - this._audioBuffer = [] + this._initializing = null this._modelReady = false this._auxModelId = null @@ -561,41 +587,6 @@ class LocalSTTService extends EventEmitter { logger.info('LocalSTTService dispose 완료') } - // ── 이중 조건 플러시 (Speakly 핵심 패턴) ── - - /** - * 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행. - * 설정 메시지(모델) 먼저, 오디오 데이터 후. - */ - private _tryFlushAll(): void { - if (!this._modelReady) return - if (this._audioBuffer.length === 0) return - if (!this._pendingResolve) return - - const merged = Buffer.concat(this._audioBuffer) - this._audioBuffer = [] - - const resolve = this._pendingResolve - const reject = this._pendingReject - this._pendingResolve = null - this._pendingReject = null - - this._sendToSidecar(merged) - .then(resolve) - .catch((err: unknown) => { - if (reject) { - reject( - err instanceof D3ROError - ? err - : new D3ROError( - ErrorCode.STTTranscriptionFailed, - `전사 실패: ${err instanceof Error ? err.message : String(err)}`, - ), - ) - } - }) - } - // ── Sidecar 관리 ── /** @@ -803,7 +794,7 @@ class LocalSTTService extends EventEmitter { /** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ private _spawnFailureError( err: Error, - launch: { command: string; source: 'bundled' | 'venv' | 'python' }, + launch: { command: string; source: 'bundled' | 'provisioned' | 'venv' | 'python' }, ): D3ROError { const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT' if (!enoent) { @@ -814,7 +805,7 @@ class LocalSTTService extends EventEmitter { } const hint = - launch.source === 'bundled' + launch.source === 'bundled' || launch.source === 'provisioned' ? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.' : launch.source === 'venv' ? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.' @@ -1058,31 +1049,30 @@ class LocalSTTService extends EventEmitter { ) this._setState(STTState.Error) this._emitError(err) - - // pending promise reject - if (this._pendingReject) { - this._pendingReject(err) - this._pendingResolve = null - this._pendingReject = null - } return } // 비동기 재시작 - const modelToReload = this._currentModelId + const modelToReload = this._currentModelId ?? configGet('sttModelId') this._currentModelId = null this._modelReady = false this._auxModelId = null - // setTimeout으로 이벤트 루프에 양보 - setTimeout(() => { - if (this._disposed) return - this.initialize(modelToReload ?? undefined).catch((err: unknown) => { - logger.error( - `Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`, - ) - }) - }, 1000 * this._restartCount) // 점진적 백오프 + // 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가 + // 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject). + const delayMs = 1000 * this._restartCount // 점진적 백오프 + const restart = new Promise((resolve) => setTimeout(resolve, delayMs)).then(async () => { + if (this._disposed) { + throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소') + } + await this._loadInitial(modelToReload) + }) + restart.catch((restartErr: unknown) => { + logger.error( + `Sidecar 재시작 실패: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`, + ) + }) + this._trackInitialization(modelToReload, restart) } private async _shutdownSidecar(): Promise { @@ -1172,4 +1162,13 @@ export function resetLocalSTTServiceForTests(): void { instance = null } +/** + * 앱 종료용: 이미 만들어진 인스턴스만 정리한다(사이드카 /shutdown → kill). + * 한 번도 쓰지 않았으면 종료 때문에 인스턴스를 새로 만들지 않는다. + */ +export async function disposeLocalSTTServiceIfStarted(): Promise { + if (!instance) return + await instance.dispose() +} + export { LocalSTTService, STTState } diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 581cc99..00ab0ea 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -5,9 +5,10 @@ import { EventEmitter } from 'events' import { BrowserWindow, Notification, dialog } from 'electron' import fs from 'fs' +import { dirname as pathDirname } from 'path' import { eq, desc, sql } from 'drizzle-orm' import { getLogger } from './LoggerService' -import { configGet, configSet } from './ConfigService' +import { configGet } from './ConfigService' import { getCloudSyncService } from './CloudSyncService' import { getMainWindow } from '../windows/WindowManager' import { getDatabase } from '../db' @@ -16,11 +17,19 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { buildExportMarkdown, - markdownToSimpleHtml, + renderMeetingPdfHtml, formatTime, formatDateFile, formatDateRange, } from '@d3ro/core/utils/meeting-markdown' +import { + applySpeakerLabels, + buildTranscriptFromSegments, + chunkTranscriptByLines, + formatTranscriptLines, + looksTruncatedRewrite, + parseTranscriptLines, +} from '@d3ro/core/meeting-transcript' import { markdownToDocx } from '@d3ro/core/utils/markdown-to-docx' import type { MeetingModeState, @@ -42,6 +51,31 @@ import type { const logger = getLogger('MeetingModeService') +/** 회의 전사를 LLM 으로 다시 쓸 때 한 번에 보낼 최대 글자 수 (출력 토큰 한도 안에 들도록) */ +const REWRITE_CHUNK_CHARS = 1500 +/** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */ +const REWRITE_CHUNK_MAX_TOKENS = 2048 +/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */ +const MAX_CHAT_HISTORY_MESSAGES = 20 + +type MeetingLlm = { + generate: ( + text: string, + options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }, + ) => Promise<{ text: string; model?: string }> + chatStream: ( + messages: Array<{ role: string; content: string }>, + options?: { temperature?: number }, + ) => AsyncGenerator +} + +/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */ +async function meetingAudioFilePath(sessionId: string): Promise { + const { app } = await import('electron') + const path = await import('path') + return path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`) +} + let isShowingMeetingSaveDialog = false class MeetingModeService extends EventEmitter { @@ -60,8 +94,8 @@ class MeetingModeService extends EventEmitter { /** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */ private _audioBuffersForFile: Buffer[] = [] - // Phase 15: Meeting AI Chat - private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [] + // Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게) + private _chatHistories = new Map>() private _chatAbortController: AbortController | null = null // ── 공개 접근자 ── @@ -101,11 +135,12 @@ class MeetingModeService extends EventEmitter { try { const { getCaptionService } = await import('./CaptionService') const captionService = getCaptionService() + // 강제 정지(소유자 무시) 후 구독을 푼다 + await captionService.stop().catch(() => null) if (this._segmentHandler) { captionService.off('segment', this._segmentHandler) this._segmentHandler = null } - await captionService.stop().catch(() => {}) } catch { /* noop */ } try { @@ -180,25 +215,16 @@ class MeetingModeService extends EventEmitter { } captionService.on('segment', this._segmentHandler) - // 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경 - // (CaptionService.start()가 ConfigService에서 다시 읽기 때문) - const prevAudioSource = configGet('captionAudioSource') - configSet('captionAudioSource', 'mic') - try { - await captionService.start() - // 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김 - const { hideCaptionOverlay } = await import('../windows/WindowManager') - hideCaptionOverlay() - // ConfigService 원래 값 복원 - if (prevAudioSource) { - configSet('captionAudioSource', prevAudioSource) - } + // 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음 + // (회의 UI 가 자체 패널에 표시), 'caption' 히스토리 중복 저장 없음, 핫키로 끌 수 없음. + await captionService.start({ + owner: 'meeting', + audioSource: 'mic', + showOverlay: false, + persistHistory: false, + }) } catch (err) { - // ConfigService 원래 값 복원 - if (prevAudioSource) { - configSet('captionAudioSource', prevAudioSource) - } // 시작 실패 시 복원 captionService.off('segment', this._segmentHandler) this._segmentHandler = null @@ -300,16 +326,26 @@ class MeetingModeService extends EventEmitter { this._audioDataHandler = null } - // CaptionService 종료 + // CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다) + // 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다. const { getCaptionService } = await import('./CaptionService') const captionService = getCaptionService() + const captionSummary = await captionService.stop('meeting') + if (this._segmentHandler) { captionService.off('segment', this._segmentHandler) this._segmentHandler = null } - await captionService.stop() + // 혹시 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다 + if (captionSummary) { + const known = new Set(this._segments.map((seg) => seg.id)) + for (const segment of captionSummary.segments) { + if (!known.has(segment.id)) this._segments.push(segment) + } + this._segments.sort((a, b) => a.timestamp - b.timestamp) + } // 세션 종료 시간 기록 const db = getDatabase() @@ -345,9 +381,9 @@ class MeetingModeService extends EventEmitter { // Step 1: 전사 텍스트 합산 this._sendProgress(sessionId, 'merging', 40) - const rawTranscript = this._segments - .map((s) => `[${formatTime(s.timestamp)}] ${s.text}`) - .join('\n') + // 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다 + const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0 + const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt) // Step 2: DB 저장 (rawTranscript + completed) this._sendProgress(sessionId, 'saving', 80) @@ -363,11 +399,8 @@ class MeetingModeService extends EventEmitter { // Step 2.5: 오디오 WAV 파일 저장 (화자 구분용) if (this._audioBuffersForFile.length > 0) { try { - const { app } = await import('electron') - const path = await import('path') - const audioDir = path.join(app.getPath('userData'), 'meeting-audio') - if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true }) - const wavPath = path.join(audioDir, `${sessionId}.wav`) + const wavPath = await meetingAudioFilePath(sessionId) + fs.mkdirSync(pathDirname(wavPath), { recursive: true }) this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile)) getCloudSyncService().pushOne('meeting_audio', sessionId) logger.info(`회의 오디오 저장: ${wavPath}`) @@ -485,9 +518,21 @@ class MeetingModeService extends EventEmitter { db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run() // 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다. getCloudSyncService().pushDelete('meetings', sessionId) + this._chatHistories.delete(sessionId) + // 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort) + void this._deleteMeetingAudio(sessionId) logger.info(`회의 세션 삭제: ${sessionId}`) } + private async _deleteMeetingAudio(sessionId: string): Promise { + try { + const audioPath = await meetingAudioFilePath(sessionId) + await fs.promises.rm(audioPath, { force: true }) + } catch (err) { + logger.warn(`회의 오디오 삭제 실패: ${err instanceof Error ? err.message : String(err)}`) + } + } + updateTitle(sessionId: string, title: string): void { const db = getDatabase() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() @@ -615,16 +660,7 @@ class MeetingModeService extends EventEmitter { * 회의 문서/폴리시/채팅/화자추정 LLM. * llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백. */ - private async _resolveMeetingLlm(): Promise<{ - generate: ( - text: string, - options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }, - ) => Promise<{ text: string; model?: string }> - chatStream: ( - messages: Array<{ role: string; content: string }>, - options?: { temperature?: number }, - ) => AsyncGenerator - }> { + private async _resolveMeetingLlm(): Promise { const backend = configGet('llmBackend') if (backend === 'online') { try { @@ -871,24 +907,7 @@ class MeetingModeService extends EventEmitter { break } case 'pdf': { - const html = ` - - - - - - -

${row.title}

- ${markdownToSimpleHtml(row.content)} - -` + const html = renderMeetingPdfHtml({ title: row.title, markdown: row.content }) const win = new BrowserWindow({ show: false, width: 800, height: 600 }) try { await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) @@ -1013,19 +1032,56 @@ class MeetingModeService extends EventEmitter { if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다') const llm = await this._resolveMeetingLlm() - const result = await llm.generate(transcript, { - systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', - temperature: 0.3, - }) + const polished = await this._rewriteTranscriptInChunks( + llm, + transcript, + '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', + ErrorCode.MeetingPolishFailed, + ) db.update(meetingSessions).set({ - editedTranscript: result.text, + editedTranscript: polished, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() getCloudSyncService().pushOne('meetings', sessionId) logger.info(`Auto Polish 완료: sessionId=${sessionId}`) - return result.text + return polished + } + + /** + * 긴 전사를 줄 경계 조각으로 나눠 LLM 으로 다시 쓰고 이어 붙인다. + * 한 번에 통째로 보내면 출력 토큰 한도(2048/4096)에서 잘린 결과가 editedTranscript 를 + * 덮어써 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라졌다. 조각 하나라도 잘린 것으로 + * 보이면 저장하지 않고 실패시킨다. + */ + private async _rewriteTranscriptInChunks( + llm: MeetingLlm, + transcript: string, + systemPrompt: string, + failureCode: ErrorCode, + ): Promise { + const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS) + if (chunks.length === 0) { + throw new D3ROError(failureCode, '전사 텍스트가 없습니다') + } + const outputs: string[] = [] + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + const result = await llm.generate(chunk, { + systemPrompt, + temperature: 0.3, + maxTokens: REWRITE_CHUNK_MAX_TOKENS, + }) + if (looksTruncatedRewrite(chunk, result.text)) { + throw new D3ROError( + failureCode, + `LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`, + ) + } + outputs.push(result.text.trim()) + } + return outputs.join('\n') } // ── Phase 15: Meeting AI Chat ── @@ -1044,42 +1100,55 @@ class MeetingModeService extends EventEmitter { ## 회의 전사 ${transcript}` - this._chatHistory.push({ role: 'user', content: userMessage }) - - const llm = await this._resolveMeetingLlm() - + // 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다 + // (실패·취소 시 짝 없는 사용자 턴이 남지 않게). + const history = this._chatHistories.get(sessionId) ?? [] const messages = [ { role: 'system' as const, content: systemPrompt }, - ...this._chatHistory, + ...history, + { role: 'user' as const, content: userMessage }, ] - let fullResponse = '' - this._chatAbortController = new AbortController() - const signal = this._chatAbortController.signal + // 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다 + this._chatAbortController?.abort() + const controller = new AbortController() + this._chatAbortController = controller + const signal = controller.signal + let fullResponse = '' try { + const llm = await this._resolveMeetingLlm() const generator = llm.chatStream(messages, { temperature: 0.5 }) for await (const token of generator) { if (signal.aborted) break fullResponse += token - this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token }) + this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { sessionId, token }) } - this._chatHistory.push({ role: 'assistant', content: fullResponse }) + if (!signal.aborted) { + const updated = [ + ...history, + { role: 'user' as const, content: userMessage }, + { role: 'assistant' as const, content: fullResponse }, + ] + this._chatHistories.set(sessionId, updated.slice(-MAX_CHAT_HISTORY_MESSAGES)) + } this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, { + sessionId, role: 'assistant', content: fullResponse, timestamp: Date.now(), }) } catch (err) { - if ((err as Error).name !== 'AbortError') { + if (!signal.aborted && (err as Error).name !== 'AbortError') { this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, { + sessionId, message: err instanceof Error ? err.message : String(err), }) } } finally { - this._chatAbortController = null + if (this._chatAbortController === controller) this._chatAbortController = null } } @@ -1090,8 +1159,18 @@ ${transcript}` } } - clearMeetingChatHistory(): void { - this._chatHistory = [] + /** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */ + clearMeetingChatHistory(sessionId?: string): void { + if (sessionId) { + this._chatHistories.delete(sessionId) + } else { + this._chatHistories.clear() + } + } + + /** 테스트/진단용: 특정 회의의 채팅 히스토리 사본 */ + getMeetingChatHistory(sessionId: string): Array<{ role: 'user' | 'assistant'; content: string }> { + return [...(this._chatHistories.get(sessionId) ?? [])] } // ── Phase 15.5: 화자 구분 ── @@ -1105,9 +1184,7 @@ ${transcript}` this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 }) // 오디오 파일 존재 확인 - const { app } = await import('electron') - const path = await import('path') - const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`) + const audioPath = await meetingAudioFilePath(sessionId) const hasAudioFile = fs.existsSync(audioPath) const hfToken = configGet('hfToken') as string @@ -1147,31 +1224,19 @@ ${transcript}` this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 }) - // 전사 세그먼트와 화자 세그먼트 매칭 + // 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다. + // 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다. const transcript = row.editedTranscript ?? row.rawTranscript ?? '' - const lines = transcript.split('\n').filter((l) => l.trim()) - const diarSegments = diarResult.segments - - const labeledLines = lines.map((line) => { - const timeMatch = /^\[(\d{2}):(\d{2})\]/.exec(line) - if (!timeMatch) return line - - const timeSec = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10) - // 해당 시간에 가장 가까운 화자 세그먼트 찾기 - const matchedSeg = diarSegments.find( - (s) => timeSec >= s.start && timeSec <= s.end, - ) - if (matchedSeg) { - const speakerLabel = matchedSeg.speaker.replace('SPEAKER_', '화자 ') - return line.replace(/^\[(\d{2}:\d{2})\]/, `[$1] [${speakerLabel}]`) - } - return line - }) + const labeledLines = applySpeakerLabels( + parseTranscriptLines(transcript), + diarResult.segments, + (raw) => raw.replace('SPEAKER_', '화자 '), + ) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 }) db.update(meetingSessions).set({ - editedTranscript: labeledLines.join('\n'), + editedTranscript: formatTranscriptLines(labeledLines), updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() getCloudSyncService().pushOne('meetings', sessionId) @@ -1205,22 +1270,23 @@ ${transcript}` ? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.` : '화자 수는 문맥에서 추정하세요.' - const result = await llm.generate(transcript, { - systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요. + const labeled = await this._rewriteTranscriptInChunks( + llm, + transcript, + `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요. ${speakerHint} 각 줄을 [시간] [화자] 내용 형식으로 변환하세요. 화자는 "화자 1", "화자 2" 등으로 표시하세요. 발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요. 원문의 타임스탬프와 내용은 변경하지 마세요.`, - temperature: 0.3, - maxTokens: 8192, - }) + ErrorCode.DiarizationFailed, + ) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 }) const db = getDatabase() db.update(meetingSessions).set({ - editedTranscript: result.text, + editedTranscript: labeled, updatedAt: Date.now(), }).where(eq(meetingSessions.id, sessionId)).run() getCloudSyncService().pushOne('meetings', sessionId) @@ -1228,33 +1294,12 @@ ${speakerHint} private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 - const minutesHtml = session.minutesMarkdown - ? markdownToSimpleHtml(session.minutesMarkdown) - : '

회의록이 생성되지 않았습니다.

' - - return ` - - - - - - -

${session.title ?? '무제 회의'}

-

일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분

- ${minutesHtml} - -` + return renderMeetingPdfHtml({ + title: session.title ?? '무제 회의', + meta: `일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분`, + markdown: session.minutesMarkdown, + emptyMessage: '회의록이 생성되지 않았습니다.', + }) } } diff --git a/apps/desktop/src/main/services/MeetingSummaryService.ts b/apps/desktop/src/main/services/MeetingSummaryService.ts index ee89506..3ada7bc 100644 --- a/apps/desktop/src/main/services/MeetingSummaryService.ts +++ b/apps/desktop/src/main/services/MeetingSummaryService.ts @@ -187,17 +187,24 @@ class MeetingSummaryService extends EventEmitter { /** * CaptionService 세션 종료 시 자동 요약 (옵션) */ - async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise { - // caption 모드 히스토리에서 해당 세션 찾기 + async onCaptionSessionSaved(sessionSummary: { sessionId: string; historyId?: string | null }): Promise { + // caption 모드 히스토리에서 해당 세션 찾기. + // 히스토리 행 id 는 HistoryService 가 따로 만든다 — 자막 세션 id 와 절대 같지 않으므로 + // CaptionService 가 실어 준 historyId 로 찾는다(없으면 저장되지 않은 세션). + const historyId = sessionSummary.historyId + if (!historyId) { + logger.info(`Caption session was not saved to history, skipping summary: ${sessionSummary.sessionId}`) + return + } const db = getDatabase() const rows = db .select() .from(history) - .where(eq(history.id, sessionSummary.sessionId)) + .where(eq(history.id, historyId)) .all() if (rows.length === 0) { - logger.warn(`Caption session not found in history: ${sessionSummary.sessionId}`) + logger.warn(`Caption session not found in history: ${historyId}`) return } diff --git a/apps/desktop/src/main/services/RAGService.ts b/apps/desktop/src/main/services/RAGService.ts index 1b7a6d8..b075f4d 100644 --- a/apps/desktop/src/main/services/RAGService.ts +++ b/apps/desktop/src/main/services/RAGService.ts @@ -7,7 +7,8 @@ import path from 'path' import fs from 'fs' import { asc, eq } from 'drizzle-orm' import { getLogger } from './LoggerService' -import { getPremiumLLMService } from './PremiumLLMService' +import { getLlmGateway } from './llm/LlmGateway' +import { extractDocxText } from './rag/docx-text' import { getOllamaServerUrl } from './LocalLLMService' import { getDatabase } from '../db' import { ragDocuments, ragChunks } from '../db/schema' @@ -291,8 +292,8 @@ class RAGService extends EventEmitter { Documents: ${context}` - const llmService = getPremiumLLMService() - const result = await llmService.generate(queryText, { systemPrompt }) + // 답변 LLM 은 사용자가 고른 백엔드를 따른다(로컬 선택 시 문서 조각을 클라우드로 보내지 않는다) + const result = await getLlmGateway().generate(queryText, { systemPrompt }) return { query: queryText, @@ -487,18 +488,11 @@ ${context}` if (fileType === 'docx') { try { const buffer = await fsp.readFile(filePath) - // DOCX는 ZIP 내 XML — w:t 태그에서 텍스트 추출 - const raw = buffer.toString('utf-8') - const matches = raw.match(/]*>([^<]*)<\/w:t>/g) - if (matches) { - const text = matches - .map((m) => m.replace(/<[^>]+>/g, '')) - .join(' ') - return text.trim() - } - // XML 태그 제거 폴백 - return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500000) - } catch { + // DOCX는 ZIP 안의 (deflate 압축된) word/document.xml — 압축을 풀어 문단 텍스트를 뽑는다. + // 텍스트를 찾지 못하면 바이너리 잡음을 색인하지 않고 실패시킨다. + return extractDocxText(buffer).slice(0, 500000) + } catch (err) { + logger.warn(`DOCX parsing failed: ${err instanceof Error ? err.message : String(err)}`) throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed') } } diff --git a/apps/desktop/src/main/services/ScreenContextService.ts b/apps/desktop/src/main/services/ScreenContextService.ts index 5b05cb6..dc12651 100644 --- a/apps/desktop/src/main/services/ScreenContextService.ts +++ b/apps/desktop/src/main/services/ScreenContextService.ts @@ -7,9 +7,17 @@ import { getLogger } from './LoggerService' import { configGet, configSet } from './ConfigService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types' +import { waitForModifiersReleased } from './modifier-state' const logger = getLogger('screen-context') +/** + * 선택 텍스트 캡처 전에 사용자가 수정자 키(Ctrl/Alt/Shift/Win)를 뗄 때까지 기다리는 최대 시간. + * 손을 떼지 않으면 캡처를 건너뛴다 — 수정자를 쥔 채 Ctrl+C 를 보내면 대상 앱엔 Ctrl+Alt+C 등이 + * 들어가 복사가 되지 않고(AltGr 배열에선 문자가 입력되어 선택 영역을 덮어쓴다) 클립보드만 비워진다. + */ +const MODIFIER_RELEASE_TIMEOUT_MS = 800 + // ============================================================ // nut-js 타입 (lazy import) // ============================================================ @@ -107,7 +115,7 @@ class ScreenContextService { if (captureSelectedText) { selectedTextAttempted = true try { - selectedText = await this._captureSelectedText() + selectedText = await this.captureSelectedText() } catch (error) { logger.warn( `Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}` @@ -130,6 +138,20 @@ class ScreenContextService { return { context, selectedTextAttempted } } + /** + * 선택된 텍스트만 캡처한다 (클립보드 방식). + * 수정자 키가 눌려 있으면 뗄 때까지 잠깐 기다리고, 그래도 쥐고 있으면 캡처하지 않고 null 을 + * 돌려준다(클립보드도 건드리지 않는다). + */ + async captureSelectedText(): Promise { + const released = await waitForModifiersReleased(MODIFIER_RELEASE_TIMEOUT_MS) + if (!released) { + logger.info('Selected text capture skipped: modifier keys still held') + return null + } + return this._captureSelectedText() + } + /** * ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다. * 빈 컨텍스트면 빈 문자열을 반환. @@ -371,7 +393,9 @@ end try } /** - * 클립보드 복원 (TextInsertService와 동일 패턴). + * 클립보드 복원 — 저장해 둔 모든 형식(text/html/rtf/image)을 한 번에 되돌린다. + * 예전엔 첫 번째로 있는 형식 하나만 썼기 때문에 서식 있는 복사본이나 텍스트+이미지가 + * 캡처 한 번에 평문으로 바뀌었다. */ private _restoreClipboard(snapshot: ClipboardSnapshot): void { if (!snapshot.hasContent) { @@ -379,15 +403,12 @@ end try return } - if (snapshot.text) { - clipboard.writeText(snapshot.text) - } else if (snapshot.html) { - clipboard.writeHTML(snapshot.html) - } else if (snapshot.rtf) { - clipboard.writeRTF(snapshot.rtf) - } else if (snapshot.image) { - clipboard.writeImage(snapshot.image) - } + const data: Electron.Data = {} + if (snapshot.text) data.text = snapshot.text + if (snapshot.html) data.html = snapshot.html + if (snapshot.rtf) data.rtf = snapshot.rtf + if (snapshot.image) data.image = snapshot.image + clipboard.write(data) } private _sleep(ms: number): Promise { diff --git a/apps/desktop/src/main/services/UpdateService.ts b/apps/desktop/src/main/services/UpdateService.ts index bc7a146..bcdfbdd 100644 --- a/apps/desktop/src/main/services/UpdateService.ts +++ b/apps/desktop/src/main/services/UpdateService.ts @@ -10,6 +10,10 @@ // 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자 // 5. staged rollout + 원격 킬 스위치 // 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) +// +// 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가 +// 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다. +// 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다. import { EventEmitter } from 'events' import { randomUUID } from 'node:crypto' @@ -25,13 +29,12 @@ import { } from '../update-feed' import { DEFAULT_UPDATE_POLICY, - decideUpdate, - isChannelAcceptable, - isWithinRollout, - majorOf, + evaluateUpdateOffer, parseUpdatePolicy, + shouldPromptForUpdate, type UpdateDecision, type UpdatePolicy, + type UpdatePromptRecord, } from '../update-policy' const logger = getLogger('UpdateService') @@ -76,124 +79,162 @@ export interface UpdateServiceEvents { 'update-error': { message: string } } +// ── 포트 ───────────────────────────────────────────────────── + +export type ReleaseNotes = string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined + +export interface UpdateInfoLike { + version: string + releaseNotes?: ReleaseNotes +} + +/** 업데이터(electron-updater) 이벤트를 서비스로 넘기는 콜백 */ +export interface UpdaterEventHandlers { + onChecking(): void + onAvailable(info: UpdateInfoLike): void + onNotAvailable(version: string): void + onProgress(progress: UpdateProgressPayload): void + onDownloaded(version: string): void + onError(message: string): void +} + +export interface UpdaterPort { + checkForUpdates(): Promise + downloadUpdate(): Promise + quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void + setChannel(channel: UpdateChannel, allowPrerelease: boolean): void + setDifferentialDisabled(disabled: boolean): void +} + +/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */ +export interface PolicySource { + load(): Promise +} + +export type ConsentAnswer = 'download' | 'later' | 'skip' +export type RestartAnswer = 'restart' | 'later' + +export interface UpdatePrompter { + askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise + askRestart(version: string): Promise +} + +export interface UpdateServiceDeps { + /** 지원 환경(패키지 빌드·플랫폼·feed)일 때 업데이터를 만든다. 아니면 null(자동 업데이트 비활성). */ + createUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null + policySource: PolicySource + prompter: UpdatePrompter + currentVersion(): string + now(): number +} + +// ── 서비스 ─────────────────────────────────────────────────── + class UpdateService extends EventEmitter { private _initialized = false private _initialTimer: NodeJS.Timeout | null = null private _intervalTimer: NodeJS.Timeout | null = null - private _promptShown = false - private _autoUpdater: import('electron-updater').AppUpdater | null = null + /** 마지막으로 사용자에게 물어본 제안 — 새 버전/필수 격상/간격 경과 시 다시 묻는다 */ + private _lastPrompt: UpdatePromptRecord | null = null + private _prompting = false + private _updater: UpdaterPort | null = null private _downloading = false private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY private _channel: UpdateChannel = 'latest' private _forceFullDownload = false + private readonly _deps: UpdateServiceDeps + + constructor(deps: Partial = {}) { + super() + this._deps = { + createUpdater: deps.createUpdater ?? createElectronUpdater, + policySource: deps.policySource ?? createRemotePolicySource(), + prompter: deps.prompter ?? createDialogPrompter(), + currentVersion: deps.currentVersion ?? (() => app.getVersion()), + now: deps.now ?? (() => Date.now()), + } + } /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ initialize(): void { if (this._initialized) return this._initialized = true - if (!app.isPackaged) { - logger.info('dev 실행 — 자동 업데이트 비활성') - return - } - if (process.platform !== 'win32' && process.platform !== 'darwin') { - logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`) - return - } - if (!UPDATE_FEED_URL) { - logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성') - return - } + this._updater = this._deps.createUpdater({ + onChecking: () => { + logger.info('업데이트 확인 중...') + this.emit('checking-for-update', undefined as unknown as void) + }, + onAvailable: (info) => { + logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`) + void this.handleUpdateAvailable(info) + }, + onNotAvailable: (version) => { + logger.info(`최신 버전 사용 중: v${version}`) + this.emit('update-not-available', { version }) + }, + onProgress: (progress) => this.emit('download-progress', progress), + onDownloaded: (version) => { + this._downloading = false + logger.info(`업데이트 다운로드 완료: v${version}`) + this.emit('update-downloaded', { version }) + void this._promptRestart(version) + }, + onError: (message) => { + // 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다 + if (this._downloading) this._lastPrompt = null + this._downloading = false + logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`) + this.emit('update-error', { message }) + }, + }) + if (!this._updater) return - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - this._autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater - } catch (err) { - logger.warn( - `electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`, - ) - return - } - - const updater = this._autoUpdater - updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL }) - // 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출) - updater.autoDownload = false - updater.autoInstallOnAppQuit = true this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY)) - updater.logger = { - info: (msg: unknown) => logger.info(String(msg)), - warn: (msg: unknown) => logger.warn(String(msg)), - error: (msg: unknown) => logger.error(String(msg)), - debug: (msg: unknown) => logger.debug(String(msg)), - } - - updater.on('checking-for-update', () => { - logger.info('업데이트 확인 중...') - this.emit('checking-for-update', undefined as unknown as void) - }) - - updater.on('update-available', (info) => { - logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`) - void this._handleUpdateAvailable(info) - }) - - updater.on('update-not-available', (info) => { - logger.info(`최신 버전 사용 중: v${info.version}`) - this.emit('update-not-available', { version: info.version }) - }) - - updater.on('download-progress', (progress) => { - this.emit('download-progress', { - percent: progress.percent, - bytesPerSecond: progress.bytesPerSecond, - transferred: progress.transferred, - total: progress.total - }) - }) - - updater.on('update-downloaded', (info) => { - this._downloading = false - logger.info(`업데이트 다운로드 완료: v${info.version}`) - this.emit('update-downloaded', { version: info.version }) - void this._promptRestart(info.version) - }) - - updater.on('error', (err) => { - this._downloading = false - logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`) - this.emit('update-error', { message: err.message }) - }) - - // 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다. - void this._loadPolicy() - this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) - this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS) + this._initialTimer = setTimeout(() => void this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) + this._intervalTimer = setInterval(() => void this.checkForUpdates(), CHECK_INTERVAL_MS) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) } - /** 수동 또는 주기적 업데이트 확인 */ - checkForUpdates(): void { - if (!this._autoUpdater || this._downloading) return + /** + * 수동 또는 주기적 업데이트 확인. + * 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는 + * 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다. + */ + async checkForUpdates(): Promise { + if (!this._updater || this._downloading) return + await this._refreshPolicy() if (this._policy.killSwitch) { logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단') return } - this._autoUpdater.checkForUpdates().catch((err: unknown) => { + try { + await this._updater.checkForUpdates() + } catch (err) { logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) - }) + } } /** 사용자 수락 시 업데이트 다운로드 시작 */ async startDownload(): Promise { - if (!this._autoUpdater || this._downloading) return + if (!this._updater || this._downloading) return + if (this._policy.killSwitch) { + logger.info('원격 킬 스위치 활성 — 다운로드 중단') + return + } this._downloading = true logger.info( this._forceFullDownload ? '전체 설치자 다운로드 시작 (major/버전갭)' : '차분 업데이트 다운로드 시작 (.blockmap)', ) - await this._autoUpdater.downloadUpdate() + try { + await this._updater.downloadUpdate() + } catch (err) { + if (this._downloading) this._lastPrompt = null + this._downloading = false + logger.warn(`업데이트 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`) + } } /** 이번 버전 건너뛰기 설정 */ @@ -224,8 +265,73 @@ class UpdateService extends EventEmitter { this._intervalTimer = null } + /** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */ + async handleUpdateAvailable(info: UpdateInfoLike): Promise { + const currentVersion = this._deps.currentVersion() + const offer = evaluateUpdateOffer({ + policy: this._policy, + channel: this._channel, + currentVersion, + targetVersion: info.version, + skippedVersion: this._skippedVersion(), + deviceId: this._deviceId(), + }) + + if (offer.action === 'ignore') { + switch (offer.reason) { + case 'kill-switch': + logger.info(`원격 킬 스위치 활성 — v${info.version} 무시`) + break + case 'channel': + logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`) + break + case 'skipped': + logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) + break + case 'rollout': + logger.info( + `staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`, + ) + break + } + return + } + + const { decision } = offer + // major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다. + this._forceFullDownload = decision.forceFull + this._updater?.setDifferentialDisabled(decision.forceFull) + + this.emit('update-available', { + version: info.version, + currentVersion, + channel: this._channel, + releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, + isMandatory: decision.mandatory, + isMajorUpgrade: offer.isMajorUpgrade, + isFullDownload: decision.forceFull, + reason: decision.reason, + }) + + if (this._downloading) return + + // forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선). + if (offer.action === 'force-download') { + logger.info(`v${info.version} 강제 설치 (${decision.reason})`) + void this.startDownload() + return + } + + await this._promptUserConsent(info.version, info.releaseNotes, decision) + } + // ── 내부 ── + private async _refreshPolicy(): Promise { + this._policy = await this._deps.policySource.load() + this._applyChannel(this._resolveChannel(this._policy)) + } + private _resolveChannel(policy: UpdatePolicy): UpdateChannel { const configured = configGet('updateChannel') return isUpdateChannel(configured) ? configured : policy.defaultChannel @@ -233,38 +339,9 @@ class UpdateService extends EventEmitter { private _applyChannel(channel: UpdateChannel): void { this._channel = channel - if (!this._autoUpdater) return + if (!this._updater) return const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false } - this._autoUpdater.channel = channel - this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease - } - - private async _loadPolicy(): Promise { - if (!UPDATE_POLICY_URL) return - try { - const response = await fetch(UPDATE_POLICY_URL, { - cache: 'no-store', - signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS), - }) - if (!response.ok) throw new Error(`HTTP ${response.status}`) - this._policy = parseUpdatePolicy(await response.json()) - this._applyChannel(this._resolveChannel(this._policy)) - logger.info( - `원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`, - ) - } catch (err) { - // 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속). - logger.debug( - `원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`, - ) - } - } - - /** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */ - private _setDifferentialDisabled(disabled: boolean): void { - if (!this._autoUpdater) return - ;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = - disabled + this._updater.setChannel(channel, channelPolicy.allowPrerelease) } private _deviceId(): string { @@ -282,142 +359,44 @@ class UpdateService extends EventEmitter { return typeof legacy === 'string' && legacy ? legacy : null } - private async _handleUpdateAvailable( - info: import('electron-updater').UpdateInfo, - ): Promise { - const currentVersion = app.getVersion() - const decision = decideUpdate(this._policy, currentVersion, info.version) - const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false } - - if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) { - logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`) - return - } - - if (!decision.mandatory && this._skippedVersion() === info.version) { - logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`) - return - } - - if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) { - logger.info( - `staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`, - ) - return - } - - // major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다. - this._forceFullDownload = decision.forceFull - this._setDifferentialDisabled(decision.forceFull) - - const currentMajor = majorOf(currentVersion) - const targetMajor = majorOf(info.version) - const isMajorUpgrade = - currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor - - this.emit('update-available', { - version: info.version, - currentVersion, - channel: this._channel, - releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined, - isMandatory: decision.mandatory, - isMajorUpgrade, - isFullDownload: decision.forceFull, - reason: decision.reason, - }) - - await this._promptUserConsent( - info.version, - info.releaseNotes, - decision, - ) - } - /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ private async _promptUserConsent( version: string, - releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined, + releaseNotes: ReleaseNotes, decision: UpdateDecision, ): Promise { - if (this._downloading) return - // forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선). - if (decision.mandatory && decision.reason === 'below-force-install') { - logger.info(`v${version} 강제 설치 (${decision.reason})`) - void this.startDownload() + if (this._prompting) return + const now = this._deps.now() + if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) { + logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`) return } - if (this._promptShown) return - this._promptShown = true - - const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true - const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' - const mandatoryNote = decision.mandatory - ? isKo - ? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).' - : '\n\nThis update is required (end of support).' - : '' - - const options = decision.mandatory - ? { - type: 'info' as const, - title: isKo ? '필수 업데이트' : 'Required Update', - message: isKo - ? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}` - : `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`, - buttons: isKo ? ['지금 업데이트'] : ['Update Now'], - defaultId: 0, - cancelId: -1, - } - : { - type: 'info' as const, - title: isKo ? '새 버전 업데이트' : 'Software Update', - message: isKo - ? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}` - : `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`, - buttons: isKo - ? ['지금 다운로드', '나중에', '이 버전 건너뛰기'] - : ['Download Now', 'Later', 'Skip This Version'], - defaultId: 0, - cancelId: 1, - } - - const win = getMainWindow() - const { response } = - win && !win.isDestroyed() - ? await dialog.showMessageBox(win, options) - : await dialog.showMessageBox(options) - - if (response === 0) { - void this.startDownload() - } else if (!decision.mandatory && response === 2) { - this.skipVersion(version) + this._lastPrompt = { version, mandatory: decision.mandatory, at: now } + this._prompting = true + try { + const answer = await this._deps.prompter.askConsent({ + version, + releaseNotes, + mandatory: decision.mandatory, + }) + if (answer === 'download') { + void this.startDownload() + } else if (!decision.mandatory && answer === 'skip') { + this.skipVersion(version) + } + } finally { + this._prompting = false } } /** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */ private async _promptRestart(version: string): Promise { - const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true - const options = { - type: 'info' as const, - title: isKo ? '업데이트 준비 완료' : 'Update Ready', - message: isKo - ? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?` - : `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`, - buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'], - defaultId: 0, - cancelId: 1, - } - - const win = getMainWindow() - const { response } = - win && !win.isDestroyed() - ? await dialog.showMessageBox(win, options) - : await dialog.showMessageBox(options) - - if (response === 0 && this._autoUpdater) { + const answer = await this._deps.prompter.askRestart(version) + if (answer === 'restart' && this._updater) { logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행') - // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행 - this._autoUpdater.quitAndInstall(false, true) + // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행. + // quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다. + this._updater.quitAndInstall(false, true) } } @@ -438,6 +417,177 @@ class UpdateService extends EventEmitter { } } +// ── 기본 어댑터 (electron-updater / fetch / electron dialog) ───── + +function createElectronUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null { + if (!app.isPackaged) { + logger.info('dev 실행 — 자동 업데이트 비활성') + return null + } + if (process.platform !== 'win32' && process.platform !== 'darwin') { + logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`) + return null + } + if (!UPDATE_FEED_URL) { + logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성') + return null + } + + let updater: import('electron-updater').AppUpdater + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + updater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater + } catch (err) { + logger.warn( + `electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`, + ) + return null + } + + updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL }) + // 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출) + updater.autoDownload = false + updater.autoInstallOnAppQuit = true + updater.logger = { + info: (msg: unknown) => logger.info(String(msg)), + warn: (msg: unknown) => logger.warn(String(msg)), + error: (msg: unknown) => logger.error(String(msg)), + debug: (msg: unknown) => logger.debug(String(msg)), + } + + updater.on('checking-for-update', () => handlers.onChecking()) + updater.on('update-available', (info) => handlers.onAvailable(info)) + updater.on('update-not-available', (info) => handlers.onNotAvailable(info.version)) + updater.on('download-progress', (progress) => + handlers.onProgress({ + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, + }), + ) + updater.on('update-downloaded', (info) => handlers.onDownloaded(info.version)) + updater.on('error', (err) => handlers.onError(err.message)) + + return { + checkForUpdates: () => updater.checkForUpdates(), + downloadUpdate: () => updater.downloadUpdate(), + quitAndInstall: (isSilent, isForceRunAfter) => updater.quitAndInstall(isSilent, isForceRunAfter), + setChannel: (channel, allowPrerelease) => { + updater.channel = channel + updater.allowPrerelease = allowPrerelease + }, + setDifferentialDisabled: (disabled) => { + // NSIS 이외 업데이터는 이 속성을 무시한다 + ;(updater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = disabled + }, + } +} + +/** 원격 정책 공급원 — 실패하면 마지막으로 성공한 정책, 그것도 없으면 내장 기본값 */ +export function createRemotePolicySource( + url: string | null | undefined = UPDATE_POLICY_URL, + fetchImpl: typeof fetch = (input, init) => fetch(input, init), +): PolicySource { + let lastGood: UpdatePolicy | null = null + return { + async load(): Promise { + if (!url) return lastGood ?? DEFAULT_UPDATE_POLICY + try { + const response = await fetchImpl(url, { + cache: 'no-store', + signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS), + }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) + lastGood = parseUpdatePolicy(await response.json()) + logger.info( + `원격 업데이트 정책 적용 — min=${lastGood.minimumSupportedVersion}, killSwitch=${lastGood.killSwitch}, staging=${lastGood.stagingPercentage}%`, + ) + return lastGood + } catch (err) { + // 네트워크 실패 시 마지막 정책(없으면 내장 기본값)으로 fail-open (업데이트 자체는 계속). + logger.debug( + `원격 업데이트 정책 로드 실패 — ${lastGood ? '마지막 정책 유지' : '내장 기본값 사용'}: ${err instanceof Error ? err.message : String(err)}`, + ) + return lastGood ?? DEFAULT_UPDATE_POLICY + } + }, + } +} + +/** + * electron dialog 기반 프롬프터. + * TODO(i18n): 문구가 아직 ko/en 인라인이다 — update.* 키를 locale 파일에 추가한 뒤 t() 로 옮긴다. + */ +function createDialogPrompter(): UpdatePrompter { + const isKorean = (): boolean => (configGet('language') as string | undefined)?.startsWith('ko') ?? true + const show = async (options: Electron.MessageBoxOptions): Promise => { + const win = getMainWindow() + const { response } = + win && !win.isDestroyed() + ? await dialog.showMessageBox(win, options) + : await dialog.showMessageBox(options) + return response + } + + return { + async askConsent({ version, releaseNotes, mandatory }): Promise { + const isKo = isKorean() + const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' + const mandatoryNote = mandatory + ? isKo + ? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).' + : '\n\nThis update is required (end of support).' + : '' + + if (mandatory) { + const response = await show({ + type: 'info', + title: isKo ? '필수 업데이트' : 'Required Update', + message: isKo + ? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}` + : `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`, + buttons: isKo ? ['지금 업데이트'] : ['Update Now'], + defaultId: 0, + cancelId: -1, + }) + return response === 0 ? 'download' : 'later' + } + + const response = await show({ + type: 'info', + title: isKo ? '새 버전 업데이트' : 'Software Update', + message: isKo + ? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}` + : `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`, + buttons: isKo + ? ['지금 다운로드', '나중에', '이 버전 건너뛰기'] + : ['Download Now', 'Later', 'Skip This Version'], + defaultId: 0, + cancelId: 1, + }) + if (response === 0) return 'download' + if (response === 2) return 'skip' + return 'later' + }, + + async askRestart(version): Promise { + const isKo = isKorean() + const response = await show({ + type: 'info', + title: isKo ? '업데이트 준비 완료' : 'Update Ready', + message: isKo + ? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?` + : `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`, + buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'], + defaultId: 0, + cancelId: 1, + }) + return response === 0 ? 'restart' : 'later' + }, + } +} + // ── 싱글톤 ── let instance: UpdateService | null = null diff --git a/apps/desktop/src/main/services/VoiceConversationService.ts b/apps/desktop/src/main/services/VoiceConversationService.ts index 45b8751..c7f6629 100644 --- a/apps/desktop/src/main/services/VoiceConversationService.ts +++ b/apps/desktop/src/main/services/VoiceConversationService.ts @@ -5,12 +5,12 @@ import { EventEmitter } from 'events' import { getLogger } from './LoggerService' import { getPremiumLLMService } from './PremiumLLMService' -import { getLocalLLMService } from './LocalLLMService' import { getLocalSTTService } from './LocalSTTService' import { getAudioCaptureService } from './AudioCaptureService' import { getTTSPlaybackService } from './TTSPlaybackService' import { getSoundEffectService } from './SoundEffectService' import { configGet } from './ConfigService' +import { getLlmGateway } from './llm/LlmGateway' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' @@ -386,45 +386,22 @@ class VoiceConversationService extends EventEmitter { } /** - * LLM 백엔드 선택 + chatStream 생성. - * premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback. + * LLM 백엔드 선택 + chatStream 생성 — 선택 정책(online+가용 → Premium, 아니면 Local)은 + * LlmGateway 가 소유한다. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다. */ private async _createChatStream(signal: AbortSignal): Promise<{ generator: AsyncGenerator backend: 'local' | 'premium' }> { const chatMessages = this._buildChatMessages() - const backend = configGet('llmBackend') - - if (backend === 'online') { - try { - const { getPremiumLLMService } = await import('./PremiumLLMService') - const premium = getPremiumLLMService() - if (premium.isAvailable()) { - return { generator: premium.chatStream(chatMessages), backend: 'premium' } - } - logger.warn('Premium LLM not available for conversation, falling back to local') - } catch (err) { - logger.warn('Premium LLM init failed for conversation:', err instanceof Error ? err.message : String(err)) - } - } - - const local = getLocalLLMService() - if (!local.isAvailable()) { - throw new D3ROError( - ErrorCode.LLMServerUnreachable, - 'Local LLM (Ollama) is not available', - ) - } - return { - generator: local.chatStream(chatMessages, { - signal, + return getLlmGateway().openChatStream(chatMessages, { + signal, + local: { maxTokens: 512, timeoutMs: 60_000, keepAlive: '2m', - }), - backend: 'local', - } + }, + }) } private _isCurrentResponse(responseAbortController: AbortController): boolean { diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index 99f6fac..be10be6 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -2,6 +2,17 @@ // 전체 음성 파이프라인 오케스트레이터. // 설계서 01의 IVoiceModeService 구현. Speakly VoiceModeService의 // 상태 머신, 이중 조건 플러시, Action Queue 패턴 적용. +// +// 세션 런(SessionRun): +// 세션마다 run 객체를 하나 만든다. 모든 비동기 단계(STT 초기화, 오디오 시작, 전사, LLM)는 +// 자기 run 을 붙잡고 있다가 await 뒤에 `_isCurrent(run)` 으로 아직 자기 세션인지 확인한다. +// 예전엔 `_isInTerminalState()` 로만 확인했는데, 취소/에러/완료가 곧바로 IDLE(비터미널)로 +// 돌아가므로 취소된 세션의 결과가 다음 세션에 삽입되고 마이크가 새는 버그가 있었다. +// 오디오 캡처 참조(refCount)는 run 이 실제로 잡았을 때만, 정확히 한 번 놓는다. +// +// 표시(RecordingTip/결과 팝업)는 이 서비스가 직접 하지 않는다 — 'phase' / 'audio-level' / +// 'partial-transcript' / 'error' / 'insert-failed' 이벤트를 내보내고 +// windows/recording-tip-presenter 가 구독해 그린다(오케스트레이션 → 윈도우 의존 제거). import { EventEmitter } from 'events' import { randomUUID } from 'crypto' @@ -10,27 +21,19 @@ import { join } from 'path' import { app } from 'electron' import { getLogger } from './LoggerService' import { getAudioCaptureService } from './AudioCaptureService' -import { getLocalSTTService } from './LocalSTTService' -import type { TranscriptionResult } from './LocalSTTService' import { getSTTManager } from './stt/STTManager' +import type { TranscriptionResult } from './stt/types' import { getKeyBindingService } from './KeyBindingService' import type { KeyBindingTriggerPayload } from './KeyBindingService' import { configGet } from './ConfigService' import { getTextInsertService } from './TextInsertService' import { getLocalLLMService } from './LocalLLMService' +import { getLlmGateway } from './llm/LlmGateway' import { buildInstructionInvocation, resolveTargetLanguage } from './llm-prompts' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { TIMING } from '@d3ro/core/constants' import { RecognitionState, AudioState } from '@d3ro/core/types' import type { KeyBindingActionId, VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types' -import { - showRecordingTip, - hideRecordingTip, - updateRecordingTipState, - sendAudioLevelToTip, - sendPartialTranscriptToTip, - showResultPopup, -} from '../windows/WindowManager' import type { ScreenContext } from '@d3ro/core/types' const logger = getLogger('VoiceModeService') @@ -61,7 +64,10 @@ interface VoiceAction { holdMode: boolean } -interface VoiceModeEvents { +/** RecordingTip 표시 단계 — 녹음 시작(모델 확인 통과 후) / 녹음 종료 후 처리 중 */ +export type VoiceSessionPhase = 'recording' | 'thinking' + +export interface VoiceModeEvents { 'session-started': (payload: { session: VoiceSession }) => void 'recognition-state-changed': (payload: { previous: RecognitionState @@ -78,6 +84,12 @@ interface VoiceModeEvents { reason: 'user' | 'timeout' | 'too-short' }) => void 'audio-level': (payload: { level: number }) => void + /** 표시 단계 전환 — 'recording' 은 모델 확인을 통과한 뒤에만 나간다 */ + phase: (payload: { phase: VoiceSessionPhase; sessionId: string }) => void + /** 녹음 중 실시간 미리보기 텍스트 (최종 삽입과 무관) */ + 'partial-transcript': (payload: { text: string; sessionId: string }) => void + /** 자동 삽입 실패 — 표시 계층이 결과 팝업으로 텍스트를 보여 준다 */ + 'insert-failed': (payload: { text: string; sessionId: string }) => void error: (payload: { error: D3ROError session: VoiceSession | null @@ -91,6 +103,45 @@ interface VoiceModeEvents { 'premium-llm-fallback': (payload: { reason: string }) => void } +/** + * 한 세션의 런타임 상태. 세션이 끝나면(완료/취소/에러) abort 되어 늦게 도착한 비동기 + * 결과가 다음 세션에 손대지 못하게 한다. + */ +class SessionRun { + readonly controller = new AbortController() + /** audio.start() 가 성공해 이 run 이 캡처 참조를 잡고 있다 */ + audioAcquired = false + /** 오디오 해제가 요청됐다 — start 가 늦게 끝나도 곧바로 놓는다 */ + audioReleaseRequested = false + /** 녹음이 스트리밍 중이다 (부분 전사·플러시 판단용) */ + audioStarted = false + /** 사용자가 이미 정지를 요청했다 — 두 번째 release/토글은 무시한다 */ + stopRequested = false + sttReady = false + transcribing = false + audioBuffer: Buffer[] = [] + audioBufferBytes = 0 + /** WAV 저장용 — 다음 세션이 덮어쓰지 못하게 run 에 둔다 */ + lastAudio: Buffer | null = null + /** 스크린 컨텍스트: 선택 텍스트 캡처 여부와 진행 중 작업 */ + captureSelection = false + selectionCapture: Promise | null = null + + dataHandler: ((payload: { buffer: Buffer }) => void) | null = null + levelHandler: ((payload: { level: number }) => void) | null = null + stoppedHandler: ((payload: { reason: 'manual' | 'device-lost' | 'error' }) => void) | null = null + + sttWaitTimer: NodeJS.Timeout | null = null + partialTimer: NodeJS.Timeout | null = null + partialInFlight: Promise | null = null + + constructor(readonly id: string) {} + + get aborted(): boolean { + return this.controller.signal.aborted + } +} + // ============================================================ // 터미널 상태 헬퍼 // ============================================================ @@ -114,42 +165,24 @@ const PARTIAL_MAX_WINDOW_MS = 7500 /** 녹음 종료 시 진행 중 부분 전사를 기다리는 최대 시간 */ const PARTIAL_DRAIN_TIMEOUT_MS = 2500 +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + // ============================================================ // VoiceModeService // ============================================================ class VoiceModeService extends EventEmitter { private _session: VoiceSession | null = null + private _run: SessionRun | null = null private _recognitionState = RecognitionState.IDLE private _audioState = AudioState.IDLE - // 이중 조건 플러시 - private _sttReady = false - private _audioStarted = false - private _audioBuffer: Buffer[] = [] - private _audioBufferBytes = 0 - - // 녹음 오디오 저장용 - private _lastAudioBuffer: Buffer | null = null - - // 에러 가드 - private _errorEmitted = false - // 에러 popup 3초 hide 예약 타이머 (다음 세션 시작 시 취소해야 현재 recording tip이 살아남음) - private _errorHideTimer: NodeJS.Timeout | null = null - /** 녹음 종료 후 STT 준비 대기 타이머 — 전사 시작 시 반드시 해제 */ - private _sttWaitTimer: NodeJS.Timeout | null = null - - // 실시간 부분 전사(미리보기) - private _partialTimer: NodeJS.Timeout | null = null - private _partialInFlight: Promise | null = null - // Action Queue (이벤트 직렬화) private _actionQueue: VoiceAction[] = [] private _isProcessingQueue = false - // 리스너 해제용 참조 - private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null - private _audioLevelHandler: ((payload: { level: number }) => void) | null = null private _keyBindingHandler: ((payload: KeyBindingTriggerPayload) => void) | null = null private _disposed = false @@ -263,14 +296,18 @@ class VoiceModeService extends EventEmitter { // CaptionService 미초기화 시 무시 } - // Phase 10.2: 스크린 컨텍스트 캡처 (녹음 시작 전, 활성 앱 정보 보존) + // Phase 10.2: 스크린 컨텍스트 — 녹음 시작 시점엔 활성 앱 정보만 캡처한다. + // 선택 텍스트(Ctrl+C)는 여기서 보내지 않는다: 이 시점엔 사용자가 아직 핫키(기본 Right Alt)를 + // 누르고 있어 대상 앱에 Ctrl+Alt+C 가 들어가고(복사 안 됨, AltGr 배열에선 문자 입력), + // 클립보드만 비워진다. 선택 텍스트는 녹음 종료(키를 뗀 뒤)에 캡처한다. let screenContext: ScreenContext | null = null + let captureSelection = false try { const { getScreenContextService } = await import('./ScreenContextService') const ctx = getScreenContextService() if (ctx.isEnabled()) { - const captureSelected = configGet('screenContextEnabled' as keyof import('@d3ro/core/types').AppConfig) as unknown as boolean - const result = await ctx.captureContext(captureSelected) + captureSelection = true + const result = await ctx.captureContext(false) screenContext = result.context logger.info(`Screen context captured: ${screenContext.appName ?? 'unknown'}`) } @@ -278,9 +315,15 @@ class VoiceModeService extends EventEmitter { // 컨텍스트 캡처 실패 시 무시 — 핵심 기능 아님 } + // 위 await 사이에 다른 경로(IPC 등)가 세션을 시작했으면 양보한다 + if (this._disposed || (this._session && !this._isInTerminalState())) return + // 세션 생성 + const run = new SessionRun(randomUUID()) + run.captureSelection = captureSelection + this._run = run this._session = { - id: randomUUID(), + id: run.id, mode, startedAt: Date.now(), recognitionState: RecognitionState.PREPARING, @@ -292,51 +335,44 @@ class VoiceModeService extends EventEmitter { screenContext, } - this._errorEmitted = false - this._sttReady = false - this._audioStarted = false - this._audioBuffer = [] - this._audioBufferBytes = 0 - - // 이전 에러의 3초 hide 타이머가 살아있으면 취소 (그대로 두면 새 recording tip을 - // ~2초 지점에서 숨겨버리는 버그 발생). _errorEmitted 리셋과 동일 타이밍. - if (this._errorHideTimer) { - clearTimeout(this._errorHideTimer) - this._errorHideTimer = null - } - this._setRecognitionState(RecognitionState.PREPARING) this.emit('session-started', { session: this._session }) - logger.info(`Session started: ${this._session.id} (mode: ${mode})`) + logger.info(`Session started: ${run.id} (mode: ${mode})`) - const provider = configGet('sttProvider') ?? 'local' - if (provider === 'local') { - const stt = getLocalSTTService() - const sttModelId = configGet('sttModelId') - const sttModel = stt.getModels().find((m) => m.id === sttModelId) - if (sttModel && !sttModel.downloaded) { - this._handleError( - new D3ROError(ErrorCode.STTModelNotFound, `STT model not installed: ${sttModelId}`), - ) - return - } + const stt = getSTTManager() + const missingModel = stt.getMissingModel() + if (missingModel) { + this._handleError( + run, + new D3ROError(ErrorCode.STTModelNotFound, `STT model not installed: ${missingModel}`), + ) + return } - // Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시 - showRecordingTip('recording') + // Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시 (표시 계층이 'phase' 를 구독) + this.emit('phase', { phase: 'recording', sessionId: run.id }) // 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작. // 주의: STT 초기화는 await하지 않는다 — sidecar 기동이 느리거나 실패할 때 // press 액션이 action queue를 점유해 release가 수십 초 지연되던 버그의 원인. // (_initSTT는 내부에서 에러를 _handleError로 처리하므로 fire-and-forget 안전) - void this._initSTT() - await this._startAudio() + void this._initSTT(run) + await this._startAudio(run) } async stopSession(): Promise { - if (!this._session || this._isInTerminalState()) return - + const run = this._run const session = this._session + if (!run || !session || !this._isCurrent(run) || this._isInTerminalState()) return + + // 정지는 세션당 한 번이다. 지연 전사가 도는 중에 두 번째 release/토글이 오면 + // 예전엔 비어 있는 버퍼를 보고 'too-short' 로 조용히 취소해 받아쓰기를 잃었다. + if (run.stopRequested) { + logger.info('Stop already requested for this session — ignoring') + return + } + run.stopRequested = true + const duration = Date.now() - session.startedAt // accidentalPress 체크 (700ms 미만) @@ -348,22 +384,28 @@ class VoiceModeService extends EventEmitter { } // 오디오 캡처 중지 - await this._stopAudio() + this._setAudioState(AudioState.STOPPED) + await this._releaseAudio(run) + if (!this._isCurrent(run)) return // Speakly 패턴: 녹음 종료 후 thinking 상태로 전환 - updateRecordingTipState('thinking') + this.emit('phase', { phase: 'thinking', sessionId: run.id }) + + // 핫키를 뗀 뒤라 Ctrl+C 가 안전하다 — 전사와 병렬로 선택 텍스트를 캡처한다 + this._startSelectionCapture(run) // 버퍼가 있으면 STT에 전달 - if (this._audioBuffer.length > 0 && this._sttReady) { - await this._transcribe() - } else if (this._audioBuffer.length > 0 && !this._sttReady) { + if (run.audioBuffer.length > 0 && run.sttReady) { + await this._transcribe(run) + } else if (run.audioBuffer.length > 0 && !run.sttReady) { // STT 아직 준비 안 됨 → _initSTT 완료 후 _tryFlushAll이 전사 logger.info('Waiting for STT to be ready before transcribing') - this._clearSttWaitTimer() - this._sttWaitTimer = setTimeout(() => { + this._clearSttWaitTimer(run) + run.sttWaitTimer = setTimeout(() => { + run.sttWaitTimer = null // 전사가 이미 시작됐으면(RECOGNIZING) 취소하지 않는다. // 예전엔 6초 타이머가 첫 모델 로딩 직후 전사를 잘라 '전사가 안 됨'으로 보였다. - if (this._session?.id === session.id && !this._sttReady && !this._isInTerminalState()) { + if (this._isCurrent(run) && !run.sttReady && !run.transcribing) { logger.warn('STT readiness timeout, cancelling session') this._cancelSession('timeout') } @@ -385,6 +427,11 @@ class VoiceModeService extends EventEmitter { return TERMINAL_STATES.has(this._recognitionState) } + /** run 이 아직 현재 세션인가 — 모든 await 뒤에 이것으로 확인한다 */ + private _isCurrent(run: SessionRun): boolean { + return this._run === run && !run.aborted + } + private _setRecognitionState(state: RecognitionState): void { if (this._recognitionState === state) return if (this._isInTerminalState() && state !== RecognitionState.IDLE) return @@ -414,30 +461,27 @@ class VoiceModeService extends EventEmitter { // ── STT 초기화 ───────────────────────────────────────── - private async _initSTT(): Promise { + private async _initSTT(run: SessionRun): Promise { try { this._setRecognitionState(RecognitionState.CONNECTING) - const provider = configGet('sttProvider') ?? 'local' - if (provider === 'local') { - const stt = getLocalSTTService() - const modelId = configGet('sttModelId') - await stt.initialize(modelId) - } + const stt = getSTTManager() + await stt.ensureReady() - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return - this._sttReady = true - this._clearSttWaitTimer() - this._setRecognitionState(RecognitionState.READY) - logger.info(`STT ready (provider: ${provider})`) + run.sttReady = true + this._clearSttWaitTimer(run) + if (!run.transcribing) this._setRecognitionState(RecognitionState.READY) + logger.info(`STT ready (provider: ${stt.getActiveProvider()})`) - this._tryFlushAll() + this._tryFlushAll(run) } catch (error) { - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return this._handleError( + run, new D3ROError( ErrorCode.STTModelLoadFailed, - `STT initialization failed: ${error instanceof Error ? error.message : String(error)}` + `STT initialization failed: ${describe(error)}` ) ) } @@ -445,120 +489,203 @@ class VoiceModeService extends EventEmitter { // ── 오디오 캡처 ──────────────────────────────────────── - private async _startAudio(): Promise { + private async _startAudio(run: SessionRun): Promise { + const audio = getAudioCaptureService() try { this._setAudioState(AudioState.INITIALIZING) - const audio = getAudioCaptureService() - // 오디오 데이터 수신 - this._audioDataHandler = (payload) => { - if (this._isInTerminalState()) return - this._audioBuffer.push(payload.buffer) - this._audioBufferBytes += payload.buffer.length + // 오디오 데이터 수신 — 이 run 이 현재 세션일 때만 적재한다 + run.dataHandler = (payload) => { + if (!this._isCurrent(run)) return + run.audioBuffer.push(payload.buffer) + run.audioBufferBytes += payload.buffer.length // 버퍼 duration 업데이트 (16kHz, 16bit, mono) - const durationMs = (this._audioBufferBytes / 2 / 16000) * 1000 if (this._session) { - this._session.audioBufferDurationMs = durationMs + this._session.audioBufferDurationMs = (run.audioBufferBytes / 2 / 16000) * 1000 } - this._tryFlushAll() + this._tryFlushAll(run) } - this._audioLevelHandler = (payload) => { + run.levelHandler = (payload) => { + if (!this._isCurrent(run)) return + // Speakly 패턴: 오디오 레벨을 RecordingTip 웨이브 바에 전달 (표시 계층이 구독) this.emit('audio-level', { level: payload.level }) - // Speakly 패턴: 오디오 레벨을 RecordingTip 웨이브 바에 전달 - sendAudioLevelToTip(payload.level) } - audio.on('audio-data', this._audioDataHandler) - audio.on('audio-level', this._audioLevelHandler) + // 캡처가 녹음 도중 죽으면(SoX 종료, 장치 분리) 예전엔 아무도 몰라 녹음 팁이 떠 있는 채 + // 'too-short' 로 조용히 취소됐다. 이제 세션이 알아채고 사용자에게 알린다. + run.stoppedHandler = ({ reason }) => { + if (reason === 'manual') return + if (!this._isCurrent(run)) return + this._onCaptureLost(run, reason) + } + + audio.on('audio-data', run.dataHandler) + audio.on('audio-level', run.levelHandler) + audio.on('stopped', run.stoppedHandler) await audio.start() + run.audioAcquired = true - if (this._isInTerminalState()) return + // start 가 끝나기 전에 세션이 끝났거나 정지가 요청됐으면 방금 잡은 참조를 곧바로 놓는다 + // (그냥 return 하면 refCount 가 새어 마이크가 계속 켜진다). + if (!this._isCurrent(run) || run.audioReleaseRequested) { + await this._releaseAudio(run) + return + } - this._audioStarted = true + run.audioStarted = true this._setAudioState(AudioState.STREAMING) logger.info('Audio capture started') - this._startPartialLoop() + this._startPartialLoop(run) - this._tryFlushAll() + this._tryFlushAll(run) } catch (error) { - if (this._isInTerminalState()) return + this._detachAudioHandlers(run) + if (!this._isCurrent(run)) return this._setAudioState(AudioState.STOPPED) this._handleError( + run, new D3ROError( ErrorCode.AudioCaptureStartFailed, - `Audio capture failed: ${error instanceof Error ? error.message : String(error)}` + `Audio capture failed: ${describe(error)}` ) ) } } - private async _stopAudio(): Promise { + /** 캡처가 예기치 않게 끝났을 때 — 들어온 오디오가 있으면 그것으로 마무리, 없으면 에러 */ + private _onCaptureLost(run: SessionRun, reason: 'device-lost' | 'error'): void { + logger.warn(`Audio capture lost during session (${reason})`) + // 캡처 쪽이 이미 정리했다 — 우리 참조로 stop() 을 부르면 다른 소비자의 참조를 깎는다 + run.audioAcquired = false + const session = this._session + const duration = session ? Date.now() - session.startedAt : 0 + if (!run.stopRequested && run.audioBufferBytes > 0 && duration >= TIMING.MIN_AUDIO_DURATION) { + void this.stopSession() + return + } + if (run.stopRequested) return this._setAudioState(AudioState.STOPPED) + this._handleError( + run, + new D3ROError( + ErrorCode.AudioCaptureFailed, + `Microphone capture stopped unexpectedly (${reason}). Check the selected microphone.`, + ), + ) + } + private _detachAudioHandlers(run: SessionRun): void { const audio = getAudioCaptureService() - if (this._audioDataHandler) { - audio.off('audio-data', this._audioDataHandler) - this._audioDataHandler = null + if (run.dataHandler) { + audio.off('audio-data', run.dataHandler) + run.dataHandler = null } - if (this._audioLevelHandler) { - audio.off('audio-level', this._audioLevelHandler) - this._audioLevelHandler = null + if (run.levelHandler) { + audio.off('audio-level', run.levelHandler) + run.levelHandler = null } - this._audioStarted = false - this._stopPartialLoop() + if (run.stoppedHandler) { + audio.off('stopped', run.stoppedHandler) + run.stoppedHandler = null + } + } + /** + * 이 run 의 오디오를 놓는다. 멱등 — 정지/취소/에러/완료 어디서 불러도 audio.stop() 은 + * run 이 실제로 참조를 잡았을 때 정확히 한 번만 호출된다(공유 refCount 보호). + */ + private async _releaseAudio(run: SessionRun): Promise { + run.audioReleaseRequested = true + this._detachAudioHandlers(run) + run.audioStarted = false + this._stopPartialLoop(run) + + if (!run.audioAcquired) return + run.audioAcquired = false try { - await audio.stop() + await getAudioCaptureService().stop() } catch (error) { - logger.warn(`Audio stop error: ${error instanceof Error ? error.message : String(error)}`) + logger.warn(`Audio stop error: ${describe(error)}`) } } + // ── 스크린 컨텍스트(선택 텍스트) ───────────────────────── + + private _startSelectionCapture(run: SessionRun): void { + if (!run.captureSelection || run.selectionCapture) return + run.selectionCapture = (async (): Promise => { + try { + const { getScreenContextService } = await import('./ScreenContextService') + return await getScreenContextService().captureSelectedText() + } catch (err) { + logger.debug(`Selected text capture skipped: ${describe(err)}`) + return null + } + })() + } + + /** 선택 텍스트 캡처가 끝나기를 기다렸다가 세션 컨텍스트에 합친다. */ + private async _applySelectionCapture(run: SessionRun): Promise { + if (!run.selectionCapture) return + const selectedText = await run.selectionCapture + if (!selectedText || !this._isCurrent(run) || !this._session) return + const base: ScreenContext = this._session.screenContext ?? { + appName: null, + windowTitle: null, + selectedText: null, + capturedAt: Date.now(), + } + this._session.screenContext = { ...base, selectedText } + } + // ── 실시간 부분 전사(미리보기) ───────────────────────────── /** * 녹음 중 주기적으로 지금까지의 오디오를 전사해 RecordingTip에 미리보기를 띄운다. * 최종 삽입 텍스트와는 완전히 분리된 경로이며, 실패는 조용히 무시된다. */ - private _startPartialLoop(): void { - this._stopPartialLoop() - if ((configGet('sttProvider') ?? 'local') !== 'local') return + private _startPartialLoop(run: SessionRun): void { + this._stopPartialLoop(run) + if (!getSTTManager().supportsPartial()) return - this._partialTimer = setInterval(() => { - void this._runPartial() + run.partialTimer = setInterval(() => { + void this._runPartial(run) }, PARTIAL_INTERVAL_MS) } - private _stopPartialLoop(): void { - if (this._partialTimer) { - clearInterval(this._partialTimer) - this._partialTimer = null + private _stopPartialLoop(run: SessionRun): void { + if (run.partialTimer) { + clearInterval(run.partialTimer) + run.partialTimer = null } } /** 진행 중인 부분 전사가 끝나기를 최대 PARTIAL_DRAIN_TIMEOUT_MS까지 기다린다. */ - private async _drainPartial(): Promise { - const inFlight = this._partialInFlight + private async _drainPartial(run: SessionRun): Promise { + const inFlight = run.partialInFlight if (!inFlight) return + let timer: NodeJS.Timeout | null = null await Promise.race([ inFlight, - new Promise((resolve) => setTimeout(resolve, PARTIAL_DRAIN_TIMEOUT_MS)), + new Promise((resolve) => { + timer = setTimeout(resolve, PARTIAL_DRAIN_TIMEOUT_MS) + }), ]) + if (timer) clearTimeout(timer) } - private async _runPartial(): Promise { - if (!this._audioStarted || this._partialInFlight) return - if (this._isInTerminalState()) return - if (!this._sttReady) return - if (this._audioBufferBytes < PARTIAL_MIN_AUDIO_MS * BYTES_PER_MS) return + private async _runPartial(run: SessionRun): Promise { + if (!run.audioStarted || run.partialInFlight) return + if (!this._isCurrent(run)) return + if (!run.sttReady) return + if (run.audioBufferBytes < PARTIAL_MIN_AUDIO_MS * BYTES_PER_MS) return - const sessionId = this._session?.id - const merged = Buffer.concat(this._audioBuffer) + const merged = Buffer.concat(run.audioBuffer) const maxBytes = PARTIAL_MAX_WINDOW_MS * BYTES_PER_MS const window = merged.length > maxBytes ? merged.subarray(merged.length - maxBytes) : merged const language = configGet('sttLanguage') @@ -570,58 +697,60 @@ class VoiceModeService extends EventEmitter { vadFilter: false, }) // 녹음이 끝났거나 세션이 바뀌었으면 미리보기를 버린다. - if (!this._audioStarted || this._isInTerminalState()) return - if (this._session?.id !== sessionId) return + if (!run.audioStarted || !this._isCurrent(run)) return if (!text) return - sendPartialTranscriptToTip(text) + this.emit('partial-transcript', { text, sessionId: run.id }) } catch (err) { - logger.debug( - `부분 전사 미리보기 무시: ${err instanceof Error ? err.message : String(err)}`, - ) + logger.debug(`부분 전사 미리보기 무시: ${describe(err)}`) } finally { - this._partialInFlight = null + run.partialInFlight = null } })() - this._partialInFlight = task + run.partialInFlight = task } // ── 이중 조건 플러시 ─────────────────────────────────── - private _tryFlushAll(): void { - // 녹음 진행 중에는 flush 하지 않음 (stopSession에서 _stopAudio 후 처리) - if (this._audioStarted) return - if (!this._sttReady) return - if (this._audioBuffer.length === 0) { + private _tryFlushAll(run: SessionRun): void { + // 녹음 진행 중에는 flush 하지 않음 (stopSession에서 오디오를 놓은 뒤 처리) + if (run.audioStarted) return + // 사용자가 정지하기 전에는 전사하지 않는다 (오디오 시작이 늦을 때 조기 전사 방지) + if (!run.stopRequested) return + if (!run.sttReady) return + if (run.transcribing) return + if (run.audioBuffer.length === 0) { logger.warn(`_tryFlushAll skipped: audioBuffer is empty`) return } - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return - this._clearSttWaitTimer() - void this._transcribe().catch((err) => { + this._clearSttWaitTimer(run) + void this._transcribe(run).catch((err) => { logger.error('Unhandled error in _transcribe flush:', err) }) } // ── 전사 ─────────────────────────────────────────────── - private async _transcribe(): Promise { - if (this._audioBuffer.length === 0) return - if (this._isInTerminalState()) return + private async _transcribe(run: SessionRun): Promise { + if (!this._isCurrent(run)) return + if (run.audioBuffer.length === 0 || run.transcribing) return + run.transcribing = true this._setRecognitionState(RecognitionState.RECOGNIZING) - const merged = Buffer.concat(this._audioBuffer) - this._lastAudioBuffer = merged // WAV 저장용 복사본 - this._audioBuffer = [] - this._audioBufferBytes = 0 + const merged = Buffer.concat(run.audioBuffer) + run.lastAudio = merged // WAV 저장용 (run 소유) + run.audioBuffer = [] + run.audioBufferBytes = 0 // Bug 13: 너무 짧은 오디오는 STT 호출 전에 에러 피드백. // 16kHz 16bit mono 기준 0.5초 = 16000 bytes. const minBytes = 16000 * 2 * 0.5 if (merged.length < minBytes) { this._handleError( + run, new D3ROError( ErrorCode.STTAudioTooShort, 'No speech detected. Please speak and try again.' @@ -645,19 +774,22 @@ class VoiceModeService extends EventEmitter { // 사이드카는 요청을 직렬 처리하므로, 진행 중인 미리보기 요청이 최종 전사를 // 지연시키지 않도록 먼저 배수한다(최대 PARTIAL_DRAIN_TIMEOUT_MS). - await this._drainPartial() + await this._drainPartial(run) + if (!this._isCurrent(run)) return const result: TranscriptionResult = await stt.transcribe(merged, { language: language === 'auto' ? undefined : language, initialPrompt, }) - if (this._isInTerminalState()) return + // 취소됐거나 다음 세션이 시작됐으면 결과를 버린다 (다른 세션에 삽입 금지) + if (!this._isCurrent(run)) return // Bug 13: VAD가 전체 오디오를 무음 판정했거나 결과가 빈 텍스트일 때 // 조용히 빈 문자열로 complete 하지 않고 사용자에게 피드백. if (!result.text || result.text.trim().length === 0) { this._handleError( + run, new D3ROError( ErrorCode.STTNoAudioData, 'No speech detected. Check your microphone and try again.' @@ -691,6 +823,7 @@ class VoiceModeService extends EventEmitter { } catch { // VoiceCommandService 미초기화 시 무시 } + if (!this._isCurrent(run)) return const llmAction = overrideAction ?? configGet('defaultLLMAction') const backend = configGet('llmBackend') @@ -707,16 +840,17 @@ class VoiceModeService extends EventEmitter { severity: 'warning', }) } - this._completeSession(effectiveText) + void this._completeSession(run, effectiveText) } else { - await this._processWithLLM(effectiveText, overrideInstructionId) + await this._processWithLLM(run, effectiveText, overrideInstructionId) } } catch (error) { - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return this._handleError( + run, new D3ROError( ErrorCode.STTTranscriptionFailed, - `Transcription failed: ${error instanceof Error ? error.message : String(error)}` + `Transcription failed: ${describe(error)}` ) ) } @@ -724,69 +858,44 @@ class VoiceModeService extends EventEmitter { // ── LLM 후처리 ───────────────────────────────────────── - private async _getLLMProcessor(): Promise<{ - service: { processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise } - backend: 'local' | 'premium' - }> { - const backend = configGet('llmBackend') - if (backend === 'online') { - try { - const { getPremiumLLMService } = await import('./PremiumLLMService') - const premium = getPremiumLLMService() - if (premium.isAvailable()) { - return { service: premium, backend: 'premium' } - } - this._emitPremiumFallback('Premium unavailable — falling back to local Ollama') - } catch (err) { - this._emitPremiumFallback( - `Premium init failed: ${err instanceof Error ? err.message : String(err)}`, - ) - } - } - return { service: getLocalLLMService(), backend: 'local' } - } - private _emitPremiumFallback(reason: string): void { - logger.warn(`Premium LLM fallback → local: ${reason}`) this.emit('premium-llm-fallback', { reason }) } - private async _runProcessorWithFallback( + /** 백엔드 선택·Premium→Local 폴백은 LlmGateway 가 소유한다. */ + private _runProcessorWithFallback( text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string, ): Promise { - const processor = await this._getLLMProcessor() - try { - return await processor.service.processText(text, action, targetLanguage, customPrompt) - } catch (err) { - if (processor.backend === 'premium') { - this._emitPremiumFallback( - `Premium call failed: ${err instanceof Error ? err.message : String(err)}`, - ) - return getLocalLLMService().processText(text, action, targetLanguage, customPrompt) - } - throw err - } + return getLlmGateway().processText(text, action, targetLanguage, customPrompt, { + onFallback: (reason) => this._emitPremiumFallback(reason), + }) } - private async _processWithLLM(transcribedText: string, overrideInstructionId?: string | null): Promise { - if (this._isInTerminalState()) return + private async _processWithLLM( + run: SessionRun, + transcribedText: string, + overrideInstructionId?: string | null, + ): Promise { + if (!this._isCurrent(run)) return try { const configuredAction = configGet('defaultLLMAction') // 음성 단축키가 특정 명령을 지목해 들어왔다면 기본 액션이 'none'이어도 처리한다. // 명시적 요청을 기본 설정이 무효화하면 안 된다. if (configuredAction === 'none' && !overrideInstructionId) { - this._completeSession(transcribedText) + void this._completeSession(run, transcribedText) return } // 여기서 'none'이 남아 있다면 overrideInstructionId가 반드시 있다 → custom 경로. const action: LLMAction = configuredAction === 'none' ? 'custom' : configuredAction - // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 + // Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입 (선택 텍스트는 정지 시점 캡처) + await this._applySelectionCapture(run) + if (!this._isCurrent(run)) return let contextPrefix = '' if (this._session?.screenContext) { try { @@ -804,17 +913,19 @@ class VoiceModeService extends EventEmitter { const activeChainId = configGet('activeChainId') if (activeChainId) { const chainResult = await getChainService().execute(activeChainId, contextPrefix + transcribedText) - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return if (this._session) this._session.processedText = chainResult.finalText - this._completeSession(chainResult.finalText) + void this._completeSession(run, chainResult.finalText) return } } catch (error) { - logger.warn(`Chain execution failed: ${error instanceof Error ? error.message : String(error)}`) + if (!this._isCurrent(run)) return + logger.warn(`Chain execution failed: ${describe(error)}`) this._handleError( + run, error instanceof D3ROError ? error - : new D3ROError(ErrorCode.ChainExecutionFailed, `Chain execution failed: ${error instanceof Error ? error.message : String(error)}`), + : new D3ROError(ErrorCode.ChainExecutionFailed, `Chain execution failed: ${describe(error)}`), ) return } @@ -871,21 +982,22 @@ class VoiceModeService extends EventEmitter { ) } - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return if (this._session) { this._session.processedText = processedText } - this._completeSession(processedText) + void this._completeSession(run, processedText) } catch (error) { - if (this._isInTerminalState()) return + if (!this._isCurrent(run)) return this._handleError( + run, error instanceof D3ROError ? error : new D3ROError( ErrorCode.LLMProcessingFailed, - `LLM processing failed: ${error instanceof Error ? error.message : String(error)}`, + `LLM processing failed: ${describe(error)}`, ), ) } @@ -893,45 +1005,47 @@ class VoiceModeService extends EventEmitter { // ── 세션 완료/취소 ───────────────────────────────────── - private async _completeSession(finalText: string): Promise { - if (!this._session) return + private async _completeSession(run: SessionRun, finalText: string): Promise { + if (!this._isCurrent(run) || !this._session) return const session = { ...this._session } + const lastAudio = run.lastAudio + const selectionCapture = run.selectionCapture logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`) - // Speakly 패턴: 먼저 RecordingTip 숨기고 IDLE 복귀 (다음 핫키 즉시 사용 가능) - hideRecordingTip() + // Speakly 패턴: 먼저 IDLE 복귀 (다음 핫키 즉시 사용 가능). 표시 계층이 완료 이벤트로 팁을 숨긴다. this._setRecognitionState(RecognitionState.COMPLETED) this._setAudioState(AudioState.STOPPED) this.emit('session-completed', { session, finalText }) - this._resetToIdle() + this._endRun(run) // 텍스트 삽입은 세션과 무관하게 비동기 실행 (세션 블로킹 방지) if (configGet('autoInsert') && finalText.length > 0) { try { + // 선택 텍스트 캡처가 클립보드를 쓰는 중이면 끝날 때까지 기다린다 (클립보드 경합 방지) + if (selectionCapture) await selectionCapture const insertMethod = configGet('insertMethod') await getTextInsertService().insertText(finalText, insertMethod) } catch (error) { - logger.warn(`Text insert failed: ${error instanceof Error ? error.message : String(error)}`) - // 삽입 실패 시 ResultPopup에 텍스트 표시 - showResultPopup(finalText, 10000) + logger.warn(`Text insert failed: ${describe(error)}`) + // 삽입 실패 시 표시 계층이 ResultPopup에 텍스트를 보여 준다 + this.emit('insert-failed', { text: finalText, sessionId: session.id }) } } // 녹음 오디오 WAV 파일 저장 (비동기, 실패해도 무시) - this._saveAudioFile(session.id) + void this._saveAudioFile(session.id, lastAudio) } /** PCM 버퍼를 WAV 파일로 저장 */ - private async _saveAudioFile(sessionId: string): Promise { - if (!this._lastAudioBuffer || this._lastAudioBuffer.length === 0) return + private async _saveAudioFile(sessionId: string, pcmData: Buffer | null): Promise { + if (!pcmData || pcmData.length === 0) return try { const recordingsDir = join(app.getPath('userData'), 'recordings') await mkdir(recordingsDir, { recursive: true }) const wavPath = join(recordingsDir, `${sessionId}.wav`) - const pcmData = this._lastAudioBuffer // WAV 헤더 생성 (16kHz, 16bit, mono) const header = Buffer.alloc(44) @@ -955,14 +1069,13 @@ class VoiceModeService extends EventEmitter { await writeFile(wavPath, Buffer.concat([header, pcmData])) logger.info(`Audio saved: ${wavPath} (${Math.round(dataSize / 1024)}KB)`) } catch (error) { - logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`) - } finally { - this._lastAudioBuffer = null + logger.warn(`Audio save failed: ${describe(error)}`) } } private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void { - if (!this._session) return + const run = this._run + if (!run || !this._session) return this._setRecognitionState(RecognitionState.CANCELLED) this._setAudioState(AudioState.STOPPED) @@ -970,34 +1083,35 @@ class VoiceModeService extends EventEmitter { const session = { ...this._session } logger.info(`Session cancelled: ${reason}`) - // Speakly 패턴: RecordingTip 즉시 숨김 - hideRecordingTip() - - // 오디오 정리 - this._stopAudio() - + // 표시 계층이 취소 이벤트로 RecordingTip 을 즉시 숨긴다 this.emit('session-cancelled', { session, reason }) - this._resetToIdle() + this._endRun(run) } - private _clearSttWaitTimer(): void { - if (this._sttWaitTimer) { - clearTimeout(this._sttWaitTimer) - this._sttWaitTimer = null + private _clearSttWaitTimer(run: SessionRun): void { + if (run.sttWaitTimer) { + clearTimeout(run.sttWaitTimer) + run.sttWaitTimer = null } } + /** + * run 을 끝낸다: 늦게 도착할 비동기 결과를 무효화(abort)하고, 타이머를 정리하고, + * 오디오를 멱등하게 놓은 뒤 IDLE 로 돌아간다. + */ + private _endRun(run: SessionRun): void { + run.controller.abort() + this._clearSttWaitTimer(run) + this._stopPartialLoop(run) + run.partialInFlight = null + void this._releaseAudio(run) + if (this._run === run) this._resetToIdle() + } + private _resetToIdle(): void { - this._clearSttWaitTimer() - this._stopPartialLoop() - this._partialInFlight = null this._session = null - this._audioBuffer = [] - this._audioBufferBytes = 0 - this._sttReady = false - this._audioStarted = false - this._errorEmitted = false + this._run = null // 즉시 IDLE로 전이 (딜레이는 race condition 유발) this._setRecognitionState(RecognitionState.IDLE) @@ -1006,28 +1120,20 @@ class VoiceModeService extends EventEmitter { // ── 에러 처리 ────────────────────────────────────────── - private _handleError(error: D3ROError): void { - if (this._errorEmitted) return - this._errorEmitted = true + private _handleError(run: SessionRun, error: D3ROError): void { + // 이미 끝난(취소·교체된) 세션의 늦은 실패는 새 세션을 건드리지 않는다 + if (!this._isCurrent(run)) { + logger.debug(`Ignoring stale session error [${error.code}]: ${error.message}`) + return + } logger.error(`VoiceMode error [${error.code}]: ${error.message}`) this._setRecognitionState(RecognitionState.ERROR) + this._setAudioState(AudioState.STOPPED) - this._stopAudio() - - // Speakly 패턴: RecordingTip에 에러 표시 → 3초 후 자동 숨김. - // 핸들 보관 → 다음 세션 시작 시 clearTimeout으로 취소(없으면 새 recording tip 숨김). - updateRecordingTipState('error', { errorMessage: error.message }) - if (this._errorHideTimer) { - clearTimeout(this._errorHideTimer) - } - this._errorHideTimer = setTimeout(() => { - this._errorHideTimer = null - hideRecordingTip() - }, 3000) - + // Speakly 패턴: 표시 계층이 'error' 로 RecordingTip 에 에러를 띄우고 3초 후 숨긴다 this.emit('error', { error, session: this._session ? { ...this._session } : null }) - this._resetToIdle() + this._endRun(run) } // ── Action Queue (이벤트 직렬화) ─────────────────────── @@ -1065,7 +1171,7 @@ class VoiceModeService extends EventEmitter { break } } catch (error) { - logger.error(`Action processing error: ${error instanceof Error ? error.message : String(error)}`) + logger.error(`Action processing error: ${describe(error)}`) } } @@ -1122,8 +1228,13 @@ class VoiceModeService extends EventEmitter { const state = caption.getState() if (state === 'active' || state === 'starting') { - // 자막 활성 중 → 정지 - await caption.stop() + // 자막 활성 중 → 정지. 단, 회의 모드가 소유한 자막 세션은 핫키로 끌 수 없다 + // (예전엔 회의의 마이크 캡처·전사가 조용히 멈추고 UI 는 계속 '녹음 중'이었다). + const stopped = await caption.stop('user') + if (stopped === false) { + logger.warn('Caption hotkey ignored: caption session is owned by meeting mode') + return + } logger.info('Caption stopped via hotkey') } else { // dictation 세션이 활성이면 자막 시작 불가 (상호 배제) @@ -1141,7 +1252,7 @@ class VoiceModeService extends EventEmitter { logger.info('Caption started via hotkey') } } catch (error) { - logger.error(`Caption toggle failed: ${error instanceof Error ? error.message : String(error)}`) + logger.error(`Caption toggle failed: ${describe(error)}`) } } @@ -1151,7 +1262,7 @@ class VoiceModeService extends EventEmitter { this._disposed = true this._actionQueue = [] - // 진행 중 세션 취소 + // 진행 중 세션 취소 (오디오 해제 포함 — 멱등이므로 따로 다시 멈추지 않는다) if (this._session && !this._isInTerminalState()) { this._cancelSession('user') } @@ -1162,15 +1273,6 @@ class VoiceModeService extends EventEmitter { this._keyBindingHandler = null } - // 오디오 리스너 해제 - this._stopAudio() - - // 에러 hide 타이머 정리 (pending 시 메모리 leak 방지) - if (this._errorHideTimer) { - clearTimeout(this._errorHideTimer) - this._errorHideTimer = null - } - this._setRecognitionState(RecognitionState.DESTROYED) this.removeAllListeners() logger.info('VoiceModeService disposed') diff --git a/apps/desktop/src/main/services/llm/LlmGateway.ts b/apps/desktop/src/main/services/llm/LlmGateway.ts new file mode 100644 index 0000000..007921f --- /dev/null +++ b/apps/desktop/src/main/services/llm/LlmGateway.ts @@ -0,0 +1,236 @@ +// src/main/services/llm/LlmGateway.ts +// LLM 백엔드 선택 정책의 단일 정본(포트 + 라우팅 구현). +// +// 예전엔 VoiceModeService / VoiceConversationService / MeetingModeService 가 각자 +// `llmBackend === 'online' ? (premium.isAvailable() ? premium : local) : local` 를 복제했고, +// RAGService·ChainService 는 이 판단을 건너뛰고 Premium 에 고정돼 있었다 +// (로컬 모드 익명 사용자는 기능이 깨지고, 로컬을 고른 로그인 사용자는 텍스트가 클라우드로 갔다). +// 기능 서비스는 이 게이트웨이(추상)에만 의존하고, Premium/Local 서비스는 어댑터로 남는다. + +import { getLogger } from '../LoggerService' +import { configGet } from '../ConfigService' +import { getLocalLLMService } from '../LocalLLMService' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import type { LLMAction } from '@d3ro/core/types' + +const logger = getLogger('LlmGateway') + +// ── 포트 타입 ────────────────────────────────────────────── + +export interface LlmGenerateOptions { + systemPrompt?: string + temperature?: number + maxTokens?: number +} + +export interface LlmGenerateResult { + text: string + model?: string +} + +export interface LlmChatMessage { + role: string + content: string +} + +/** 로컬(Ollama) 채팅 스트림 전용 옵션 — 프리미엄 경로에는 전달되지 않는다. */ +export interface LocalChatStreamOptions { + maxTokens?: number + timeoutMs?: number + keepAlive?: string + temperature?: number +} + +export type LlmBackendKind = 'local' | 'premium' + +export interface LlmCallHooks { + /** Premium 을 쓰려 했으나 로컬로 넘어갔을 때 사유와 함께 호출된다. */ + onFallback?: (reason: string) => void +} + +export interface LlmChatStreamHandle { + generator: AsyncGenerator + backend: LlmBackendKind +} + +export interface LlmGateway { + /** 액션 기반 텍스트 처리 (받아쓰기 후처리, 체인 단계). Premium 실패 시 로컬로 폴백한다. */ + processText( + text: string, + action: LLMAction, + targetLanguage?: string, + customPrompt?: string, + hooks?: LlmCallHooks, + ): Promise + /** 자유 생성 (RAG 답변 등). Premium 실패 시 로컬로 폴백한다. */ + generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise + /** + * 스트리밍 대화. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다(부분 응답 이중 출력 방지). + * 로컬이 가용하지 않으면 LLMServerUnreachable 을 던진다. + */ + openChatStream( + messages: LlmChatMessage[], + options: { signal: AbortSignal; local?: LocalChatStreamOptions }, + hooks?: LlmCallHooks, + ): Promise + /** 두 어댑터의 진행 중 생성을 모두 취소한다. */ + cancel(): void +} + +// ── 어댑터 계약 (기존 Premium/Local 서비스가 구조적으로 만족) ───── + +export interface PremiumLlmAdapter { + isAvailable(): boolean + processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise + generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string }> + chatStream(messages: LlmChatMessage[], options?: { temperature?: number }): AsyncGenerator + cancelGeneration(): void +} + +export interface LocalLlmAdapter { + isAvailable(): boolean + processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise + generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string; model?: string }> + chatStream( + messages: LlmChatMessage[], + options?: LocalChatStreamOptions & { signal?: AbortSignal }, + ): AsyncGenerator + cancelGeneration(): void +} + +export interface LlmGatewayDeps { + /** 사용자가 고른 백엔드 ('online' 만 Premium 을 의미한다) */ + getBackend: () => string | null | undefined + /** Premium 어댑터 로드 — 지연 import(순환 의존·초기화 비용 회피) */ + loadPremium: () => Promise + getLocal: () => LocalLlmAdapter + /** 이미 로드된 Premium 이 있으면 돌려준다(취소용). 없으면 null. */ + peekPremium?: () => PremiumLlmAdapter | null +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +// ── 라우팅 구현 ───────────────────────────────────────────── + +class RoutedLlmGateway implements LlmGateway { + private _premium: PremiumLlmAdapter | null = null + + constructor(private readonly _deps: LlmGatewayDeps) {} + + private _fallback(hooks: LlmCallHooks | undefined, reason: string): void { + logger.warn(`Premium LLM fallback → local: ${reason}`) + hooks?.onFallback?.(reason) + } + + /** + * llmBackend==='online' 이고 Premium 이 가용하면 Premium, 아니면 null(로컬 사용). + * 가용하지 않거나 로드에 실패하면 폴백 사유를 알린다. + */ + private async _selectPremium(hooks: LlmCallHooks | undefined): Promise { + if (this._deps.getBackend() !== 'online') return null + try { + const premium = await this._deps.loadPremium() + this._premium = premium + if (premium.isAvailable()) return premium + this._fallback(hooks, 'Premium unavailable — falling back to local Ollama') + } catch (err) { + this._fallback(hooks, `Premium init failed: ${describe(err)}`) + } + return null + } + + async processText( + text: string, + action: LLMAction, + targetLanguage?: string, + customPrompt?: string, + hooks?: LlmCallHooks, + ): Promise { + const premium = await this._selectPremium(hooks) + if (premium) { + try { + return await premium.processText(text, action, targetLanguage, customPrompt) + } catch (err) { + this._fallback(hooks, `Premium call failed: ${describe(err)}`) + } + } + return this._deps.getLocal().processText(text, action, targetLanguage, customPrompt) + } + + async generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise { + const premium = await this._selectPremium(hooks) + if (premium) { + try { + return await premium.generate(text, options) + } catch (err) { + this._fallback(hooks, `Premium call failed: ${describe(err)}`) + } + } + return this._deps.getLocal().generate(text, options) + } + + async openChatStream( + messages: LlmChatMessage[], + options: { signal: AbortSignal; local?: LocalChatStreamOptions }, + hooks?: LlmCallHooks, + ): Promise { + const premium = await this._selectPremium(hooks) + if (premium) { + return { generator: premium.chatStream(messages), backend: 'premium' } + } + + const local = this._deps.getLocal() + if (!local.isAvailable()) { + throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Local LLM (Ollama) is not available') + } + return { + generator: local.chatStream(messages, { ...options.local, signal: options.signal }), + backend: 'local', + } + } + + cancel(): void { + try { + this._deps.getLocal().cancelGeneration() + } catch (err) { + logger.debug(`Local cancel failed: ${describe(err)}`) + } + const premium = this._premium ?? this._deps.peekPremium?.() ?? null + try { + premium?.cancelGeneration() + } catch (err) { + logger.debug(`Premium cancel failed: ${describe(err)}`) + } + } +} + +export function createRoutedLlmGateway(deps: LlmGatewayDeps): LlmGateway { + return new RoutedLlmGateway(deps) +} + +// ── 싱글톤 (테스트에서 교체 가능) ─────────────────────────── + +function defaultDeps(): LlmGatewayDeps { + return { + getBackend: () => configGet('llmBackend'), + loadPremium: async () => { + const { getPremiumLLMService } = await import('../PremiumLLMService') + return getPremiumLLMService() + }, + getLocal: () => getLocalLLMService(), + } +} + +let instance: LlmGateway | null = null + +export function getLlmGateway(): LlmGateway { + if (!instance) instance = createRoutedLlmGateway(defaultDeps()) + return instance +} + +/** 테스트 전용: 게이트웨이를 교체하거나(null 이면) 기본 구현으로 되돌린다. */ +export function setLlmGatewayForTests(gateway: LlmGateway | null): void { + instance = gateway +} diff --git a/apps/desktop/src/main/services/rag/docx-text.ts b/apps/desktop/src/main/services/rag/docx-text.ts new file mode 100644 index 0000000..d423be7 --- /dev/null +++ b/apps/desktop/src/main/services/rag/docx-text.ts @@ -0,0 +1,116 @@ +// src/main/services/rag/docx-text.ts +// .docx(ZIP) 에서 본문 텍스트를 꺼낸다 — 순수 JS + zlib, 외부 의존 없음. +// +// 예전엔 ZIP 바이트를 그대로 UTF-8 문자열로 보고 정규식을 돌렸다. Word 는 항상 +// word/document.xml 을 deflate 압축해 저장하므로 아무것도 찾지 못했고, 폴백이 태그만 지운 +// 바이너리 잡음을 문서 내용으로 색인·임베딩·클라우드 동기화했다. + +import { inflateRawSync } from 'zlib' + +const EOCD_SIGNATURE = 0x06054b50 +const CENTRAL_SIGNATURE = 0x02014b50 +const LOCAL_SIGNATURE = 0x04034b50 +const METHOD_STORED = 0 +const METHOD_DEFLATE = 8 +/** 압축 해제 상한 — 비정상 파일(zip bomb)로 메모리를 다 쓰지 않게 */ +const MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024 + +export class DocxExtractionError extends Error {} + +function findEndOfCentralDirectory(buffer: Buffer): number { + // EOCD 는 끝에서 22바이트 + 주석(최대 65535) 안에 있다 + const minOffset = Math.max(0, buffer.length - 22 - 0xffff) + for (let offset = buffer.length - 22; offset >= minOffset; offset--) { + if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset + } + throw new DocxExtractionError('Not a ZIP archive (end of central directory not found)') +} + +/** ZIP 안의 한 항목을 꺼낸다. 없으면 null. */ +export function readZipEntry(buffer: Buffer, entryName: string): Buffer | null { + if (buffer.length < 22) throw new DocxExtractionError('File too small to be a ZIP archive') + const eocd = findEndOfCentralDirectory(buffer) + const entryCount = buffer.readUInt16LE(eocd + 10) + let offset = buffer.readUInt32LE(eocd + 16) + + for (let i = 0; i < entryCount; i++) { + if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) { + throw new DocxExtractionError('Corrupt ZIP central directory') + } + const method = buffer.readUInt16LE(offset + 10) + const compressedSize = buffer.readUInt32LE(offset + 20) + const uncompressedSize = buffer.readUInt32LE(offset + 24) + const nameLength = buffer.readUInt16LE(offset + 28) + const extraLength = buffer.readUInt16LE(offset + 30) + const commentLength = buffer.readUInt16LE(offset + 32) + const localHeaderOffset = buffer.readUInt32LE(offset + 42) + const name = buffer.toString('utf-8', offset + 46, offset + 46 + nameLength) + offset += 46 + nameLength + extraLength + commentLength + + if (name !== entryName) continue + + if (localHeaderOffset + 30 > buffer.length || buffer.readUInt32LE(localHeaderOffset) !== LOCAL_SIGNATURE) { + throw new DocxExtractionError('Corrupt ZIP local header') + } + const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26) + const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28) + const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength + const data = buffer.subarray(dataStart, dataStart + compressedSize) + + if (uncompressedSize > MAX_UNCOMPRESSED_BYTES) { + throw new DocxExtractionError('ZIP entry too large') + } + if (method === METHOD_STORED) return Buffer.from(data) + if (method === METHOD_DEFLATE) { + return inflateRawSync(data, { maxOutputLength: MAX_UNCOMPRESSED_BYTES }) + } + throw new DocxExtractionError(`Unsupported ZIP compression method: ${method}`) + } + return null +} + +const XML_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +} + +export function decodeXmlEntities(text: string): string { + return text.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity: string) => { + if (entity.startsWith('#x')) return String.fromCodePoint(parseInt(entity.slice(2), 16)) + if (entity.startsWith('#')) return String.fromCodePoint(parseInt(entity.slice(1), 10)) + return XML_ENTITIES[entity] ?? match + }) +} + +/** WordprocessingML(document.xml) 에서 문단 단위 텍스트를 뽑는다. */ +export function wordXmlToText(xml: string): string { + const paragraphs: string[] = [] + const paragraphPattern = /][\s\S]*?<\/w:p>|/g + for (const match of xml.match(paragraphPattern) ?? []) { + const runs: string[] = [] + const tokenPattern = /]*)?>([\s\S]*?)<\/w:t>||/g + let token: RegExpExecArray | null + while ((token = tokenPattern.exec(match)) !== null) { + if (token[1] !== undefined) runs.push(decodeXmlEntities(token[1])) + else if (token[0] === '') runs.push('\t') + else runs.push('\n') + } + const text = runs.join('').trim() + if (text) paragraphs.push(text) + } + return paragraphs.join('\n') +} + +/** + * .docx 버퍼에서 본문 텍스트를 추출한다. ZIP 이 아니거나 본문이 없으면 DocxExtractionError. + */ +export function extractDocxText(buffer: Buffer): string { + const documentXml = readZipEntry(buffer, 'word/document.xml') + if (!documentXml) throw new DocxExtractionError('word/document.xml not found') + const text = wordXmlToText(documentXml.toString('utf-8')) + if (!text.trim()) throw new DocxExtractionError('No text found in document') + return text +} diff --git a/apps/desktop/src/main/services/stt/STTManager.ts b/apps/desktop/src/main/services/stt/STTManager.ts index 9f3f6df..1e48584 100644 --- a/apps/desktop/src/main/services/stt/STTManager.ts +++ b/apps/desktop/src/main/services/stt/STTManager.ts @@ -14,7 +14,8 @@ import type { TestSTTConnectionResult, STTStatus, } from '@d3ro/core/types' -import type { ISTTDriver } from './types' +import type { ISTTDriver, STTEngine } from './types' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { OpenAIDriver } from './drivers/OpenAIDriver' import { GroqDriver } from './drivers/GroqDriver' import { DeepgramDriver } from './drivers/DeepgramDriver' @@ -26,6 +27,23 @@ import { normalizeLoopbackUrl } from '../../utils/loopback' const logger = getLogger('STTManager') +/** + * 클라우드 실패 → 로컬 폴백의 전체 상한(모델 로딩 포함). + * 로컬 엔진의 각 단계에도 타임아웃이 있지만, 폴백 경로가 어떤 이유로든 멈추면 + * 받아쓰기 액션 큐 전체가 굳으므로 여기서 한 번 더 끊는다. + */ +export const LOCAL_FALLBACK_TIMEOUT_MS = 180_000 + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: NodeJS.Timeout | null = null + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new D3ROError(ErrorCode.STTTranscriptionTimeout, message)), timeoutMs) + }) + return Promise.race([promise, timeout]).finally(() => { + if (timer) clearTimeout(timer) + }) +} + export const STT_PROVIDERS_META: STTProviderInfo[] = [ { id: 'local', @@ -116,7 +134,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [ }, ] -export class STTManager extends EventEmitter { +export class STTManager extends EventEmitter implements STTEngine { private _drivers = new Map() constructor() { @@ -210,7 +228,7 @@ export class STTManager extends EventEmitter { async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise { const provider = this.getActiveProvider() - // 1. Local STT + // 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다 if (provider === 'local') { return getLocalSTTService().transcribe(audioBuffer, options) } @@ -219,7 +237,7 @@ export class STTManager extends EventEmitter { const driver = this._drivers.get(provider) if (!driver) { logger.warn(`Driver not found for provider ${provider}, falling back to local`) - return getLocalSTTService().transcribe(audioBuffer, options) + return this._transcribeLocalFallback(audioBuffer, options) } const config = this.getProviderConfig(provider) @@ -236,7 +254,7 @@ export class STTManager extends EventEmitter { logger.info(`Auto-fallback: executing Local Whisper transcription...`) this.emit('fallback-to-local', { provider, reason: errorMsg }) try { - return await getLocalSTTService().transcribe(audioBuffer, options) + return await this._transcribeLocalFallback(audioBuffer, options) } catch (localError) { logger.error(`Local STT fallback also failed: ${localError}`) throw error // 원래 에러 유지 @@ -247,6 +265,45 @@ export class STTManager extends EventEmitter { } } + /** + * 클라우드 공급자에서 로컬 엔진으로 넘어가는 경로. + * 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가 + * 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어 + * 받아쓰기 액션 큐가 굳지 않게 한다. + */ + private _transcribeLocalFallback( + audioBuffer: Buffer, + options?: TranscribeOptions, + ): Promise { + return withTimeout( + getLocalSTTService().transcribe(audioBuffer, options), + LOCAL_FALLBACK_TIMEOUT_MS, + `Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`, + ) + } + + /** + * STTEngine 포트: 현재 공급자로 전사할 준비를 한다. + * 로컬이면 설정된 모델을 올리고(이미 올라가 있으면 즉시 반환), 클라우드면 할 일이 없다. + */ + async ensureReady(): Promise { + if (this.getActiveProvider() !== 'local') return + await getLocalSTTService().initialize(configGet('sttModelId')) + } + + /** STTEngine 포트: 로컬 공급자에서 선택한 모델이 설치되지 않았으면 그 id를 돌려준다. */ + getMissingModel(): string | null { + if (this.getActiveProvider() !== 'local') return null + const modelId = configGet('sttModelId') + const model = getLocalSTTService().getModels().find((m) => m.id === modelId) + return model && !model.downloaded ? modelId : null + } + + /** STTEngine 포트: 실시간 미리보기는 로컬 Whisper에서만 지원한다. */ + supportsPartial(): boolean { + return this.getActiveProvider() === 'local' + } + /** * 녹음 중 실시간 미리보기 전사(최종 삽입과 무관). * 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다. diff --git a/apps/desktop/src/main/services/stt/types.ts b/apps/desktop/src/main/services/stt/types.ts index bf782e4..83149f9 100644 --- a/apps/desktop/src/main/services/stt/types.ts +++ b/apps/desktop/src/main/services/stt/types.ts @@ -4,6 +4,8 @@ import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService' import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types' +export type { TranscriptionResult, TranscribeOptions } + export interface ISTTDriver { readonly id: STTProviderType readonly name: string @@ -18,3 +20,26 @@ export interface ISTTDriver { message: string }> } + +/** + * 받아쓰기 파이프라인이 의존하는 STT 엔진 포트. + * + * 공급자(로컬/클라우드) 선택과 로컬 폴백 정책은 구현(STTManager) 한 곳에만 둔다. + * 오케스트레이터(VoiceModeService)는 `provider === 'local'` 같은 분기를 알 필요가 없다. + */ +export interface STTEngine { + /** + * 현재 공급자로 전사할 준비를 한다. 로컬 공급자면 설정된 모델을 올리고, + * 클라우드 공급자면 즉시 끝난다. 실패하면 예외를 던진다(무한 대기 없음). + */ + ensureReady(): Promise + /** + * 녹음 시작 전에 확인할 수 있는 "필수 모델 미설치" 상태. + * 설치되지 않은 모델 id를 돌려주고, 문제가 없으면 null. + */ + getMissingModel(): string | null + /** 실시간 미리보기(부분 전사)를 지원하는지 — 클라우드 공급자는 요청 비용 때문에 지원하지 않는다. */ + supportsPartial(): boolean + transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise + transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise +} diff --git a/apps/desktop/src/main/shutdown-registry.ts b/apps/desktop/src/main/shutdown-registry.ts new file mode 100644 index 0000000..78da585 --- /dev/null +++ b/apps/desktop/src/main/shutdown-registry.ts @@ -0,0 +1,113 @@ +// src/main/shutdown-registry.ts +// 앱 종료 시 정리할 자원(사이드카 프로세스, 전역 입력 훅, 타이머 등)의 등록부. +// +// 예전엔 lifecycle.ts 가 disposeLocalLLMService 하나만 하드코딩해 호출했고, 새로 프로세스를 +// 소유하는 서비스(STT 사이드카 등)는 lifecycle 을 고치는 것을 잊어 종료 후에도 고아로 남았다. +// 이제 서비스는 시작 시점에 스스로 등록하고, lifecycle 은 이 등록부에만 의존한다(DIP/OCP). + +export interface Disposable { + name: string + dispose(): Promise | void +} + +export interface ShutdownReport { + completed: string[] + failed: string[] + timedOut: string[] +} + +export interface ShutdownRegistryOptions { + now?: () => number + onError?: (name: string, error: unknown) => void +} + +const TIMED_OUT = Symbol('timed-out') + +export class ShutdownRegistry { + private _entries: Disposable[] = [] + private _running: Promise | null = null + private readonly _now: () => number + private readonly _onError: (name: string, error: unknown) => void + + constructor(options: ShutdownRegistryOptions = {}) { + this._now = options.now ?? (() => Date.now()) + this._onError = options.onError ?? (() => undefined) + } + + /** 정리 대상을 등록한다. 돌려받은 함수로 등록을 취소할 수 있다. */ + register(entry: Disposable): () => void { + this._entries.push(entry) + return () => { + this._entries = this._entries.filter((e) => e !== entry) + } + } + + get size(): number { + return this._entries.length + } + + /** + * 등록의 역순(나중에 시작한 것부터)으로 하나씩 정리한다. 여러 번 불러도 한 번만 실행된다. + * 전체 시간 상한(timeoutMs)을 넘기면 남은 항목은 기다리지 않고 dispose 만 걸어 둔다 + * (동기 구간 — 프로세스 kill 등 — 은 그래도 실행된다). 한 항목이 멈춰도 종료가 막히지 않는다. + */ + disposeAll(timeoutMs: number): Promise { + if (!this._running) this._running = this._run(timeoutMs) + return this._running + } + + private async _run(timeoutMs: number): Promise { + const report: ShutdownReport = { completed: [], failed: [], timedOut: [] } + const deadline = this._now() + timeoutMs + const entries = [...this._entries].reverse() + + for (const entry of entries) { + const remaining = deadline - this._now() + let pending: Promise + try { + pending = Promise.resolve(entry.dispose()) + } catch (error) { + this._onError(entry.name, error) + report.failed.push(entry.name) + continue + } + + if (remaining <= 0) { + pending.catch((error: unknown) => this._onError(entry.name, error)) + report.timedOut.push(entry.name) + continue + } + + let timer: ReturnType | null = null + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMED_OUT), remaining) + }) + try { + const outcome = await Promise.race([pending.then(() => 'done' as const), timeout]) + if (outcome === TIMED_OUT) { + pending.catch((error: unknown) => this._onError(entry.name, error)) + report.timedOut.push(entry.name) + } else { + report.completed.push(entry.name) + } + } catch (error) { + this._onError(entry.name, error) + report.failed.push(entry.name) + } finally { + if (timer) clearTimeout(timer) + } + } + return report + } +} + +let instance: ShutdownRegistry | null = null + +export function getShutdownRegistry(): ShutdownRegistry { + if (!instance) instance = new ShutdownRegistry() + return instance +} + +export function resetShutdownRegistryForTests(): void { + instance = null +} diff --git a/apps/desktop/src/main/update-policy.ts b/apps/desktop/src/main/update-policy.ts index 2b20270..78344de 100644 --- a/apps/desktop/src/main/update-policy.ts +++ b/apps/desktop/src/main/update-policy.ts @@ -259,3 +259,84 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy { return policy } + +// ============================================================ +// 업데이트 제안 평가 (게이팅 순서의 단일 정본) +// ============================================================ + +export interface UpdateOfferInput { + policy: UpdatePolicy + channel: UpdateChannel + currentVersion: string + targetVersion: string + /** 사용자가 건너뛴 버전 (없으면 null) */ + skippedVersion: string | null + deviceId: string +} + +export type UpdateOfferIgnoreReason = 'kill-switch' | 'channel' | 'skipped' | 'rollout' + +export type UpdateOffer = + | { action: 'ignore'; reason: UpdateOfferIgnoreReason; decision: UpdateDecision } + | { action: 'prompt' | 'force-download'; decision: UpdateDecision; isMajorUpgrade: boolean } + +/** + * 발견된 업데이트를 어떻게 다룰지 결정한다. 순서: + * 킬 스위치 → 채널(prerelease) → 사용자 건너뛰기(필수면 무시) → staged rollout(필수면 무시) + * → 강제 설치 하한 미만이면 대화상자 없이 다운로드, 아니면 사용자에게 묻는다. + */ +export function evaluateUpdateOffer(input: UpdateOfferInput): UpdateOffer { + const { policy, channel, currentVersion, targetVersion } = input + const decision = decideUpdate(policy, currentVersion, targetVersion) + + if (policy.killSwitch) return { action: 'ignore', reason: 'kill-switch', decision } + + const channelPolicy = policy.channels[channel] ?? { allowPrerelease: false } + if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, targetVersion)) { + return { action: 'ignore', reason: 'channel', decision } + } + + if (!decision.mandatory && input.skippedVersion === targetVersion) { + return { action: 'ignore', reason: 'skipped', decision } + } + + if (!isWithinRollout(policy, input.deviceId, targetVersion, decision)) { + return { action: 'ignore', reason: 'rollout', decision } + } + + const currentMajor = majorOf(currentVersion) + const targetMajor = majorOf(targetVersion) + const isMajorUpgrade = currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor + + if (decision.mandatory && decision.reason === 'below-force-install') { + return { action: 'force-download', decision, isMajorUpgrade } + } + return { action: 'prompt', decision, isMajorUpgrade } +} + +/** 마지막으로 사용자에게 업데이트를 물어본 기록 */ +export interface UpdatePromptRecord { + version: string + mandatory: boolean + at: number +} + +/** '나중에' 이후 같은 버전을 다시 묻기까지의 간격 (24시간) */ +export const UPDATE_REPROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000 + +/** + * 이번 제안을 사용자에게 다시 물을지 결정한다. + * 예전엔 프로세스당 한 번만 물어 '나중에'나 다운로드 실패 뒤로는 새 버전·필수 업데이트도 + * 앱을 다시 켤 때까지 조용히 묻혔다. 이제 새 버전, 필수로 격상, 또는 간격이 지나면 다시 묻는다. + */ +export function shouldPromptForUpdate( + last: UpdatePromptRecord | null, + offer: { version: string; mandatory: boolean }, + now: number, + intervalMs: number = UPDATE_REPROMPT_INTERVAL_MS, +): boolean { + if (!last) return true + if (last.version !== offer.version) return true + if (offer.mandatory && !last.mandatory) return true + return now - last.at >= intervalMs +} diff --git a/apps/desktop/src/main/windows/popup-navigation.ts b/apps/desktop/src/main/windows/popup-navigation.ts new file mode 100644 index 0000000..492a44a --- /dev/null +++ b/apps/desktop/src/main/windows/popup-navigation.ts @@ -0,0 +1,157 @@ +// src/main/windows/popup-navigation.ts +// 히스토리/명령어 팝업의 표시·키보드 내비게이션 컨트롤러. +// +// 두 팝업은 포커스를 갖지 않으므로(focusable:false) 키보드 조작은 전역 단축키 +// (Up/Down/Return/Escape/1-9)로만 들어온다. 예전엔 bootstrap 이 팝업마다 단축키를 따로 +// 등록했는데, Electron 은 이미 등록된 가속기에 대한 재등록을 조용히 거부(false 반환)하므로 +// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 여전히 히스토리 항목을 삽입했다. +// 이제 단축키 소유자(owner)는 하나이고, 한 팝업을 열기 전에 다른 팝업을 닫는다. +// Electron 의존은 포트(PopupWindowPort/ShortcutPort)로 주입한다 — 이 파일은 순수 로직이다. + +export type PopupKind = 'history' | 'command' + +export interface PopupWindowPort { + /** 최신 데이터로 팝업을 띄운다 */ + show(): void + hide(): void + isVisible(): boolean + sendKey(key: string): void +} + +export interface ShortcutPort { + /** 성공하면 true — Electron globalShortcut.register 와 같은 의미 */ + register(accelerator: string, callback: () => void): boolean + unregister(accelerator: string): void +} + +export interface PopupNavigationDeps { + windows: Record + shortcuts: ShortcutPort + /** 히스토리 항목 텍스트를 포커스된 앱에 삽입한다 */ + insertText(text: string): Promise + /** 명령어 선택(빈 id 는 선택 해제)을 설정에 반영하고 UI 에 알린다 */ + applyCommandSelection(selection: { id: string; name: string }): void + /** 지연 실행 — 기본 setTimeout */ + schedule?(fn: () => void, ms: number): void + warn?(message: string): void +} + +/** 팝업 키 내비게이션 가속기 → 팝업에 보낼 키 이름 */ +export const POPUP_NAV_KEYS: ReadonlyArray<{ accel: string; key: string }> = [ + { accel: 'Up', key: 'ArrowUp' }, + { accel: 'Down', key: 'ArrowDown' }, + { accel: 'Return', key: 'Enter' }, + { accel: 'Escape', key: 'Escape' }, + { accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' }, + { accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' }, + { accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, +] + +/** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */ +export const HISTORY_INSERT_DELAY_MS = 200 + +export class PopupNavigationController { + private _owner: PopupKind | null = null + private _keysRegistered = false + + constructor(private readonly _deps: PopupNavigationDeps) {} + + /** 현재 내비게이션 키를 받는 팝업 */ + get owner(): PopupKind | null { + return this._owner + } + + toggle(kind: PopupKind): void { + if (this._deps.windows[kind].isVisible()) { + this.dismiss(kind) + return + } + this.show(kind) + } + + /** 다른 팝업을 닫고 이 팝업을 띄운 뒤 내비게이션 키를 이 팝업에 묶는다. */ + show(kind: PopupKind): void { + const other: PopupKind = kind === 'history' ? 'command' : 'history' + if (this._deps.windows[other].isVisible()) { + this._deps.windows[other].hide() + } + this._deps.windows[kind].show() + this._owner = kind + this._registerKeys() + } + + /** 팝업을 닫는다. 키는 그 팝업이 소유자일 때만 해제한다(다른 팝업의 키를 뺏지 않게). */ + dismiss(kind: PopupKind): void { + this._deps.windows[kind].hide() + if (this._owner === kind) { + this._owner = null + this._unregisterKeys() + } + } + + /** 히스토리 항목 선택 → 팝업을 닫고 잠시 뒤 삽입 */ + selectHistoryItem(text: string): void { + this.dismiss('history') + const schedule = this._deps.schedule ?? ((fn: () => void, ms: number) => { setTimeout(fn, ms) }) + schedule(() => { + this._deps.insertText(text).catch((error: unknown) => { + this._warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`) + }) + }, HISTORY_INSERT_DELAY_MS) + } + + /** 명령어 선택 → 팝업을 닫고 선택을 반영 */ + selectCommand(selection: { id: string; name: string }): void { + this.dismiss('command') + this._deps.applyCommandSelection(selection) + } + + dispose(): void { + this._owner = null + this._unregisterKeys() + } + + private _registerKeys(): void { + if (this._keysRegistered) return + this._keysRegistered = true + for (const { accel, key } of POPUP_NAV_KEYS) { + try { + // 콜백은 등록 시점이 아니라 "지금의 소유자"에게 보낸다 + const ok = this._deps.shortcuts.register(accel, () => this._routeKey(key)) + if (!ok) this._warn(`Popup nav key not registered (held elsewhere): ${accel}`) + } catch { + // 일부 키는 globalShortcut으로 등록 불가할 수 있음 + this._warn(`Popup nav key registration threw: ${accel}`) + } + } + } + + private _unregisterKeys(): void { + if (!this._keysRegistered) return + this._keysRegistered = false + for (const { accel } of POPUP_NAV_KEYS) { + try { + this._deps.shortcuts.unregister(accel) + } catch { + // 이미 해제된 경우 무시 + } + } + } + + private _routeKey(key: string): void { + const owner = this._owner + if (!owner) return + const win = this._deps.windows[owner] + if (win.isVisible()) { + win.sendKey(key) + return + } + // 팝업이 다른 경로로 사라졌다 — 전역 키(Enter/방향키/숫자)를 계속 붙잡지 않게 놓는다 + this._owner = null + this._unregisterKeys() + } + + private _warn(message: string): void { + this._deps.warn?.(message) + } +} diff --git a/apps/desktop/src/main/windows/recording-tip-presenter.ts b/apps/desktop/src/main/windows/recording-tip-presenter.ts new file mode 100644 index 0000000..9367f1b --- /dev/null +++ b/apps/desktop/src/main/windows/recording-tip-presenter.ts @@ -0,0 +1,133 @@ +// src/main/windows/recording-tip-presenter.ts +// RecordingTip(녹음 팁)·결과 팝업 표시 정책의 단일 정본. +// +// 예전엔 VoiceModeService 가 WindowManager 를 직접 부르며 에러 3초 숨김 타이머를 가졌고, +// bootstrap 도 같은 'error' 이벤트에 자기 3초 숨김 타이머(핸들 없음)를 따로 걸었다. +// 그래서 에러 직후 3초 안에 시작한 새 세션의 녹음 팁이 녹음 도중 사라졌고, +// 세션을 막지 않는 'warning'(Ollama 미가용)에도 매번 에러 표시가 떴다. +// 이제 VoiceModeService 는 이벤트만 내보내고, 이 프레젠터가 취소 가능한 타이머 하나로 그린다. + +import { + showRecordingTip, + hideRecordingTip, + updateRecordingTipState, + sendAudioLevelToTip, + sendPartialTranscriptToTip, + showResultPopup, +} from './WindowManager' + +/** 프레젠터가 그리는 대상(창) 포트 — 테스트에서는 가짜로 바꾼다 */ +export interface RecordingTipView { + showRecording(): void + hide(): void + showThinking(): void + showError(message: string): void + sendAudioLevel(level: number): void + sendPartialTranscript(text: string): void + showResult(text: string, durationMs: number): void +} + +export interface RecordingTipTimers { + setTimeout(fn: () => void, ms: number): ReturnType + clearTimeout(handle: ReturnType): void +} + +export interface RecordingTipPresenterOptions { + /** 에러 표시 후 팁을 숨기기까지(ms). 기본 3000 */ + errorHideMs?: number + /** 자동 삽입 실패 시 결과 팝업 표시 시간(ms). 기본 10000 */ + resultPopupMs?: number + timers?: RecordingTipTimers +} + +export type RecordingTipPhase = 'recording' | 'thinking' +export type RecordingTipSeverity = 'error' | 'warning' + +export class RecordingTipPresenter { + private _hideTimer: ReturnType | null = null + private readonly _errorHideMs: number + private readonly _resultPopupMs: number + private readonly _timers: RecordingTipTimers + + constructor( + private readonly _view: RecordingTipView, + options: RecordingTipPresenterOptions = {}, + ) { + this._errorHideMs = options.errorHideMs ?? 3000 + this._resultPopupMs = options.resultPopupMs ?? 10000 + this._timers = options.timers ?? { + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => clearTimeout(handle), + } + } + + /** 새 세션이 시작되면 이전 에러의 숨김 예약을 취소한다 (새 팁을 숨기지 않게) */ + onSessionStarted(): void { + this._cancelHide() + } + + onPhase(phase: RecordingTipPhase): void { + if (phase === 'recording') { + this._cancelHide() + this._view.showRecording() + } else { + this._view.showThinking() + } + } + + onAudioLevel(level: number): void { + this._view.sendAudioLevel(level) + } + + onPartialTranscript(text: string): void { + this._view.sendPartialTranscript(text) + } + + /** + * 에러 표시 후 한 번만 숨김을 예약한다(이전 예약은 교체). + * 'warning' 은 세션을 막지 않는 알림이므로 팁을 건드리지 않는다. + */ + onError(message: string, severity: RecordingTipSeverity = 'error'): void { + if (severity === 'warning') return + this._view.showError(message) + this._cancelHide() + this._hideTimer = this._timers.setTimeout(() => { + this._hideTimer = null + this._view.hide() + }, this._errorHideMs) + } + + /** 세션 완료/취소 — 팁을 즉시 숨긴다 */ + onSessionEnded(): void { + this._cancelHide() + this._view.hide() + } + + onInsertFailed(text: string): void { + this._view.showResult(text, this._resultPopupMs) + } + + dispose(): void { + this._cancelHide() + } + + private _cancelHide(): void { + if (this._hideTimer) { + this._timers.clearTimeout(this._hideTimer) + this._hideTimer = null + } + } +} + +/** WindowManager 기반 실제 창 어댑터 */ +export function createWindowRecordingTipView(): RecordingTipView { + return { + showRecording: () => showRecordingTip('recording'), + hide: () => hideRecordingTip(), + showThinking: () => updateRecordingTipState('thinking'), + showError: (message) => updateRecordingTipState('error', { errorMessage: message }), + sendAudioLevel: (level) => sendAudioLevelToTip(level), + sendPartialTranscript: (text) => sendPartialTranscriptToTip(text), + showResult: (text, durationMs) => showResultPopup(text, durationMs), + } +} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx index e3b2d99..9231eaa 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx @@ -27,6 +27,17 @@ function stripThinkTags(text: string): string { return result.trim() } +/** + * main 이 실어 보낸 sessionId 가 이 패널의 회의인지 확인한다. + * sessionId 가 없는(구버전) 이벤트는 그대로 받아들인다. + */ +function isForSession(payload: object, sessionId: string): boolean { + if ('sessionId' in payload && typeof payload.sessionId === 'string') { + return payload.sessionId === sessionId + } + return true +} + // 타이핑 인디케이터 점 3개 애니메이션 function TypingIndicator(): React.ReactElement { return ( @@ -74,21 +85,38 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re scrollToBottom() }, [messages, streamingContent, scrollToBottom]) - // 이벤트 구독 + // 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다. + // main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다. + useEffect(() => { + setMessages([]) + setStreaming(false) + setStreamingContent('') + setError(null) + isSendingChatRef.current = false + return () => { + // 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게 + void window.electronAPI.meetingChat.cancel() + } + }, [sessionId]) + + // 이벤트 구독 — 다른 회의로 향한 이벤트는 무시한다 useEffect(() => { const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => { + if (!isForSession(data, sessionId)) return setStreaming(true) setStreamingContent((prev) => prev + data.token) }) const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => { - setMessages((prev) => [...prev, msg]) + if (!isForSession(msg, sessionId)) return + setMessages((prev) => [...prev, { role: msg.role, content: msg.content, timestamp: msg.timestamp }]) setStreaming(false) setStreamingContent('') isSendingChatRef.current = false }) const unsubError = window.electronAPI.meetingChat.onError((err) => { + if (!isForSession(err, sessionId)) return setStreaming(false) setStreamingContent('') setError(err.message) @@ -139,7 +167,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re setMessages([]) setStreamingContent('') setStreaming(false) - window.electronAPI.meetingChat.clear({ sessionId }) + window.electronAPI.meetingChat.clear() }, [sessionId]) const handleToggleCollapse = useCallback(() => { diff --git a/apps/desktop/tests/main/services/audio-capture-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/audio-capture-redteam-r1-3.test.ts new file mode 100644 index 0000000..142c8ad --- /dev/null +++ b/apps/desktop/tests/main/services/audio-capture-redteam-r1-3.test.ts @@ -0,0 +1,75 @@ +// tests/main/services/audio-capture-redteam-r1-3.test.ts +// SoX 가 장치를 못 열고 끝났을 때 'error' 리스너가 없어 ERR_UNHANDLED_ERROR 가 나고 +// 뒤이은 'stopped' 가 영영 나가지 않던 버그의 회귀 테스트. + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { EventEmitter } from 'events' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn(() => 'usb-headset-gone'), +})) + +vi.mock('../../../src/main/utils/paths', () => ({ + getSoxPath: () => 'sox', +})) + +class FakeProcess extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + kill = vi.fn() +} + +const spawned = vi.hoisted(() => ({ list: [] as unknown[] })) + +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + spawn: vi.fn(() => { + const proc = new FakeProcess() + spawned.list.push(proc) + return proc + }), + } +}) + +type Module = typeof import('../../../src/main/services/AudioCaptureService') +let mod: Module + +beforeEach(async () => { + vi.resetModules() + spawned.list = [] + mod = await import('../../../src/main/services/AudioCaptureService') + mod.resetAudioCaptureServiceForTests() +}) + +describe('AudioCaptureService — 캡처 프로세스 종료', () => { + it("SoX 가 녹음 중 끝나도 예외 없이 'stopped'(device-lost) 를 알린다", async () => { + const audio = mod.getAudioCaptureService() + const stopped: string[] = [] + audio.on('stopped', ({ reason }) => stopped.push(reason)) + + await audio.start() + expect(audio.state).toBe('capturing') + + const proc = spawned.list[0] as FakeProcess + expect(() => proc.emit('close', 1)).not.toThrow() + + expect(stopped).toEqual(['device-lost']) + expect(audio.state).toBe('error') + }) + + it('실패 뒤에도 다시 시작할 수 있다', async () => { + const audio = mod.getAudioCaptureService() + await audio.start() + ;(spawned.list[0] as FakeProcess).emit('close', 1) + + await audio.start() + expect(audio.state).toBe('capturing') + expect(spawned.list).toHaveLength(2) + }) +}) diff --git a/apps/desktop/tests/main/services/caption-owner-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/caption-owner-redteam-r1-3.test.ts new file mode 100644 index 0000000..7018d25 --- /dev/null +++ b/apps/desktop/tests/main/services/caption-owner-redteam-r1-3.test.ts @@ -0,0 +1,134 @@ +// tests/main/services/caption-owner-redteam-r1-3.test.ts +// CaptionService 세션 계약 회귀 테스트: +// - 회의 모드가 소유한 자막 세션은 사용자 요청(stop('user'))으로 멈추지 않는다 +// - stop() 은 flush 중 확정된 마지막 문장까지 담은 요약을 돌려준다 +// - persistHistory:false 면 'caption' 히스토리를 만들지 않고 session-saved 도 없다 +// - 저장한 경우 요약에 히스토리 행 id 가 실린다 (자동 요약이 그 id 로 찾는다) +// - audioSource 옵션은 전역 설정/다음 세션으로 새지 않는다 + +import { describe, it, expect, beforeEach, vi } from 'vitest' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +const config = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => config.values[key]), + configSet: vi.fn((key: string, value: unknown) => { + config.values[key] = value + }), +})) + +vi.mock('../../../src/main/services/LocalSTTService', () => ({ + getLocalSTTService: () => ({ + initialize: vi.fn(async () => undefined), + ensureAuxModel: vi.fn(async () => undefined), + transcribe: vi.fn(async () => ({ text: '', segments: [], language: 'ko', duration: 0, processingTime: 0 })), + }), +})) + +const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) })) +vi.mock('../../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }), + calculateRMS: () => 0.1, +})) + +vi.mock('../../../src/main/services/SoundEffectService', () => ({ + getSoundEffectService: () => ({ play: vi.fn() }), +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }), +})) + +const history = vi.hoisted(() => ({ create: vi.fn() })) +vi.mock('../../../src/main/services/HistoryService', () => ({ + getHistoryService: () => history, +})) + +// 트랙: flush 하면 말하던 마지막 문장을 확정한다 +vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({ + StreamingCaptionTrack: class { + constructor(private readonly deps: { onFinal: (text: string) => void }) {} + push(): void {} + async tick(): Promise {} + async flush(): Promise { + this.deps.onFinal('정지 직전 마지막 문장') + } + }, +})) + +type CaptionModule = typeof import('../../../src/main/services/CaptionService') +let mod: CaptionModule + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + config.values = { captionAudioSource: 'system' } + history.create.mockReturnValue({ id: 'history-row-1' }) + mod = await import('../../../src/main/services/CaptionService') + mod.resetCaptionServiceForTests() +}) + +describe('CaptionService 세션 소유권', () => { + it('회의가 소유한 세션은 stop(\'user\') 를 거부하고 계속 돈다', async () => { + const caption = mod.getCaptionService() + await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false }) + + expect(await caption.stop('user')).toBe(false) + expect(caption.getState()).toBe('active') + expect(audio.stop).not.toHaveBeenCalled() + + // 소유자와 강제 정지(인자 없음)는 멈출 수 있다 + expect(await caption.stop('meeting')).not.toBe(false) + expect(caption.getState()).toBe('inactive') + }) + + it('사용자 세션은 stop(\'user\') 로 멈춘다', async () => { + const caption = mod.getCaptionService() + await caption.start() + expect(caption.getOwner()).toBe('user') + await caption.stop('user') + expect(caption.getState()).toBe('inactive') + }) +}) + +describe('CaptionService 정지 요약', () => { + it('flush 중 확정된 문장을 요약에 담아 돌려주고, persistHistory:false 면 히스토리를 만들지 않는다', async () => { + const caption = mod.getCaptionService() + const saved = vi.fn() + caption.on('session-saved', saved) + await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false }) + + const summary = await caption.stop('meeting') + + expect(summary).not.toBe(false) + expect(summary && summary.segments.map((s) => s.text)).toEqual(['정지 직전 마지막 문장']) + expect(summary && summary.historyId).toBeNull() + expect(history.create).not.toHaveBeenCalled() + expect(saved).not.toHaveBeenCalled() + }) + + it('저장한 세션은 요약에 히스토리 행 id 를 싣는다', async () => { + const caption = mod.getCaptionService() + const saved = vi.fn() + caption.on('session-saved', saved) + await caption.start({ audioSource: 'mic' }) + + const summary = await caption.stop() + + expect(history.create).toHaveBeenCalledTimes(1) + expect(summary && summary.historyId).toBe('history-row-1') + expect(saved).toHaveBeenCalledWith(expect.objectContaining({ historyId: 'history-row-1' })) + }) + + it('audioSource 옵션은 다음 사용자 세션의 설정으로 새지 않는다', async () => { + const caption = mod.getCaptionService() + caption.setConfig({ audioSource: 'system' }) + await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false }) + await caption.stop() + expect(config.values.captionAudioSource).toBe('system') + expect(caption.getConfig().audioSource).toBe('system') + }) +}) diff --git a/apps/desktop/tests/main/services/llm-gateway.test.ts b/apps/desktop/tests/main/services/llm-gateway.test.ts new file mode 100644 index 0000000..7920caf --- /dev/null +++ b/apps/desktop/tests/main/services/llm-gateway.test.ts @@ -0,0 +1,131 @@ +// tests/main/services/llm-gateway.test.ts +// LLM 백엔드 선택 정책(단일 정본)의 local / online / 불가 / 실패 매트릭스. + +import { describe, it, expect, vi } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' +import { + createRoutedLlmGateway, + type LocalLlmAdapter, + type PremiumLlmAdapter, +} from '../../../src/main/services/llm/LlmGateway' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +async function* tokens(...values: string[]): AsyncGenerator { + for (const v of values) yield v + return values.join('') +} + +function premium(overrides: Partial = {}): PremiumLlmAdapter { + return { + isAvailable: vi.fn(() => true), + processText: vi.fn(async () => 'premium'), + generate: vi.fn(async () => ({ text: 'premium' })), + chatStream: vi.fn(() => tokens('p')), + cancelGeneration: vi.fn(), + ...overrides, + } +} + +function local(overrides: Partial = {}): LocalLlmAdapter { + return { + isAvailable: vi.fn(() => true), + processText: vi.fn(async () => 'local'), + generate: vi.fn(async () => ({ text: 'local', model: 'gemma' })), + chatStream: vi.fn(() => tokens('l')), + cancelGeneration: vi.fn(), + ...overrides, + } +} + +function gateway(backend: string | undefined, p: PremiumLlmAdapter, l: LocalLlmAdapter) { + return createRoutedLlmGateway({ + getBackend: () => backend, + loadPremium: async () => p, + getLocal: () => l, + }) +} + +describe('LlmGateway 라우팅', () => { + it('local 백엔드는 Premium 을 절대 건드리지 않는다', async () => { + const p = premium() + const l = local() + const gw = gateway('local', p, l) + + expect(await gw.processText('t', 'refine')).toBe('local') + expect((await gw.generate('q', { systemPrompt: 'docs' })).text).toBe('local') + expect(p.processText).not.toHaveBeenCalled() + expect(p.generate).not.toHaveBeenCalled() + expect(p.isAvailable).not.toHaveBeenCalled() + }) + + it('online + 가용이면 Premium 을 쓴다', async () => { + const p = premium() + const l = local() + const gw = gateway('online', p, l) + expect(await gw.processText('t', 'custom', undefined, 'sys')).toBe('premium') + expect(p.processText).toHaveBeenCalledWith('t', 'custom', undefined, 'sys') + expect(l.processText).not.toHaveBeenCalled() + }) + + it('online + 불가면 로컬로 폴백하고 사유를 알린다', async () => { + const onFallback = vi.fn() + const gw = gateway('online', premium({ isAvailable: vi.fn(() => false) }), local()) + expect(await gw.processText('t', 'refine', undefined, undefined, { onFallback })).toBe('local') + expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('unavailable')) + }) + + it('online + Premium 호출 실패면 로컬로 폴백한다', async () => { + const onFallback = vi.fn() + const gw = gateway( + 'online', + premium({ generate: vi.fn(async () => { throw new Error('quota') }) }), + local(), + ) + expect((await gw.generate('q', undefined, { onFallback })).text).toBe('local') + expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('quota')) + }) + + it('로컬 실패는 그대로 올린다 (성공으로 위장하지 않는다)', async () => { + const gw = gateway('local', premium(), local({ processText: vi.fn(async () => { throw new Error('ollama down') }) })) + await expect(gw.processText('t', 'refine')).rejects.toThrow('ollama down') + }) + + it('채팅 스트림: Premium 가용이면 Premium, 로컬은 옵션과 signal 을 받는다', async () => { + const p = premium() + const l = local() + const signal = new AbortController().signal + + const online = await gateway('online', p, l).openChatStream([{ role: 'user', content: 'hi' }], { signal }) + expect(online.backend).toBe('premium') + + const offline = await gateway('local', p, l).openChatStream([{ role: 'user', content: 'hi' }], { + signal, + local: { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m' }, + }) + expect(offline.backend).toBe('local') + expect(l.chatStream).toHaveBeenCalledWith( + [{ role: 'user', content: 'hi' }], + { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m', signal }, + ) + }) + + it('채팅 스트림: 로컬이 불가하면 LLMServerUnreachable', async () => { + const gw = gateway('local', premium(), local({ isAvailable: vi.fn(() => false) })) + await expect( + gw.openChatStream([], { signal: new AbortController().signal }), + ).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable }) + }) + + it('cancel 은 로드된 두 어댑터를 모두 취소한다', async () => { + const p = premium() + const l = local() + const gw = gateway('online', p, l) + await gw.processText('t', 'refine') + gw.cancel() + expect(p.cancelGeneration).toHaveBeenCalled() + expect(l.cancelGeneration).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/services/rag-docx-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/rag-docx-redteam-r1-3.test.ts new file mode 100644 index 0000000..7f90e92 --- /dev/null +++ b/apps/desktop/tests/main/services/rag-docx-redteam-r1-3.test.ts @@ -0,0 +1,43 @@ +// tests/main/services/rag-docx-redteam-r1-3.test.ts +// Word 가 만든(deflate 압축) .docx 에서 본문을 뽑는지, ZIP 이 아니면 바이너리 잡음을 +// 색인하지 않고 실패하는지 확인한다. + +import { describe, it, expect } from 'vitest' +import { Document, Packer, Paragraph, TextRun } from 'docx' +import { + extractDocxText, + decodeXmlEntities, + wordXmlToText, + DocxExtractionError, +} from '../../../src/main/services/rag/docx-text' + +async function buildDocx(paragraphs: string[]): Promise { + const doc = new Document({ + sections: [{ children: paragraphs.map((p) => new Paragraph({ children: [new TextRun(p)] })) }], + }) + return Buffer.from(await Packer.toBuffer(doc)) +} + +describe('extractDocxText', () => { + it('압축된 word/document.xml 에서 문단 텍스트를 뽑는다', async () => { + const buffer = await buildDocx(['분기 매출 보고서', 'List 타입을 쓰고 A & B 를 비교한다']) + // 원본 ZIP 바이트에는 본문이 평문으로 보이지 않는다 (예전 정규식이 실패한 이유) + expect(buffer.toString('utf-8')).not.toContain('분기 매출 보고서') + + const text = extractDocxText(buffer) + expect(text).toBe('분기 매출 보고서\nList 타입을 쓰고 A & B 를 비교한다') + }) + + it('ZIP 이 아니면 잡음을 돌려주지 않고 실패한다', () => { + const garbage = Buffer.from('PK\u0003\u0004 this is not really a zip archive at all, just junk bytes', 'utf-8') + expect(() => extractDocxText(garbage)).toThrow(DocxExtractionError) + }) + + it('XML 엔티티와 탭/줄바꿈을 복원한다', () => { + expect(decodeXmlEntities('a & b <c> AB')).toBe('a & b AB') + const xml = + '하나 둘' + + '셋넷' + expect(wordXmlToText(xml)).toBe('하나\t 둘\n셋\n넷') + }) +}) diff --git a/apps/desktop/tests/main/services/screen-context-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/screen-context-redteam-r1-3.test.ts new file mode 100644 index 0000000..b019a96 --- /dev/null +++ b/apps/desktop/tests/main/services/screen-context-redteam-r1-3.test.ts @@ -0,0 +1,70 @@ +// tests/main/services/screen-context-redteam-r1-3.test.ts +// 선택 텍스트 캡처 회귀 테스트: +// - 핫키 수정자(Alt 등)를 쥔 상태면 Ctrl+C 를 보내지 않고 클립보드도 건드리지 않는다 +// - 캡처 후 클립보드는 저장한 모든 형식(text/html/rtf/image)을 한 번에 복원한다 + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { clipboard } from 'electron' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn(() => true), + configSet: vi.fn(), +})) + +const modifiers = vi.hoisted(() => ({ released: true })) +vi.mock('../../../src/main/services/modifier-state', () => ({ + waitForModifiersReleased: vi.fn(async () => modifiers.released), +})) + +const nut = vi.hoisted(() => ({ pressKey: vi.fn(async () => undefined), releaseKey: vi.fn(async () => undefined) })) +vi.mock('@nut-tree-fork/nut-js', () => ({ + keyboard: nut, + Key: { LeftControl: 'LeftControl', C: 'C' }, +})) + +type Module = typeof import('../../../src/main/services/ScreenContextService') +let getScreenContextService: Module['getScreenContextService'] + +// 공용 electron 목에는 clipboard.clear 가 없다 — 이 테스트에서만 채운다 +const clipboardClear = vi.fn() +Object.assign(clipboard, { clear: clipboardClear }) + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + modifiers.released = true + getScreenContextService = (await import('../../../src/main/services/ScreenContextService')).getScreenContextService +}) + +describe('ScreenContextService.captureSelectedText', () => { + it('수정자를 쥐고 있으면 Ctrl+C 를 보내지 않고 클립보드도 비우지 않는다', async () => { + modifiers.released = false + const text = await getScreenContextService().captureSelectedText() + expect(text).toBeNull() + expect(nut.pressKey).not.toHaveBeenCalled() + expect(clipboardClear).not.toHaveBeenCalled() + }) + + it('수정자를 뗀 뒤에는 복사하고, 모든 형식을 한 번에 복원한다', async () => { + const image = { isEmpty: () => false, toPNG: () => Buffer.alloc(1) } + vi.mocked(clipboard.readText).mockReturnValueOnce('원래 텍스트').mockReturnValueOnce('선택한 문장') + vi.mocked(clipboard.readHTML).mockReturnValueOnce('원래') + vi.mocked(clipboard.readRTF).mockReturnValueOnce('{\\rtf1 원래}') + vi.mocked(clipboard.readImage).mockReturnValueOnce(image as unknown as Electron.NativeImage) + + const text = await getScreenContextService().captureSelectedText() + + expect(text).toBe('선택한 문장') + expect(nut.pressKey).toHaveBeenCalledTimes(1) + expect(clipboard.write).toHaveBeenCalledWith({ + text: '원래 텍스트', + html: '원래', + rtf: '{\\rtf1 원래}', + image, + }) + }) +}) diff --git a/apps/desktop/tests/main/services/stt-fallback-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/stt-fallback-redteam-r1-3.test.ts new file mode 100644 index 0000000..a740409 --- /dev/null +++ b/apps/desktop/tests/main/services/stt-fallback-redteam-r1-3.test.ts @@ -0,0 +1,171 @@ +// tests/main/services/stt-fallback-redteam-r1-3.test.ts +// 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트. +// - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다 +// - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다 +// - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음) + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager' +import { + getLocalSTTService, + resetLocalSTTServiceForTests, +} from '../../../src/main/services/LocalSTTService' +import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService' +import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService' + +type LocalInternals = { + _modelReady: boolean + _sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise +} + +function internals(): LocalInternals { + return getLocalSTTService() as unknown as LocalInternals +} + +/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */ +function settleWithin(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)), + ]) +} + +const echo = (buf: Buffer, options?: TranscribeOptions): Promise => + Promise.resolve({ + text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`, + segments: [], + language: options?.language ?? 'ko', + duration: 1, + processingTime: 1, + }) + +describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => { + beforeEach(() => { + initInMemoryConfig() + resetLocalSTTServiceForTests() + vi.restoreAllMocks() + }) + + afterEach(() => { + resetLocalSTTServiceForTests() + resetInMemoryConfig() + vi.restoreAllMocks() + }) + + it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => { + const local = getLocalSTTService() + vi.spyOn(local, 'initialize').mockRejectedValue( + new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'), + ) + + await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({ + code: ErrorCode.STTSidecarSpawnFailed, + }) + }) + + it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => { + const local = getLocalSTTService() + const init = vi.spyOn(local, 'initialize').mockImplementation(async () => { + internals()._modelReady = true + }) + vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo) + + const result = await settleWithin( + local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }), + 1000, + ) + + expect(init).toHaveBeenCalledTimes(1) + expect(result.text).toBe('100:en:hint') + }) + + it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => { + const local = getLocalSTTService() + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(local, 'initialize').mockImplementation(async () => { + await gate + internals()._modelReady = true + }) + vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo) + + const first = local.transcribe(Buffer.alloc(10), { language: 'ko' }) + const second = local.transcribe(Buffer.alloc(20), { language: 'ja' }) + release() + + const [a, b] = await settleWithin(Promise.all([first, second]), 1000) + expect(a.text).toBe('10:ko:-') + expect(b.text).toBe('20:ja:-') + }) +}) + +describe('STTManager — 클라우드 실패 후 로컬 폴백', () => { + beforeEach(() => { + initInMemoryConfig() + resetSTTManagerForTests() + resetLocalSTTServiceForTests() + vi.restoreAllMocks() + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + resetInMemoryConfig() + resetSTTManagerForTests() + resetLocalSTTServiceForTests() + vi.restoreAllMocks() + }) + + function failCloud(): void { + const mgr = getSTTManager() + mgr.setProvider('openai') + mgr.setProviderConfig('openai', { apiKey: 'sk-test' }) + configSet('sttFallbackToLocal', true) + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }), + ) + } + + it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => { + failCloud() + vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue( + new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'), + ) + + const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000) + await expect(outcome).rejects.toBeInstanceOf(Error) + await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') }) + }) + + it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => { + failCloud() + vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise(() => undefined)) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + + const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager') + const pending = getSTTManager().transcribe(Buffer.alloc(32000)) + const assertion = expect(pending).rejects.toBeInstanceOf(Error) + await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1) + await assertion + }) + + it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => { + const mgr = getSTTManager() + const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined) + + mgr.setProvider('openai') + await mgr.ensureReady() + expect(init).not.toHaveBeenCalled() + expect(mgr.supportsPartial()).toBe(false) + expect(mgr.getMissingModel()).toBeNull() + + mgr.setProvider('local') + await mgr.ensureReady() + expect(init).toHaveBeenCalledTimes(1) + expect(mgr.supportsPartial()).toBe(true) + }) +}) diff --git a/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts new file mode 100644 index 0000000..29e35b4 --- /dev/null +++ b/apps/desktop/tests/main/services/update-service-redteam-r1-3.test.ts @@ -0,0 +1,232 @@ +// tests/main/services/update-service-redteam-r1-3.test.ts +// 자동 업데이트 회귀 테스트 (가짜 포트 주입): +// - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영) +// - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다 +// - evaluateUpdateOffer 게이팅 순서 + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + DEFAULT_UPDATE_POLICY, + evaluateUpdateOffer, + shouldPromptForUpdate, + UPDATE_REPROMPT_INTERVAL_MS, + type UpdatePolicy, +} from '../../../src/main/update-policy' +import type { + ConsentAnswer, + PolicySource, + UpdatePrompter, + UpdaterEventHandlers, + UpdaterPort, +} from '../../../src/main/services/UpdateService' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +const store = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => store.values[key]), + configSet: vi.fn((key: string, value: unknown) => { + store.values[key] = value + }), + configGetAll: vi.fn(() => store.values), +})) + +type ServiceModule = typeof import('../../../src/main/services/UpdateService') +let UpdateService: ServiceModule['UpdateService'] + +function policy(overrides: Partial = {}): UpdatePolicy { + return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides } +} + +function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) { + const answers = [...(opts.answers ?? [])] + const policies = [...(opts.policies ?? [policy()])] + let clock = 1_000_000 + let handlers: UpdaterEventHandlers | null = null + const updater: UpdaterPort & { checks: number; downloads: number } = { + checks: 0, + downloads: 0, + checkForUpdates: vi.fn(async () => { + updater.checks++ + }), + downloadUpdate: vi.fn(async () => { + updater.downloads++ + }), + quitAndInstall: vi.fn(), + setChannel: vi.fn(), + setDifferentialDisabled: vi.fn(), + } + const policySource: PolicySource & { loads: number } = { + loads: 0, + load: vi.fn(async () => { + policySource.loads++ + return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0] + }), + } + const consents: string[] = [] + const prompter: UpdatePrompter = { + askConsent: vi.fn(async ({ version }) => { + consents.push(version) + return answers.shift() ?? 'later' + }), + askRestart: vi.fn(async () => 'later' as const), + } + const service = new UpdateService({ + createUpdater: (h) => { + handlers = h + return updater + }, + policySource, + prompter, + currentVersion: () => '1.9.0', + now: () => clock, + }) + service.initialize() + service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출 + return { + service, + updater, + policySource, + consents, + handlers: () => handlers as UpdaterEventHandlers, + advance: (ms: number) => { + clock += ms + }, + } +} + +beforeEach(async () => { + vi.resetModules() + store.values = { updateDeviceId: 'device-1' } + UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService +}) + +describe('UpdateService — 원격 정책 갱신', () => { + it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => { + const h = harness({ policies: [policy(), policy({ killSwitch: true })] }) + + await h.service.checkForUpdates() + expect(h.updater.checks).toBe(1) + + await h.service.checkForUpdates() + expect(h.policySource.loads).toBe(2) + expect(h.updater.checks).toBe(1) + }) + + it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => { + const h = harness({ policies: [policy({ killSwitch: true })] }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual([]) + }) +}) + +describe('UpdateService — 다시 묻기', () => { + it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => { + const h = harness({ answers: ['later', 'later'] }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + await h.service.handleUpdateAvailable({ version: '1.9.2' }) + expect(h.consents).toEqual(['1.9.1', '1.9.2']) + }) + + it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => { + const h = harness({ answers: ['later', 'later'] }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual(['1.9.1']) + + h.advance(UPDATE_REPROMPT_INTERVAL_MS) + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual(['1.9.1', '1.9.1']) + }) + + it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => { + const h = harness({ + answers: ['later', 'download'], + policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })], + }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual(['1.9.1', '1.9.1']) + expect(h.updater.downloads).toBe(1) + }) + + it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => { + const h = harness({ answers: ['download', 'later'] }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.updater.downloads).toBe(1) + + h.handlers().onError('network down') + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual(['1.9.1', '1.9.1']) + }) + + it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => { + const h = harness({ answers: ['skip'] }) + await h.service.checkForUpdates() + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2) + await h.service.handleUpdateAvailable({ version: '1.9.1' }) + expect(h.consents).toEqual(['1.9.1']) + expect(store.values.skippedUpdateVersion).toBe('1.9.1') + }) +}) + +describe('evaluateUpdateOffer 게이팅 순서', () => { + const base = { + channel: 'latest' as const, + currentVersion: '1.9.0', + skippedVersion: null, + deviceId: 'device-1', + } + + it('킬 스위치가 가장 먼저다', () => { + const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' }) + expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' }) + }) + + it('stable 채널은 prerelease 를 거부한다', () => { + const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' }) + expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' }) + }) + + it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => { + const offer = evaluateUpdateOffer({ + ...base, + policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }), + targetVersion: '1.9.1', + skippedVersion: '1.9.1', + }) + expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } }) + }) + + it('건너뛴 버전 / rollout 밖은 무시한다', () => { + expect( + evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }), + ).toMatchObject({ action: 'ignore', reason: 'skipped' }) + expect( + evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }), + ).toMatchObject({ action: 'ignore', reason: 'rollout' }) + }) + + it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => { + const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' }) + expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true }) + }) + + it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => { + const last = { version: '1.9.1', mandatory: false, at: 0 } + expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true) + expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true) + expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true) + expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false) + expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true) + }) +}) diff --git a/apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts b/apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts new file mode 100644 index 0000000..fda12aa --- /dev/null +++ b/apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts @@ -0,0 +1,389 @@ +// tests/main/services/voice-mode-redteam-r1-3.test.ts +// VoiceModeService 세션 런(SessionRun) 회귀 테스트: +// - 취소된 세션의 늦은 STT 결과가 다음 세션에 삽입되지 않는다 +// - 지연 전사 중 두 번째 release 가 받아쓰기를 취소하지 않는다 +// - 오디오 캡처 참조는 세션당 정확히 한 번 놓는다 (start 대기 중 취소 포함) +// - 녹음 중 캡처가 죽으면(장치 분리) 에러로 알린다 +// - 회의 모드가 소유한 자막은 자막 핫키로 끌 수 없다 +// - 표시 계층 이벤트(phase / insert-failed) + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { EventEmitter } from 'events' +import { ErrorCode } from '@d3ro/core/errors' +import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (err: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const result = (text: string): { text: string; segments: never[]; language: string; duration: number; processingTime: number } => ({ + text, + segments: [], + language: 'ko', + duration: 1, + processingTime: 1, +}) + +const mockSTT = vi.hoisted(() => ({ + initialize: vi.fn(), + transcribe: vi.fn(), + transcribePartial: vi.fn(async () => ''), + getModels: vi.fn(() => [ + { id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 }, + ]), + getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })), +})) + +vi.mock('../../../src/main/services/LocalSTTService', () => ({ + getLocalSTTService: () => mockSTT, + resetLocalSTTServiceForTests: () => undefined, +})) + +const audioBus = new EventEmitter() +const mockAudio = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), + on: vi.fn(), + off: vi.fn(), +})) + +vi.mock('../../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => mockAudio, +})) + +const keyBinding = vi.hoisted(() => ({ + handler: null as ((payload: KeyBindingTriggerPayload) => void) | null, +})) + +vi.mock('../../../src/main/services/KeyBindingService', () => ({ + getKeyBindingService: () => ({ + on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => { + keyBinding.handler = fn + }, + off: vi.fn(), + }), +})) + +const config = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => config.values[key]), +})) + +const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() })) +vi.mock('../../../src/main/services/TextInsertService', () => ({ + getTextInsertService: () => mockInsert, +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ + isAvailable: () => true, + processText: vi.fn(async (text: string) => text), + generate: vi.fn(), + chatStream: vi.fn(), + cancelGeneration: vi.fn(), + }), +})) + +const caption = vi.hoisted(() => ({ + state: 'inactive' as string, + stop: vi.fn(), + start: vi.fn(async () => undefined), +})) +vi.mock('../../../src/main/services/CaptionService', () => ({ + getCaptionService: () => ({ + getState: () => caption.state, + stop: caption.stop, + start: caption.start, + }), +})) + +vi.mock('../../../src/main/services/MeetingModeService', () => ({ + getMeetingModeService: () => ({ getState: () => 'recording' }), +})) + +type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService') +let getVoiceModeService: VoiceModeModule['getVoiceModeService'] + +const SPEECH = Buffer.alloc(16000 * 2) // 1초 + +async function flush(times = 5): Promise { + for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r)) +} + +/** Date.now 를 앞으로 당겨 accidental-press(700ms) 판정을 건너뛴다 */ +function advanceClock(ms: number): void { + const base = Date.now() + vi.spyOn(Date, 'now').mockReturnValue(base + ms) +} + +beforeEach(async () => { + vi.restoreAllMocks() + vi.resetModules() + vi.clearAllMocks() + audioBus.removeAllListeners() + config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' } + caption.state = 'inactive' + keyBinding.handler = null + // once 큐가 테스트 사이로 새지 않게 완전히 초기화한다 + for (const fn of [mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, mockAudio.start, mockAudio.stop, mockInsert.insertText, caption.stop]) { + fn.mockReset() + } + mockSTT.initialize.mockResolvedValue(undefined) + mockSTT.transcribe.mockResolvedValue(result('기본 전사')) + mockSTT.transcribePartial.mockResolvedValue('') + mockSTT.getModels.mockReturnValue([ + { id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 }, + ]) + mockAudio.start.mockResolvedValue(undefined) + mockAudio.stop.mockResolvedValue(undefined) + mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => { + audioBus.on(ev, fn) + }) + mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => { + audioBus.off(ev, fn) + }) + mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 }) + const mod = await import('../../../src/main/services/VoiceModeService') + mod.resetVoiceModeServiceForTests() + getVoiceModeService = mod.getVoiceModeService +}) + +describe('세션 런 — 늦게 도착한 결과', () => { + it('취소된 세션 A 의 STT 결과는 새 세션 B 에 삽입되지 않는다', async () => { + const svc = getVoiceModeService() + const sttA = deferred>() + mockSTT.transcribe.mockReturnValueOnce(sttA.promise) + const completed: string[] = [] + svc.on('session-completed', ({ finalText }) => completed.push(finalText)) + + // 세션 A: 녹음 → 정지 → 전사 진행 중 + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + const stopA = svc.stopSession() + await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1)) + + // A 취소 → B 시작 + svc.cancelSession() + await svc.startSession('dictation') + const sessionB = svc.currentSession + expect(sessionB).not.toBeNull() + + // A 의 결과가 늦게 도착 + sttA.resolve(result('세션 A 텍스트')) + await stopA + await flush() + + expect(completed).toEqual([]) + expect(mockInsert.insertText).not.toHaveBeenCalled() + expect(svc.currentSession?.id).toBe(sessionB?.id) + expect(svc.isActive).toBe(true) + }) + + it('취소된 세션의 늦은 STT 실패는 새 세션을 에러로 끝내지 않는다', async () => { + const svc = getVoiceModeService() + const sttA = deferred>() + mockSTT.transcribe.mockReturnValueOnce(sttA.promise) + const errors: number[] = [] + svc.on('error', ({ error }) => errors.push(error.code)) + + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + const stopA = svc.stopSession() + await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1)) + svc.cancelSession() + await svc.startSession('dictation') + + sttA.reject(new Error('sidecar exploded')) + await stopA + await flush() + + expect(errors).toEqual([]) + expect(svc.isActive).toBe(true) + }) +}) + +describe('세션 런 — 정지 멱등성', () => { + it('지연 전사 중 두 번째 정지는 받아쓰기를 취소하지 않는다', async () => { + const init = deferred() + mockSTT.initialize.mockReturnValueOnce(init.promise) + const stt = deferred>() + mockSTT.transcribe.mockReturnValueOnce(stt.promise) + + const svc = getVoiceModeService() + const cancelled: string[] = [] + const completed: string[] = [] + svc.on('session-cancelled', ({ reason }) => cancelled.push(reason)) + svc.on('session-completed', ({ finalText }) => completed.push(finalText)) + + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + await svc.stopSession() // STT 미준비 → 대기 + + init.resolve() // 모델 로딩 완료 → 지연 전사 시작 (버퍼 비움) + await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1)) + + await svc.stopSession() // 두 번째 release + expect(cancelled).toEqual([]) + + stt.resolve(result('살아남은 받아쓰기')) + await vi.waitFor(() => expect(completed).toEqual(['살아남은 받아쓰기'])) + expect(mockInsert.insertText).toHaveBeenCalledWith('살아남은 받아쓰기', undefined) + }) +}) + +describe('세션 런 — 오디오 참조 소유', () => { + it('정지 후 "No speech" 에러가 나도 audio.stop 은 세션당 한 번이다', async () => { + mockSTT.transcribe.mockResolvedValueOnce(result(' ')) + const svc = getVoiceModeService() + const errors: number[] = [] + svc.on('error', ({ error }) => errors.push(error.code)) + + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + await svc.stopSession() + await vi.waitFor(() => expect(errors).toEqual([ErrorCode.STTNoAudioData])) + expect(mockAudio.start).toHaveBeenCalledTimes(1) + expect(mockAudio.stop).toHaveBeenCalledTimes(1) + }) + + it('audio.start 대기 중 취소돼도 늦게 잡힌 참조를 정확히 한 번 놓는다', async () => { + const start = deferred() + mockAudio.start.mockReturnValueOnce(start.promise) + const svc = getVoiceModeService() + + const starting = svc.startSession('dictation') + await vi.waitFor(() => expect(mockAudio.start).toHaveBeenCalledTimes(1)) + svc.cancelSession() + expect(mockAudio.stop).not.toHaveBeenCalled() + + start.resolve() + await starting + await flush() + expect(mockAudio.stop).toHaveBeenCalledTimes(1) + expect(audioBus.listenerCount('audio-data')).toBe(0) + }) + + it('세션이 끝나면 오디오 핸들러가 모두 해제된다 (다음 세션 프레임 중복 없음)', async () => { + const svc = getVoiceModeService() + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + await svc.stopSession() + await vi.waitFor(() => expect(svc.isActive).toBe(false)) + + await svc.startSession('dictation') + expect(audioBus.listenerCount('audio-data')).toBe(1) + expect(audioBus.listenerCount('stopped')).toBe(1) + }) +}) + +describe('캡처 손실', () => { + it('오디오 없이 캡처가 죽으면 AudioCaptureFailed 에러를 낸다', async () => { + const svc = getVoiceModeService() + const errors: number[] = [] + svc.on('error', ({ error }) => errors.push(error.code)) + + await svc.startSession('dictation') + audioBus.emit('stopped', { reason: 'device-lost' }) + await flush() + + expect(errors).toEqual([ErrorCode.AudioCaptureFailed]) + expect(svc.isActive).toBe(false) + // 캡처 쪽이 이미 정리했으므로 다른 소비자의 참조를 깎지 않는다 + expect(mockAudio.stop).not.toHaveBeenCalled() + }) + + it("수동 정지('manual')는 캡처 손실로 보지 않는다", async () => { + const svc = getVoiceModeService() + const errors: number[] = [] + svc.on('error', ({ error }) => errors.push(error.code)) + await svc.startSession('dictation') + audioBus.emit('stopped', { reason: 'manual' }) + await flush() + expect(errors).toEqual([]) + expect(svc.isActive).toBe(true) + }) +}) + +describe('표시 이벤트', () => { + it("모델 확인을 통과한 뒤에만 'recording' phase 를 내보낸다", async () => { + mockSTT.getModels.mockReturnValueOnce([ + { id: 'base', name: 'Base', sizeBytes: 0, downloaded: false, languages: [], accuracy: 2, speed: 4 }, + ]) + const svc = getVoiceModeService() + const phases: string[] = [] + const errors: number[] = [] + svc.on('phase', ({ phase }) => phases.push(phase)) + svc.on('error', ({ error }) => errors.push(error.code)) + + await svc.startSession('dictation') + + expect(phases).toEqual([]) + expect(errors).toEqual([ErrorCode.STTModelNotFound]) + }) + + it("녹음 → 정지에서 'recording' 다음 'thinking' phase 가 나간다", async () => { + const svc = getVoiceModeService() + const phases: string[] = [] + svc.on('phase', ({ phase }) => phases.push(phase)) + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + await svc.stopSession() + expect(phases).toEqual(['recording', 'thinking']) + }) + + it('자동 삽입이 실패하면 insert-failed 를 내보낸다', async () => { + mockInsert.insertText.mockRejectedValueOnce(new Error('clipboard busy')) + const svc = getVoiceModeService() + const failed: string[] = [] + svc.on('insert-failed', ({ text }) => failed.push(text)) + + await svc.startSession('dictation') + audioBus.emit('audio-data', { buffer: SPEECH }) + advanceClock(1000) + await svc.stopSession() + await vi.waitFor(() => expect(failed).toEqual(['기본 전사'])) + }) +}) + +describe('자막 핫키', () => { + it('회의 모드가 소유한 자막 세션은 자막 핫키로 멈추지 않는다', async () => { + caption.state = 'active' + caption.stop.mockResolvedValue(false) + const svc = getVoiceModeService() + svc.connectKeyBindings() + expect(keyBinding.handler).not.toBeNull() + + keyBinding.handler?.({ + actionId: 'caption', + type: 'pressed', + timestamp: Date.now(), + isDoublePress: false, + holdMode: false, + } as KeyBindingTriggerPayload) + // 소유자('user')를 밝혀 요청하므로 CaptionService 가 거부할 수 있다 + await vi.waitFor(() => expect(caption.stop).toHaveBeenCalledWith('user')) + expect(caption.start).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/shutdown-registry.test.ts b/apps/desktop/tests/main/shutdown-registry.test.ts new file mode 100644 index 0000000..81653c3 --- /dev/null +++ b/apps/desktop/tests/main/shutdown-registry.test.ts @@ -0,0 +1,72 @@ +// tests/main/shutdown-registry.test.ts +// 종료 등록부 — 역순 정리, 멱등, 멈춘 항목이 종료를 막지 않음. + +import { describe, it, expect, vi } from 'vitest' +import { ShutdownRegistry } from '../../src/main/shutdown-registry' + +describe('ShutdownRegistry', () => { + it('등록의 역순으로 정리한다', async () => { + const order: string[] = [] + const registry = new ShutdownRegistry() + registry.register({ name: 'a', dispose: () => { order.push('a') } }) + registry.register({ name: 'b', dispose: async () => { order.push('b') } }) + registry.register({ name: 'c', dispose: () => { order.push('c') } }) + + const report = await registry.disposeAll(1000) + expect(order).toEqual(['c', 'b', 'a']) + expect(report.completed).toEqual(['c', 'b', 'a']) + }) + + it('여러 번 불러도 한 번만 정리한다', async () => { + const dispose = vi.fn() + const registry = new ShutdownRegistry() + registry.register({ name: 'x', dispose }) + await Promise.all([registry.disposeAll(1000), registry.disposeAll(1000)]) + await registry.disposeAll(1000) + expect(dispose).toHaveBeenCalledTimes(1) + }) + + it('실패한 항목은 기록하고 나머지를 계속 정리한다', async () => { + const onError = vi.fn() + const later = vi.fn() + const registry = new ShutdownRegistry({ onError }) + registry.register({ name: 'later', dispose: later }) + registry.register({ name: 'throws', dispose: () => { throw new Error('sync boom') } }) + registry.register({ name: 'rejects', dispose: async () => { throw new Error('async boom') } }) + + const report = await registry.disposeAll(1000) + expect(report.failed).toEqual(['rejects', 'throws']) + expect(report.completed).toEqual(['later']) + expect(later).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledTimes(2) + }) + + it('멈춘 항목이 있어도 상한 시간 안에 끝나고, 남은 항목의 dispose 는 걸어 둔다', async () => { + vi.useFakeTimers() + try { + const last = vi.fn() + const registry = new ShutdownRegistry() + registry.register({ name: 'last', dispose: last }) + registry.register({ name: 'hangs', dispose: () => new Promise(() => undefined) }) + + const running = registry.disposeAll(500) + await vi.advanceTimersByTimeAsync(501) + const report = await running + + expect(report.timedOut).toEqual(['hangs', 'last']) + // 기다리진 않지만 동기 구간(프로세스 kill 등)은 실행된다 + expect(last).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('등록 취소한 항목은 정리하지 않는다', async () => { + const dispose = vi.fn() + const registry = new ShutdownRegistry() + const unregister = registry.register({ name: 'x', dispose }) + unregister() + await registry.disposeAll(1000) + expect(dispose).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/windows/popup-navigation.test.ts b/apps/desktop/tests/main/windows/popup-navigation.test.ts new file mode 100644 index 0000000..05fdace --- /dev/null +++ b/apps/desktop/tests/main/windows/popup-navigation.test.ts @@ -0,0 +1,162 @@ +// tests/main/windows/popup-navigation.test.ts +// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 히스토리 항목을 삽입하던 버그의 회귀 테스트. + +import { describe, it, expect, vi } from 'vitest' +import { + PopupNavigationController, + POPUP_NAV_KEYS, + HISTORY_INSERT_DELAY_MS, + type PopupKind, + type PopupWindowPort, +} from '../../../src/main/windows/popup-navigation' + +interface FakeWindow extends PopupWindowPort { + visible: boolean + keys: string[] +} + +function fakeWindow(): FakeWindow { + const win: FakeWindow = { + visible: false, + keys: [], + show: () => { + win.visible = true + }, + hide: () => { + win.visible = false + }, + isVisible: () => win.visible, + sendKey: (key) => { + win.keys.push(key) + }, + } + return win +} + +/** Electron globalShortcut 처럼 이미 등록된 가속기의 재등록은 false 로 거부한다 */ +function fakeShortcuts(): { + registered: Map void> + register: (accel: string, cb: () => void) => boolean + unregister: (accel: string) => void + press: (accel: string) => void +} { + const registered = new Map void>() + return { + registered, + register: (accel, cb) => { + if (registered.has(accel)) return false + registered.set(accel, cb) + return true + }, + unregister: (accel) => { + registered.delete(accel) + }, + press: (accel) => registered.get(accel)?.(), + } +} + +function setup(): { + controller: PopupNavigationController + windows: Record + shortcuts: ReturnType + inserted: string[] + scheduled: Array<{ fn: () => void; ms: number }> + selections: Array<{ id: string; name: string }> +} { + const windows = { history: fakeWindow(), command: fakeWindow() } + const shortcuts = fakeShortcuts() + const inserted: string[] = [] + const scheduled: Array<{ fn: () => void; ms: number }> = [] + const selections: Array<{ id: string; name: string }> = [] + const controller = new PopupNavigationController({ + windows, + shortcuts, + insertText: async (text) => { + inserted.push(text) + }, + applyCommandSelection: (sel) => selections.push(sel), + schedule: (fn, ms) => scheduled.push({ fn, ms }), + }) + return { controller, windows, shortcuts, inserted, scheduled, selections } +} + +describe('PopupNavigationController', () => { + it('히스토리 → 명령어 전환 시 히스토리를 닫고 Enter 는 명령어 팝업으로 간다', () => { + const { controller, windows, shortcuts } = setup() + controller.toggle('history') + controller.toggle('command') + + expect(windows.history.visible).toBe(false) + expect(windows.command.visible).toBe(true) + expect(controller.owner).toBe('command') + + shortcuts.press('Return') + expect(windows.command.keys).toEqual(['Enter']) + expect(windows.history.keys).toEqual([]) + }) + + it('소유자가 아닌 팝업을 닫아도 소유자의 키를 뺏지 않는다', () => { + const { controller, windows, shortcuts } = setup() + controller.show('command') + controller.dismiss('history') + + expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length) + shortcuts.press('Down') + expect(windows.command.keys).toEqual(['ArrowDown']) + }) + + it('소유 팝업을 닫으면 전역 내비게이션 키를 모두 해제한다', () => { + const { controller, shortcuts } = setup() + controller.toggle('history') + expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length) + controller.toggle('history') + expect(shortcuts.registered.size).toBe(0) + expect(controller.owner).toBeNull() + }) + + it('히스토리 선택은 팝업을 닫고 지연 뒤 삽입한다', async () => { + const { controller, windows, inserted, scheduled, shortcuts } = setup() + controller.show('history') + controller.selectHistoryItem('붙여넣을 텍스트') + + expect(windows.history.visible).toBe(false) + expect(shortcuts.registered.size).toBe(0) + expect(scheduled).toHaveLength(1) + expect(scheduled[0].ms).toBe(HISTORY_INSERT_DELAY_MS) + expect(inserted).toEqual([]) + scheduled[0].fn() + await Promise.resolve() + expect(inserted).toEqual(['붙여넣을 텍스트']) + }) + + it('명령어 선택은 팝업을 닫고 선택을 반영한다', () => { + const { controller, windows, selections } = setup() + controller.show('command') + controller.selectCommand({ id: 'cmd-1', name: '번역' }) + expect(windows.command.visible).toBe(false) + expect(selections).toEqual([{ id: 'cmd-1', name: '번역' }]) + }) + + it('팝업이 다른 경로로 사라졌으면 다음 키에서 전역 키를 놓는다', () => { + const { controller, windows, shortcuts } = setup() + controller.show('history') + windows.history.visible = false + shortcuts.press('Return') + expect(shortcuts.registered.size).toBe(0) + expect(windows.history.keys).toEqual([]) + }) + + it('등록 실패(다른 앱이 점유)를 경고한다', () => { + const warn = vi.fn() + const windows = { history: fakeWindow(), command: fakeWindow() } + const controller = new PopupNavigationController({ + windows, + shortcuts: { register: () => false, unregister: () => undefined }, + insertText: async () => undefined, + applyCommandSelection: () => undefined, + warn, + }) + controller.show('history') + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts b/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts new file mode 100644 index 0000000..6c0f06a --- /dev/null +++ b/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts @@ -0,0 +1,94 @@ +// tests/main/windows/recording-tip-presenter.test.ts +// RecordingTip 표시 정책 — 에러 숨김 타이머가 다음 세션의 녹음 팁을 숨기던 버그의 회귀 테스트. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + RecordingTipPresenter, + type RecordingTipView, +} from '../../../src/main/windows/recording-tip-presenter' + +function fakeView(): RecordingTipView & { calls: string[] } { + const calls: string[] = [] + return { + calls, + showRecording: () => calls.push('show'), + hide: () => calls.push('hide'), + showThinking: () => calls.push('thinking'), + showError: (message) => calls.push(`error:${message}`), + sendAudioLevel: (level) => calls.push(`level:${level}`), + sendPartialTranscript: (text) => calls.push(`partial:${text}`), + showResult: (text, ms) => calls.push(`result:${text}:${ms}`), + } +} + +describe('RecordingTipPresenter', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('에러 직후 3초 안에 시작한 새 세션의 팁은 숨겨지지 않는다', () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + + presenter.onError('No speech detected') + vi.advanceTimersByTime(1000) + presenter.onSessionStarted() + presenter.onPhase('recording') + vi.advanceTimersByTime(5000) + + expect(view.calls).toEqual(['error:No speech detected', 'show']) + }) + + it('에러는 3초 뒤 한 번 숨긴다', () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + presenter.onError('boom') + vi.advanceTimersByTime(2999) + expect(view.calls).toEqual(['error:boom']) + vi.advanceTimersByTime(1) + expect(view.calls).toEqual(['error:boom', 'hide']) + }) + + it("'warning' 은 팁을 건드리지 않는다 (Ollama 미가용 알림 등)", () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + presenter.onPhase('recording') + presenter.onError('Ollama unavailable', 'warning') + vi.advanceTimersByTime(5000) + expect(view.calls).toEqual(['show']) + }) + + it('연속 에러는 숨김 예약을 하나로 유지한다', () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + presenter.onError('a') + vi.advanceTimersByTime(2000) + presenter.onError('b') + vi.advanceTimersByTime(2000) + expect(view.calls).toEqual(['error:a', 'error:b']) + vi.advanceTimersByTime(1000) + expect(view.calls).toEqual(['error:a', 'error:b', 'hide']) + }) + + it('완료·취소는 즉시 숨기고, 삽입 실패는 결과 팝업을 띄운다', () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + presenter.onPhase('recording') + presenter.onPhase('thinking') + presenter.onSessionEnded() + presenter.onInsertFailed('text') + expect(view.calls).toEqual(['show', 'thinking', 'hide', 'result:text:10000']) + }) + + it('dispose 는 남은 숨김 예약을 지운다', () => { + const view = fakeView() + const presenter = new RecordingTipPresenter(view) + presenter.onError('x') + presenter.dispose() + vi.advanceTimersByTime(5000) + expect(view.calls).toEqual(['error:x']) + }) +}) diff --git a/apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts b/apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts new file mode 100644 index 0000000..2b796e9 --- /dev/null +++ b/apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts @@ -0,0 +1,226 @@ +// tests/red/meeting-mode-redteam-r1-3.test.ts +// MeetingModeService 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM): +// - 정지 직전 문장(자막 flush 로 확정)이 회의 전사에 남는다 +// - 전사 줄은 epoch 이 아니라 회의 시작 기준 [MM:SS] 로 저장된다 +// - 회의는 자기 소유 자막 세션을 시작한다 (마이크 고정, 오버레이·히스토리 저장 없음, 전역 설정 불변) +// - 회의 AI 채팅 히스토리는 회의마다 분리된다 +// - 긴 전사 다듬기가 잘리면 editedTranscript 를 덮어쓰지 않는다 +// - 회의 삭제 시 로컬 녹음 WAV 도 지운다 + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EventEmitter } from 'events' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' +import { eq } from 'drizzle-orm' +import { ErrorCode } from '@d3ro/core/errors' +import type { CaptionSegment } from '@d3ro/core/types' +import { useRedHarness } from './harness' + +const caption = vi.hoisted(() => ({ + bus: null as EventEmitter | null, + state: 'inactive' as string, + startOptions: [] as unknown[], + stopOwners: [] as unknown[], + flushText: '마지막 문장', +})) + +vi.mock('../../src/main/services/CaptionService', async () => { + const { EventEmitter: Emitter } = await import('events') + const bus = new Emitter() + caption.bus = bus + const service = { + getState: () => caption.state, + on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn), + off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn), + start: async (opts: unknown) => { + caption.startOptions.push(opts) + caption.state = 'active' + }, + stop: async (owner?: unknown) => { + caption.stopOwners.push(owner) + if (caption.state !== 'active') return null + // CaptionService.stop 은 말하던 문장을 flush 해 'segment' 로 확정한 뒤 끝난다 + const segment: CaptionSegment = { + id: 'seg-last', + text: caption.flushText, + timestamp: Date.now() + 10_000, + isFinal: true, + } + bus.emit('segment', segment) + caption.state = 'inactive' + return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 } + }, + } + return { + getCaptionService: () => service, + resetCaptionServiceForTests: () => undefined, + } +}) + +vi.mock('../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }), + calculateRMS: () => 0, +})) + +const llm = vi.hoisted(() => ({ + chatCalls: [] as Array>, + generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>), +})) + +vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLocalLLMService: () => ({ + isAvailable: () => true, + async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator { + llm.chatCalls.push(messages) + yield '답변' + return '답변' + }, + generate: (text: string, options?: { maxTokens?: number }) => + llm.generate ? llm.generate(text, options) : Promise.resolve({ text }), + cancelGeneration: vi.fn(), + }), + } +}) + +useRedHarness() + +import { getMeetingModeService } from '../../src/main/services/MeetingModeService' +import { configGet, configSet } from '../../src/main/services/ConfigService' +import { getDatabase } from '../../src/main/db' +import { meetingSessions } from '../../src/main/db/schema' + +function insertSession(id: string, rawTranscript: string): void { + const now = Date.now() + getDatabase() + .insert(meetingSessions) + .values({ + id, + title: id, + status: 'completed', + startedAt: now, + endedAt: now + 1000, + durationMs: 1000, + rawTranscript, + createdAt: now, + updatedAt: now, + }) + .run() +} + +beforeEach(() => { + caption.state = 'inactive' + caption.startOptions = [] + caption.stopOwners = [] + llm.chatCalls = [] + llm.generate = null +}) + +describe('회의 녹음 정지', () => { + it('정지 직전 문장을 잃지 않고, 전사는 회의 시작 기준 시각으로 저장된다', async () => { + configSet('captionAudioSource', 'system') + const meeting = getMeetingModeService() + const { sessionId } = await meeting.startRecording() + + expect(caption.startOptions).toEqual([ + { owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false }, + ]) + // 전역 설정은 건드리지 않는다 + expect(configGet('captionAudioSource')).toBe('system') + + const startedAt = Date.now() + caption.bus?.emit('segment', { + id: 'seg-1', + text: '첫 문장', + timestamp: startedAt + 5_000, + isFinal: true, + } satisfies CaptionSegment) + + await meeting.stopRecording() + expect(caption.stopOwners).toEqual(['meeting']) + + await vi.waitFor(() => { + const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + expect(row?.rawTranscript).toBeTruthy() + }) + const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + const lines = (row?.rawTranscript ?? '').split('\n') + expect(lines).toHaveLength(2) + expect(lines[0]).toMatch(/^\[00:0[45]\] 첫 문장$/) + expect(lines[1]).toMatch(/^\[\d{2}:\d{2}\] 마지막 문장$/) + // epoch 이 새지 않는다 (예: [29841999:45]) + expect(row?.rawTranscript).not.toMatch(/\[\d{4,}:/) + }) +}) + +describe('회의 AI 채팅', () => { + it('회의 A 의 질의응답이 회의 B 의 대화로 새지 않는다', async () => { + insertSession('meeting-a', '[00:01] A 회의 내용') + insertSession('meeting-b', '[00:01] B 회의 내용') + const meeting = getMeetingModeService() + + await meeting.chatWithMeeting('meeting-a', 'A 의 결정은?') + await meeting.chatWithMeeting('meeting-b', 'B 의 결정은?') + + const bMessages = llm.chatCalls[1] + expect(bMessages.some((m) => m.content.includes('A 의 결정은?'))).toBe(false) + expect(bMessages.filter((m) => m.role !== 'system')).toEqual([{ role: 'user', content: 'B 의 결정은?' }]) + expect(meeting.getMeetingChatHistory('meeting-a')).toHaveLength(2) + expect(meeting.getMeetingChatHistory('meeting-b')).toHaveLength(2) + }) +}) + +describe('Auto Polish', () => { + it('LLM 결과가 잘리면 editedTranscript 를 덮어쓰지 않고 실패한다', async () => { + const lines = Array.from({ length: 200 }, (_, i) => { + const mm = String(Math.floor(i / 60)).padStart(2, '0') + const ss = String(i % 60).padStart(2, '0') + return `[${mm}:${ss}] 이것은 회의 발언 ${i}번째 문장입니다` + }) + insertSession('long-meeting', lines.join('\n')) + // 출력 한도에 걸린 것처럼 입력의 앞 절반만 돌려준다 + llm.generate = async (text) => { + const kept = text.split('\n') + return { text: kept.slice(0, Math.ceil(kept.length / 2)).join('\n') } + } + + await expect(getMeetingModeService().polishTranscript('long-meeting')).rejects.toMatchObject({ + code: ErrorCode.MeetingPolishFailed, + }) + const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, 'long-meeting')).get() + expect(row?.editedTranscript ?? null).toBeNull() + }) + + it('긴 전사는 줄 경계 조각으로 나눠 다듬고 이어 붙인다', async () => { + const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i}`) + insertSession('chunked', lines.join('\n')) + const maxTokens: Array = [] + llm.generate = async (text, options) => { + maxTokens.push(options?.maxTokens) + return { text } + } + + const polished = await getMeetingModeService().polishTranscript('chunked') + + expect(maxTokens.length).toBeGreaterThan(1) + expect(maxTokens.every((n) => n !== undefined && n <= 4096)).toBe(true) + expect(polished.split('\n')).toEqual(lines) + }) +}) + +describe('회의 삭제', () => { + it('로컬 녹음 WAV 도 지운다', async () => { + insertSession('with-audio', '[00:01] x') + const audioDir = path.join(app.getPath('userData'), 'meeting-audio') + fs.mkdirSync(audioDir, { recursive: true }) + const wav = path.join(audioDir, 'with-audio.wav') + fs.writeFileSync(wav, Buffer.alloc(64)) + + getMeetingModeService().deleteSession('with-audio') + + await vi.waitFor(() => expect(fs.existsSync(wav)).toBe(false)) + }) +}) diff --git a/apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts b/apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts new file mode 100644 index 0000000..f73ba8f --- /dev/null +++ b/apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts @@ -0,0 +1,32 @@ +// tests/red/meeting-summary-redteam-r1-3.test.ts +// 자막 세션 자동 요약이 자막 세션 id 로 히스토리를 찾아 항상 실패하던 버그의 회귀 테스트. + +import { describe, it, expect, vi } from 'vitest' +import { useRedHarness, historyInput } from './harness' + +useRedHarness() + +import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService' +import { getHistoryService } from '../../src/main/services/HistoryService' + +describe('MeetingSummaryService.onCaptionSessionSaved', () => { + it('자막 세션 요약에 실린 historyId 로 히스토리 행을 찾아 요약한다', async () => { + const text = '오늘 회의에서는 다음 분기 출시 일정과 담당자를 정했습니다. '.repeat(3) + const entry = getHistoryService().create(historyInput({ originalText: text, mode: 'caption' })) + const service = getMeetingSummaryService() + const summarize = vi.spyOn(service, 'summarize').mockResolvedValue( + undefined as unknown as Awaited>, + ) + + await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: entry.id }) + + expect(summarize).toHaveBeenCalledWith(entry.id) + }) + + it('저장되지 않은 세션(historyId 없음)은 조용히 건너뛴다', async () => { + const service = getMeetingSummaryService() + const summarize = vi.spyOn(service, 'summarize') + await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: null }) + expect(summarize).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/__tests__/meeting-markdown.test.ts b/packages/core/__tests__/meeting-markdown.test.ts new file mode 100644 index 0000000..6053f99 --- /dev/null +++ b/packages/core/__tests__/meeting-markdown.test.ts @@ -0,0 +1,99 @@ +// 회의 내보내기 마크다운 렌더러(core) 회귀 테스트. +// 예전 정규식 변환은 이스케이프가 없어 `List`·`` 가 PDF에서 사라졌고, +// 한 줄짜리 줄들이 한 문단으로 붙었으며, 표가 파이프 그대로 찍혔다. +import { describe, expect, it } from 'vitest' +import { Paragraph, Table } from 'docx' +import { + escapeHtml, + markdownToSimpleHtml, + parseMarkdownBlocks, + renderMeetingPdfHtml, +} from '../src/utils/meeting-markdown' +import { blocksToDocxElements, markdownToDocx } from '../src/utils/markdown-to-docx' + +describe('parseMarkdownBlocks', () => { + it('제목·불릿·체크박스(두 상태)·표·문단·구분선을 나눈다', () => { + const md = [ + '## 결정', + '- 타입을 List 으로 변경', + '- [ ] 문서화', + '- [x] 배포', + '', + '담당: ', + '줄1', + '---', + '| 시간 | 내용 |', + '|---|---|', + '| 00:01 | 시작 |', + ].join('\n') + expect(parseMarkdownBlocks(md)).toEqual([ + { type: 'heading', level: 2, text: '결정' }, + { type: 'bullet', text: '타입을 List 으로 변경' }, + { type: 'checkbox', checked: false, text: '문서화' }, + { type: 'checkbox', checked: true, text: '배포' }, + { type: 'paragraph', lines: ['담당: ', '줄1'] }, + { type: 'rule' }, + { type: 'table', header: ['시간', '내용'], rows: [['00:01', '시작']] }, + ]) + }) + + it('빈 입력은 빈 목록', () => { + expect(parseMarkdownBlocks('')).toEqual([]) + expect(parseMarkdownBlocks(null)).toEqual([]) + }) +}) + +describe('markdownToSimpleHtml', () => { + it('꺾쇠·앰퍼샌드·따옴표를 이스케이프해 PDF 에서 사라지지 않게 한다', () => { + const html = markdownToSimpleHtml('- List & "q" ') + expect(html).toContain('List<String> & "q" <script>') + expect(html).not.toContain('