refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue