refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes

This commit is contained in:
Yun Chan 2026-09-28 00:53:43 +09:00
parent 0adedf5e7b
commit 83cf9133ac
39 changed files with 4787 additions and 1061 deletions

View file

@ -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<void> {
// 종료 훅을 가장 먼저 건다 — 초기화 도중의 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<void> {
}
}
/**
* 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다.
* (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다)
*/
function registerCoreShutdownSteps(): void {
const registry = getShutdownRegistry()
registry.register({ name: 'local-llm', dispose: () => disposeLocalLLMService() })
registry.register({ name: 'local-stt', dispose: () => disposeLocalSTTServiceIfStarted() })
}
async function initLogger(): Promise<void> {
initLoggerService()
}
@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise<void> {
})
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<void> {
async function initAutoUpdate(): Promise<void> {
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<void> {
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<Record<string, unknown>>)
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<Record<string, unknown>>)
},
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<Record<string, unknown>>, 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<Record<string, unknown>>, 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<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, unknown>>)
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')
})
}

View file

@ -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()
})
})
}

View file

@ -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 }

View file

@ -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<void> {
async start(options: CaptionStartOptions = {}): Promise<void> {
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<void> {
/**
* 자막 세션을 멈추고 요약을 돌려준다(말하던 마지막 문장 flush 포함).
*
* @param owner 생략하면 강제 정지(앱 종료·로그아웃·회의 강제 초기화).
* 지정하면 그 소유자의 세션일 때만 멈추고, 아니면 false 를 돌려준다.
* @returns 세션 요약(세그먼트가 없으면 null), 다른 소유자라 거부했으면 false
*/
async stop(owner?: CaptionOwner): Promise<CaptionSessionResult | null | false> {
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
}
// ── 내부: 상태 전이 ──

View file

@ -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<void> } | 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<void>): Promise<void> {
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<void> {
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<TranscriptionResult>((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<void> {
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<void>((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<void> {
@ -1172,4 +1162,13 @@ export function resetLocalSTTServiceForTests(): void {
instance = null
}
/**
* 앱 종료용: 이미 만들어진 인스턴스만 정리한다(사이드카 /shutdown → kill).
* 한 번도 쓰지 않았으면 종료 때문에 인스턴스를 새로 만들지 않는다.
*/
export async function disposeLocalSTTServiceIfStarted(): Promise<void> {
if (!instance) return
await instance.dispose()
}
export { LocalSTTService, STTState }

View file

@ -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<string, string>
}
/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */
async function meetingAudioFilePath(sessionId: string): Promise<string> {
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<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
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<void> {
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<string, string>
}> {
private async _resolveMeetingLlm(): Promise<MeetingLlm> {
const backend = configGet('llmBackend')
if (backend === 'online') {
try {
@ -871,24 +907,7 @@ class MeetingModeService extends EventEmitter {
break
}
case 'pdf': {
const html = `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 40px; color: #222; }
h1 { font-size: 20px; border-bottom: 1px solid #ddd; padding-bottom: 8px; }
h2 { font-size: 16px; margin-top: 20px; }
p { line-height: 1.6; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${row.title}</h1>
${markdownToSimpleHtml(row.content)}
</body>
</html>`
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<string> {
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)
: '<p>회의록이 생성되지 않았습니다.</p>'
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
h2 { font-size: 16px; color: #444; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
th { background: #f5f5f5; font-weight: 600; }
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${session.title ?? '무제 회의'}</h1>
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
${minutesHtml}
</body>
</html>`
return renderMeetingPdfHtml({
title: session.title ?? '무제 회의',
meta: `일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분`,
markdown: session.minutesMarkdown,
emptyMessage: '회의록이 생성되지 않았습니다.',
})
}
}

View file

@ -187,17 +187,24 @@ class MeetingSummaryService extends EventEmitter {
/**
* CaptionService 세션 종료 시 자동 요약 (옵션)
*/
async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise<void> {
// caption 모드 히스토리에서 해당 세션 찾기
async onCaptionSessionSaved(sessionSummary: { sessionId: string; historyId?: string | null }): Promise<void> {
// 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
}

View file

@ -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[^>]*>([^<]*)<\/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')
}
}

View file

@ -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<string | null> {
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<void> {

View file

@ -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<unknown>
downloadUpdate(): Promise<unknown>
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void
setChannel(channel: UpdateChannel, allowPrerelease: boolean): void
setDifferentialDisabled(disabled: boolean): void
}
/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */
export interface PolicySource {
load(): Promise<UpdatePolicy>
}
export type ConsentAnswer = 'download' | 'later' | 'skip'
export type RestartAnswer = 'restart' | 'later'
export interface UpdatePrompter {
askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise<ConsentAnswer>
askRestart(version: string): Promise<RestartAnswer>
}
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<UpdateServiceDeps> = {}) {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<UpdatePolicy> {
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<number> => {
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<ConsentAnswer> {
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<RestartAnswer> {
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

View file

@ -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<string, string>
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 {

File diff suppressed because it is too large Load diff

View file

@ -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<string, string>
backend: LlmBackendKind
}
export interface LlmGateway {
/** 액션 기반 텍스트 처리 (받아쓰기 후처리, 체인 단계). Premium 실패 시 로컬로 폴백한다. */
processText(
text: string,
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
hooks?: LlmCallHooks,
): Promise<string>
/** 자유 생성 (RAG 답변 등). Premium 실패 시 로컬로 폴백한다. */
generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise<LlmGenerateResult>
/**
* 스트리밍 대화. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다(부분 응답 이중 출력 방지).
* 로컬이 가용하지 않으면 LLMServerUnreachable 을 던진다.
*/
openChatStream(
messages: LlmChatMessage[],
options: { signal: AbortSignal; local?: LocalChatStreamOptions },
hooks?: LlmCallHooks,
): Promise<LlmChatStreamHandle>
/** 두 어댑터의 진행 중 생성을 모두 취소한다. */
cancel(): void
}
// ── 어댑터 계약 (기존 Premium/Local 서비스가 구조적으로 만족) ─────
export interface PremiumLlmAdapter {
isAvailable(): boolean
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string }>
chatStream(messages: LlmChatMessage[], options?: { temperature?: number }): AsyncGenerator<string, string>
cancelGeneration(): void
}
export interface LocalLlmAdapter {
isAvailable(): boolean
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string; model?: string }>
chatStream(
messages: LlmChatMessage[],
options?: LocalChatStreamOptions & { signal?: AbortSignal },
): AsyncGenerator<string, string>
cancelGeneration(): void
}
export interface LlmGatewayDeps {
/** 사용자가 고른 백엔드 ('online' 만 Premium 을 의미한다) */
getBackend: () => string | null | undefined
/** Premium 어댑터 로드 — 지연 import(순환 의존·초기화 비용 회피) */
loadPremium: () => Promise<PremiumLlmAdapter>
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<PremiumLlmAdapter | null> {
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<string> {
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<LlmGenerateResult> {
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<LlmChatStreamHandle> {
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
}

View file

@ -0,0 +1,116 @@
// src/main/services/rag/docx-text.ts
// .docx(ZIP) 에서 본문 텍스트를 꺼낸다 — 순수 JS + zlib, 외부 의존 없음.
//
// 예전엔 ZIP 바이트를 그대로 UTF-8 문자열로 보고 <w:t> 정규식을 돌렸다. 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<string, string> = {
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 = /<w:p[\s>][\s\S]*?<\/w:p>|<w:p\/>/g
for (const match of xml.match(paragraphPattern) ?? []) {
const runs: string[] = []
const tokenPattern = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g
let token: RegExpExecArray | null
while ((token = tokenPattern.exec(match)) !== null) {
if (token[1] !== undefined) runs.push(decodeXmlEntities(token[1]))
else if (token[0] === '<w:tab/>') 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
}

View file

@ -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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | null = null
const timeout = new Promise<never>((_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<STTProviderType, ISTTDriver>()
constructor() {
@ -210,7 +228,7 @@ export class STTManager extends EventEmitter {
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
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<TranscriptionResult> {
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<void> {
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에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.

View file

@ -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<void>
/**
* 녹음 시작 전에 확인할 수 있는 "필수 모델 미설치" 상태.
* 설치되지 않은 모델 id를 돌려주고, 문제가 없으면 null.
*/
getMissingModel(): string | null
/** 실시간 미리보기(부분 전사)를 지원하는지 — 클라우드 공급자는 요청 비용 때문에 지원하지 않는다. */
supportsPartial(): boolean
transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult>
transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise<string>
}

View file

@ -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> | 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<ShutdownReport> | 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<ShutdownReport> {
if (!this._running) this._running = this._run(timeoutMs)
return this._running
}
private async _run(timeoutMs: number): Promise<ShutdownReport> {
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<void>
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<typeof setTimeout> | null = null
const timeout = new Promise<typeof TIMED_OUT>((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
}

View file

@ -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
}

View file

@ -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<PopupKind, PopupWindowPort>
shortcuts: ShortcutPort
/** 히스토리 항목 텍스트를 포커스된 앱에 삽입한다 */
insertText(text: string): Promise<void>
/** 명령어 선택(빈 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)
}
}

View file

@ -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<typeof setTimeout>
clearTimeout(handle: ReturnType<typeof setTimeout>): 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<typeof setTimeout> | 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),
}
}

View file

@ -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(() => {

View file

@ -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<typeof import('child_process')>()
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)
})
})

View file

@ -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<string, unknown> }))
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<void> {}
async flush(): Promise<void> {
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')
})
})

View file

@ -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<string, string> {
for (const v of values) yield v
return values.join('')
}
function premium(overrides: Partial<PremiumLlmAdapter> = {}): 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> = {}): 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()
})
})

View file

@ -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<Buffer> {
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<String> 타입을 쓰고 A & B 를 비교한다'])
// 원본 ZIP 바이트에는 본문이 평문으로 보이지 않는다 (예전 정규식이 실패한 이유)
expect(buffer.toString('utf-8')).not.toContain('분기 매출 보고서')
const text = extractDocxText(buffer)
expect(text).toBe('분기 매출 보고서\nList<String> 타입을 쓰고 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 &amp; b &lt;c&gt; &#65;&#x42;')).toBe('a & b <c> AB')
const xml =
'<w:body><w:p><w:r><w:t>하나</w:t><w:tab/><w:t xml:space="preserve"> 둘</w:t></w:r></w:p>' +
'<w:p><w:pPr><w:pStyle w:val="x"/></w:pPr><w:r><w:t>셋</w:t><w:br/><w:t>넷</w:t></w:r></w:p><w:p/></w:body>'
expect(wordXmlToText(xml)).toBe('하나\t 둘\n셋\n넷')
})
})

View file

@ -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('<b>원래</b>')
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: '<b>원래</b>',
rtf: '{\\rtf1 원래}',
image,
})
})
})

View file

@ -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<TranscriptionResult>
}
function internals(): LocalInternals {
return getLocalSTTService() as unknown as LocalInternals
}
/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */
function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)),
])
}
const echo = (buf: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> =>
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<void>((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<never>(() => 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)
})
})

View file

@ -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<string, unknown> }))
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> = {}): 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)
})
})

View file

@ -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<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (err: unknown) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (err: unknown) => void
const promise = new Promise<T>((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<string, unknown> }))
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<void> {
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<ReturnType<typeof result>>()
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<ReturnType<typeof result>>()
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<void>()
mockSTT.initialize.mockReturnValueOnce(init.promise)
const stt = deferred<ReturnType<typeof result>>()
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<void>()
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()
})
})

View file

@ -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<void>(() => 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()
})
})

View file

@ -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<string, () => void>
register: (accel: string, cb: () => void) => boolean
unregister: (accel: string) => void
press: (accel: string) => void
} {
const registered = new Map<string, () => 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<PopupKind, FakeWindow>
shortcuts: ReturnType<typeof fakeShortcuts>
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()
})
})

View file

@ -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'])
})
})

View file

@ -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<Array<{ role: string; content: string }>>,
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
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<number | undefined> = []
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))
})
})

View file

@ -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<ReturnType<typeof service.summarize>>,
)
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()
})
})