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),
}
}