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 { acquireGlobalInputHook } from './services/global-input-hook'
|
||||||
import { trackModifierKeys } from './services/modifier-state'
|
import { trackModifierKeys } from './services/modifier-state'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
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 { getHistoryService } from './services/HistoryService'
|
||||||
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
||||||
import { getTextInsertService } from './services/TextInsertService'
|
import { getTextInsertService } from './services/TextInsertService'
|
||||||
|
|
@ -34,14 +39,16 @@ import {
|
||||||
hideCommandPopup,
|
hideCommandPopup,
|
||||||
sendKeyToCommandPopup,
|
sendKeyToCommandPopup,
|
||||||
isCommandPopupVisible,
|
isCommandPopupVisible,
|
||||||
hideRecordingTip,
|
|
||||||
updateRecordingTipState,
|
|
||||||
showSuggestionOverlay,
|
showSuggestionOverlay,
|
||||||
updateSuggestionOverlay,
|
updateSuggestionOverlay,
|
||||||
hideSuggestionOverlay,
|
hideSuggestionOverlay,
|
||||||
isSuggestionOverlayVisible,
|
isSuggestionOverlayVisible,
|
||||||
} from './windows/WindowManager'
|
} from './windows/WindowManager'
|
||||||
import { createTray } from './windows/TrayManager'
|
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 { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
|
||||||
import { registerAllIpcHandlers } from './ipc'
|
import { registerAllIpcHandlers } from './ipc'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
|
|
@ -56,6 +63,11 @@ interface BootstrapStep {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bootstrap(): Promise<void> {
|
export async function bootstrap(): Promise<void> {
|
||||||
|
// 종료 훅을 가장 먼저 건다 — 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우고
|
||||||
|
// 등록된 자원을 정리해야 한다. (index.ts 의 뒤늦은 호출은 멱등이라 아무것도 하지 않는다)
|
||||||
|
setupLifecycle()
|
||||||
|
registerCoreShutdownSteps()
|
||||||
|
|
||||||
const steps: BootstrapStep[] = [
|
const steps: BootstrapStep[] = [
|
||||||
{ name: 'logger', critical: false, fn: initLogger },
|
{ name: 'logger', critical: false, fn: initLogger },
|
||||||
{ name: 'config', critical: false, fn: initConfig },
|
{ 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> {
|
async function initLogger(): Promise<void> {
|
||||||
initLoggerService()
|
initLoggerService()
|
||||||
}
|
}
|
||||||
|
|
@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise<void> {
|
||||||
})
|
})
|
||||||
|
|
||||||
telemetry.start()
|
telemetry.start()
|
||||||
|
|
||||||
|
// 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다
|
||||||
|
const registry = getShutdownRegistry()
|
||||||
|
registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() })
|
||||||
|
registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
|
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
|
||||||
|
|
@ -285,89 +312,82 @@ async function initAutoLaunch(): Promise<void> {
|
||||||
|
|
||||||
async function initAutoUpdate(): Promise<void> {
|
async function initAutoUpdate(): Promise<void> {
|
||||||
const { getUpdateService } = await import('./services/UpdateService')
|
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> {
|
async function initPopupWindows(): Promise<void> {
|
||||||
preloadPopupWindows()
|
preloadPopupWindows()
|
||||||
setupHistoryPopupIPC()
|
|
||||||
|
|
||||||
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
|
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
|
||||||
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
|
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
|
||||||
|
|
||||||
|
const popups = createPopupNavigationController()
|
||||||
|
getShutdownRegistry().register({ name: 'popup-navigation', dispose: () => popups.dispose() })
|
||||||
|
|
||||||
// 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다.
|
// 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다.
|
||||||
getKeyBindingService().on('triggered', (payload) => {
|
getKeyBindingService().on('triggered', (payload) => {
|
||||||
if (payload.type !== 'pressed') return
|
if (payload.type !== 'pressed') return
|
||||||
if (payload.actionId === 'history-popup') {
|
if (payload.actionId === 'history-popup') {
|
||||||
toggleHistoryPopup()
|
popups.toggle('history')
|
||||||
} else if (payload.actionId === 'command-popup') {
|
} else if (payload.actionId === 'command-popup') {
|
||||||
toggleCommandPopup()
|
popups.toggle('command')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
setupCommandPopupIPC()
|
setupPopupIPC(popups)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHistoryPopup(): void {
|
/** 팝업 컨트롤러를 Electron 창·전역 단축키·서비스 어댑터로 조립한다. */
|
||||||
if (isHistoryPopupVisible()) {
|
function createPopupNavigationController(): PopupNavigationController {
|
||||||
hideHistoryPopup()
|
return new PopupNavigationController({
|
||||||
unregisterPopupNavKeys()
|
windows: {
|
||||||
return
|
history: {
|
||||||
}
|
show: () => {
|
||||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||||
registerPopupNavKeys()
|
},
|
||||||
}
|
hide: () => hideHistoryPopup(),
|
||||||
|
isVisible: () => isHistoryPopupVisible(),
|
||||||
function toggleCommandPopup(): void {
|
sendKey: (key) => sendKeyToHistoryPopup(key),
|
||||||
if (isCommandPopupVisible()) {
|
},
|
||||||
hideCommandPopup()
|
command: {
|
||||||
unregisterPopupNavKeys()
|
show: () => {
|
||||||
return
|
|
||||||
}
|
|
||||||
const instructions = getCustomInstructionService().getAll()
|
const instructions = getCustomInstructionService().getAll()
|
||||||
const activeId = configGet('activeInstructionId') as string | null
|
const activeId = configGet('activeInstructionId') as string | null
|
||||||
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
|
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
|
||||||
registerPopupNavKeys('command')
|
},
|
||||||
}
|
hide: () => hideCommandPopup(),
|
||||||
|
isVisible: () => isCommandPopupVisible(),
|
||||||
// 히스토리 팝업 키 네비게이션 등록/해제
|
sendKey: (key) => sendKeyToCommandPopup(key),
|
||||||
const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [
|
},
|
||||||
{ accel: 'Up', key: 'ArrowUp' },
|
},
|
||||||
{ accel: 'Down', key: 'ArrowDown' },
|
shortcuts: {
|
||||||
{ accel: 'Return', key: 'Enter' },
|
register: (accelerator, callback) => globalShortcut.register(accelerator, callback),
|
||||||
{ accel: 'Escape', key: 'Escape' },
|
unregister: (accelerator) => globalShortcut.unregister(accelerator),
|
||||||
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
|
},
|
||||||
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
|
insertText: async (text) => {
|
||||||
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
|
await getTextInsertService().insertText(text)
|
||||||
]
|
},
|
||||||
|
applyCommandSelection: ({ id, name }) => {
|
||||||
function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void {
|
if (id) {
|
||||||
for (const { accel, key } of POPUP_NAV_KEYS) {
|
// 명령어 선택 → 활성 명령어로 설정
|
||||||
try {
|
configSet('activeInstructionId', id)
|
||||||
globalShortcut.register(accel, () => {
|
configSet('defaultLLMAction', 'custom')
|
||||||
if (target === 'history' && isHistoryPopupVisible()) {
|
logger.info(`Active command set: ${name} (${id})`)
|
||||||
sendKeyToHistoryPopup(key)
|
} else {
|
||||||
} else if (target === 'command' && isCommandPopupVisible()) {
|
// 선택 해제 → 명령어 없음 (원본 삽입)
|
||||||
sendKeyToCommandPopup(key)
|
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),
|
||||||
})
|
})
|
||||||
} catch {
|
|
||||||
// 일부 키는 globalShortcut으로 등록 불가할 수 있음
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function unregisterPopupNavKeys(): void {
|
|
||||||
for (const { accel } of POPUP_NAV_KEYS) {
|
|
||||||
try {
|
|
||||||
globalShortcut.unregister(accel)
|
|
||||||
} catch {
|
|
||||||
// 이미 해제된 경우 무시
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function initVoiceMode(): Promise<void> {
|
async function initVoiceMode(): Promise<void> {
|
||||||
const voiceMode = getVoiceModeService()
|
const voiceMode = getVoiceModeService()
|
||||||
|
|
@ -375,17 +395,28 @@ async function initVoiceMode(): Promise<void> {
|
||||||
|
|
||||||
const soundEffect = getSoundEffectService()
|
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', () => {
|
voiceMode.on('session-started', () => {
|
||||||
|
tip.onSessionStarted()
|
||||||
soundEffect.play('recording-start')
|
soundEffect.play('recording-start')
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||||
|
tip.onSessionEnded()
|
||||||
soundEffect.play('recording-stop')
|
soundEffect.play('recording-stop')
|
||||||
|
|
||||||
persistCompletedVoiceSessionSafe(
|
persistCompletedVoiceSessionSafe(
|
||||||
(error, failedSession) => {
|
(error) => {
|
||||||
voiceMode.emit('error', { error, session: failedSession })
|
voiceMode.emit('error', { error, session })
|
||||||
},
|
},
|
||||||
session,
|
session,
|
||||||
finalText,
|
finalText,
|
||||||
|
|
@ -398,13 +429,13 @@ async function initVoiceMode(): Promise<void> {
|
||||||
if (reason !== 'too-short') {
|
if (reason !== 'too-short') {
|
||||||
soundEffect.play('cancel')
|
soundEffect.play('cancel')
|
||||||
}
|
}
|
||||||
hideRecordingTip()
|
tip.onSessionEnded()
|
||||||
})
|
})
|
||||||
|
|
||||||
voiceMode.on('error', ({ error }) => {
|
voiceMode.on('error', ({ error, severity }) => {
|
||||||
soundEffect.play('error')
|
// 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
|
||||||
updateRecordingTipState('error', { errorMessage: error.message })
|
if (severity !== 'warning') soundEffect.play('error')
|
||||||
setTimeout(() => hideRecordingTip(), 3000)
|
tip.onError(error.message, severity)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -447,7 +478,7 @@ async function initMeetingSummaryWiring(): Promise<void> {
|
||||||
const captionService = getCaptionService()
|
const captionService = getCaptionService()
|
||||||
const summaryService = getMeetingSummaryService()
|
const summaryService = getMeetingSummaryService()
|
||||||
|
|
||||||
captionService.on('session-saved', (summary: { sessionId: string }) => {
|
captionService.on('session-saved', (summary) => {
|
||||||
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
|
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
|
||||||
import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
|
import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
|
||||||
if (getMeetingModeService().isMeetingModeActive()) {
|
if (getMeetingModeService().isMeetingModeActive()) {
|
||||||
|
|
@ -478,62 +509,31 @@ async function initMeetingMode(): Promise<void> {
|
||||||
getMeetingDocTemplateService()
|
getMeetingDocTemplateService()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
// ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ─────
|
||||||
|
|
||||||
function setupHistoryPopupIPC(): void {
|
function setupPopupIPC(popups: PopupNavigationController): void {
|
||||||
// 히스토리 팝업 열기 (핫키에서 호출)
|
// 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다)
|
||||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
|
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
|
||||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
popups.show('history')
|
||||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 아이템 선택 → 텍스트 삽입
|
// 아이템 선택 → 텍스트 삽입
|
||||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => {
|
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => {
|
||||||
hideHistoryPopup()
|
popups.selectHistoryItem(data.text)
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 팝업 닫기
|
// 팝업 닫기
|
||||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
|
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
|
||||||
hideHistoryPopup()
|
popups.dismiss('history')
|
||||||
unregisterPopupNavKeys()
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// ── CommandPopup IPC 연동 ────────────────────────────
|
|
||||||
|
|
||||||
function setupCommandPopupIPC(): void {
|
|
||||||
// 명령어 선택 → 활성 명령어로 설정
|
// 명령어 선택 → 활성 명령어로 설정
|
||||||
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
|
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
|
||||||
hideCommandPopup()
|
popups.selectCommand({ id: data.id, name: data.name })
|
||||||
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 })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 팝업 닫기
|
// 팝업 닫기
|
||||||
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
|
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
|
||||||
hideCommandPopup()
|
popups.dismiss('command')
|
||||||
unregisterPopupNavKeys()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
// src/main/lifecycle.ts — 종료 시퀀스
|
// src/main/lifecycle.ts — 종료 시퀀스
|
||||||
|
//
|
||||||
|
// 종료 시 정리 대상은 ShutdownRegistry 에 서비스가 스스로 등록한다. lifecycle 은 개별 서비스를
|
||||||
|
// import 하지 않고 등록부만 비운다. setupLifecycle 은 멱등이다 — bootstrap 이 가장 먼저 불러
|
||||||
|
// 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우게 한다(그래야 창의
|
||||||
|
// close-to-tray 가 종료를 가로채지 않는다). index.ts 의 뒤늦은 호출은 아무것도 하지 않는다.
|
||||||
|
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { getLogger } from './services/LoggerService'
|
import { getLogger } from './services/LoggerService'
|
||||||
import { getConfigService } from './services/ConfigService'
|
import { getConfigService } from './services/ConfigService'
|
||||||
import { disposeLocalLLMService } from './services/LocalLLMService'
|
import { getShutdownRegistry } from './shutdown-registry'
|
||||||
|
|
||||||
const logger = getLogger('lifecycle')
|
const logger = getLogger('lifecycle')
|
||||||
|
|
||||||
|
/** 종료 정리 전체 상한 — STT 사이드카 /shutdown 요청(최대 3초) + SIGKILL 여유 */
|
||||||
|
export const SHUTDOWN_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
|
let lifecycleInstalled = false
|
||||||
|
let shutdownComplete = false
|
||||||
|
|
||||||
export function getIsQuitting(): boolean {
|
export function getIsQuitting(): boolean {
|
||||||
return isQuitting
|
return isQuitting
|
||||||
|
|
@ -18,14 +28,30 @@ export function setIsQuitting(value: boolean): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupLifecycle(): void {
|
export function setupLifecycle(): void {
|
||||||
|
if (lifecycleInstalled) return
|
||||||
|
lifecycleInstalled = true
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
logger.info('[lifecycle] before-quit')
|
logger.info('[lifecycle] before-quit')
|
||||||
isQuitting = true
|
isQuitting = true
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', (event) => {
|
||||||
logger.info('[lifecycle] will-quit — flushing config')
|
if (shutdownComplete) return
|
||||||
disposeLocalLLMService()
|
// 첫 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 {
|
try {
|
||||||
const configService = getConfigService()
|
const configService = getConfigService()
|
||||||
if (configService) {
|
if (configService) {
|
||||||
|
|
@ -35,5 +61,8 @@ export function setupLifecycle(): void {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('[lifecycle] config flush failed:', error)
|
logger.warn('[lifecycle] config flush failed:', error)
|
||||||
}
|
}
|
||||||
|
shutdownComplete = true
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@ class AudioCaptureService extends EventEmitter {
|
||||||
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
|
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
|
||||||
private _levelAccumulator: Buffer[] = []
|
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 {
|
get state(): CaptureState {
|
||||||
return this._state
|
return this._state
|
||||||
}
|
}
|
||||||
|
|
@ -560,3 +569,10 @@ export function getAudioCaptureService(): AudioCaptureService {
|
||||||
}
|
}
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetAudioCaptureServiceForTests(): void {
|
||||||
|
if (instance) instance.removeAllListeners()
|
||||||
|
instance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AudioCaptureService }
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,44 @@ const DEFAULT_CONFIG: CaptionConfig = {
|
||||||
audioSource: 'mic',
|
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 {
|
interface CaptionServiceEvents {
|
||||||
'state-changed': (state: CaptionState) => void
|
'state-changed': (state: CaptionState) => void
|
||||||
'segment': (segment: CaptionSegment) => void
|
'segment': (segment: CaptionSegment) => void
|
||||||
'delta': (data: { text: string; isFinal: boolean }) => void
|
'delta': (data: { text: string; isFinal: boolean }) => void
|
||||||
'session-saved': (summary: CaptionSessionSummary) => void
|
'session-saved': (summary: CaptionSessionResult) => void
|
||||||
'error': (error: D3ROError) => void
|
'error': (error: D3ROError) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,6 +114,8 @@ class CaptionService extends EventEmitter {
|
||||||
private _refining = false
|
private _refining = false
|
||||||
|
|
||||||
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
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
|
return this._state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 현재 세션 소유자 (세션이 없으면 null) */
|
||||||
|
getOwner(): CaptionOwner | null {
|
||||||
|
return this._state === 'inactive' ? null : this._session.owner
|
||||||
|
}
|
||||||
|
|
||||||
getConfig(): CaptionConfig {
|
getConfig(): CaptionConfig {
|
||||||
return { ...this._config }
|
return { ...this._config }
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +142,7 @@ class CaptionService extends EventEmitter {
|
||||||
|
|
||||||
// ── 시작 ──
|
// ── 시작 ──
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(options: CaptionStartOptions = {}): Promise<void> {
|
||||||
if (this._disposed) {
|
if (this._disposed) {
|
||||||
throw new D3ROError(
|
throw new D3ROError(
|
||||||
ErrorCode.CaptionStartFailed,
|
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')
|
this._setState('starting')
|
||||||
|
|
||||||
// 오버레이를 즉시 표시 (로딩 상태)
|
// 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다
|
||||||
|
if (this._session.showOverlay) {
|
||||||
showCaptionOverlay()
|
showCaptionOverlay()
|
||||||
sendToCaptionOverlay('caption:config', this._config)
|
sendToCaptionOverlay('caption:config', this._config)
|
||||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
|
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// STT 초기화 (모델 로딩 — 시간 소요)
|
// STT 초기화 (모델 로딩 — 시간 소요)
|
||||||
|
|
@ -142,13 +189,19 @@ class CaptionService extends EventEmitter {
|
||||||
const lang = configGet('sttLanguage') as string | undefined
|
const lang = configGet('sttLanguage') as string | undefined
|
||||||
const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
|
const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
|
||||||
|
|
||||||
// ConfigService에서 저장된 오디오 소스 읽기
|
// 오디오 소스: 호출자 지정값(이번 세션 한정) → 저장된 설정 순.
|
||||||
|
// 호출자 지정값은 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
|
const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string
|
||||||
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
|
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
|
||||||
this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
|
this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
|
||||||
}
|
}
|
||||||
const { audioSource } = this._config
|
audioSource = this._config.audioSource
|
||||||
logger.info(`Caption audioSource: ${audioSource}`)
|
}
|
||||||
|
logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`)
|
||||||
|
|
||||||
// 마이크 캡처 (mic 또는 both)
|
// 마이크 캡처 (mic 또는 both)
|
||||||
if (audioSource === 'mic' || audioSource === 'both') {
|
if (audioSource === 'mic' || audioSource === 'both') {
|
||||||
|
|
@ -190,6 +243,7 @@ class CaptionService extends EventEmitter {
|
||||||
await audioCaptureService.stop().catch(() => {})
|
await audioCaptureService.stop().catch(() => {})
|
||||||
}
|
}
|
||||||
hideCaptionOverlay()
|
hideCaptionOverlay()
|
||||||
|
this._session = { ...DEFAULT_START_OPTIONS }
|
||||||
this._setState('inactive')
|
this._setState('inactive')
|
||||||
const d3roErr =
|
const d3roErr =
|
||||||
err instanceof D3ROError
|
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') {
|
if (this._state !== 'active' && this._state !== 'starting') {
|
||||||
logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`)
|
logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`)
|
||||||
return
|
return null
|
||||||
|
}
|
||||||
|
if (owner && owner !== this._session.owner) {
|
||||||
|
logger.warn(`캡션 중지 거부: 세션 소유자=${this._session.owner}, 요청=${owner}`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this._setState('stopping')
|
this._setState('stopping')
|
||||||
|
|
@ -238,22 +303,25 @@ class CaptionService extends EventEmitter {
|
||||||
// 오버레이 숨김
|
// 오버레이 숨김
|
||||||
hideCaptionOverlay()
|
hideCaptionOverlay()
|
||||||
|
|
||||||
// 세션을 히스토리에 저장
|
// 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다)
|
||||||
const summary = this._saveSession()
|
const persist = this._session.persistHistory
|
||||||
|
const summary = this._saveSession(persist)
|
||||||
|
|
||||||
this._sessionId = null
|
this._sessionId = null
|
||||||
this._sessionStartedAt = null
|
this._sessionStartedAt = null
|
||||||
this._refineQueue = []
|
this._refineQueue = []
|
||||||
this._sessionLanguage = null
|
this._sessionLanguage = null
|
||||||
|
this._session = { ...DEFAULT_START_OPTIONS }
|
||||||
|
|
||||||
this._setState('inactive')
|
this._setState('inactive')
|
||||||
getSoundEffectService().play('recording-stop')
|
getSoundEffectService().play('recording-stop')
|
||||||
logger.info('Live Caption 중지')
|
logger.info('Live Caption 중지')
|
||||||
|
|
||||||
if (summary) {
|
if (summary && persist) {
|
||||||
this.emit('session-saved', summary)
|
this.emit('session-saved', summary)
|
||||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary)
|
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary)
|
||||||
}
|
}
|
||||||
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── dispose ──
|
// ── 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) {
|
if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -421,10 +489,27 @@ class CaptionService extends EventEmitter {
|
||||||
const fullText = this._segments.map((s) => s.text).join(' ')
|
const fullText = this._segments.map((s) => s.text).join(' ')
|
||||||
const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length
|
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 {
|
try {
|
||||||
const historyService = getHistoryService()
|
const entry = getHistoryService().create({
|
||||||
historyService.create({
|
|
||||||
originalText: fullText,
|
originalText: fullText,
|
||||||
polishedText: null,
|
polishedText: null,
|
||||||
focusedApp: null,
|
focusedApp: null,
|
||||||
|
|
@ -447,22 +532,13 @@ class CaptionService extends EventEmitter {
|
||||||
logger.info(
|
logger.info(
|
||||||
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
||||||
)
|
)
|
||||||
|
return entry.id
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`,
|
`캡션 세션 저장 실패: ${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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 내부: 상태 전이 ──
|
// ── 내부: 상태 전이 ──
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,14 @@ class LocalSTTService extends EventEmitter {
|
||||||
private _disposed: boolean = false
|
private _disposed: boolean = false
|
||||||
private _gpuAccelerated: boolean = false
|
private _gpuAccelerated: boolean = false
|
||||||
|
|
||||||
// ── 이중 조건 플러시 (Speakly 패턴) ──
|
// ── 준비 상태 ──
|
||||||
|
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
|
||||||
|
// initialize()가 풀어 주기를 기다렸다. 아무도 initialize()를 부르지 않으면(클라우드 STT 실패 →
|
||||||
|
// 로컬 폴백) 영원히 끝나지 않았고, 두 번째 호출은 첫 호출을 덮어썼으며, 옵션도 버려졌다.
|
||||||
|
// 이제 transcribe()는 진행 중인 초기화를 기다리거나 직접 초기화한 뒤 전사한다.
|
||||||
private _modelReady: boolean = false
|
private _modelReady: boolean = false
|
||||||
private _audioBuffer: Buffer[] = []
|
/** 진행 중인 초기화(크래시 재시작 포함) — transcribe()가 이것을 기다린다 */
|
||||||
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
|
private _initializing: { modelId: string; promise: Promise<void> } | null = null
|
||||||
private _pendingReject: ((error: D3ROError) => void) | null = null
|
|
||||||
// errorEmitted 플래그로 이벤트 중복 방지
|
// errorEmitted 플래그로 이벤트 중복 방지
|
||||||
private _errorEmitted: boolean = false
|
private _errorEmitted: boolean = false
|
||||||
|
|
||||||
|
|
@ -253,15 +256,36 @@ class LocalSTTService extends EventEmitter {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이미 같은 모델이 로딩된 상태면 무시
|
// 이미 같은 모델이 로딩된 상태면 무시 (전사 중이어도 모델은 올라가 있다)
|
||||||
if (
|
if (
|
||||||
this._state === STTState.Ready &&
|
this._modelReady &&
|
||||||
|
(this._state === STTState.Ready || this._state === STTState.Transcribing) &&
|
||||||
this._currentModelId === targetModel
|
this._currentModelId === targetModel
|
||||||
) {
|
) {
|
||||||
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
|
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
|
||||||
return
|
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._setState(STTState.Loading)
|
||||||
this._errorEmitted = false
|
this._errorEmitted = false
|
||||||
|
|
||||||
|
|
@ -275,9 +299,6 @@ class LocalSTTService extends EventEmitter {
|
||||||
this._modelReady = true
|
this._modelReady = true
|
||||||
this._setState(STTState.Ready)
|
this._setState(STTState.Ready)
|
||||||
|
|
||||||
// 이중 조건 플러시 시도
|
|
||||||
this._tryFlushAll()
|
|
||||||
|
|
||||||
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this._setState(STTState.Error)
|
this._setState(STTState.Error)
|
||||||
|
|
@ -313,23 +334,37 @@ class LocalSTTService extends EventEmitter {
|
||||||
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
|
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
|
// 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
|
||||||
|
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
|
||||||
if (!this._modelReady) {
|
if (!this._modelReady) {
|
||||||
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
|
await this._ensureModelReady()
|
||||||
this._audioBuffer.push(audioBuffer)
|
|
||||||
|
|
||||||
return new Promise<TranscriptionResult>((resolve, reject) => {
|
|
||||||
this._pendingResolve = resolve
|
|
||||||
this._pendingReject = reject
|
|
||||||
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
|
|
||||||
this._tryFlushAll()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모델 준비 완료 상태: 직접 전사
|
// 호출자 옵션(language/initialPrompt/modelId)을 그대로 전달한다
|
||||||
return this._sendToSidecar(audioBuffer, options)
|
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와 모델을 미리 데운다.
|
* 앱 시작 시 sidecar와 모델을 미리 데운다.
|
||||||
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
||||||
|
|
@ -542,16 +577,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
|
|
||||||
logger.info('LocalSTTService dispose 시작')
|
logger.info('LocalSTTService dispose 시작')
|
||||||
|
|
||||||
// pending promise를 reject
|
this._initializing = null
|
||||||
if (this._pendingReject) {
|
|
||||||
this._pendingReject(
|
|
||||||
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
|
|
||||||
)
|
|
||||||
this._pendingResolve = null
|
|
||||||
this._pendingReject = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this._audioBuffer = []
|
|
||||||
this._modelReady = false
|
this._modelReady = false
|
||||||
this._auxModelId = null
|
this._auxModelId = null
|
||||||
|
|
||||||
|
|
@ -561,41 +587,6 @@ class LocalSTTService extends EventEmitter {
|
||||||
logger.info('LocalSTTService dispose 완료')
|
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 관리 ──
|
// ── Sidecar 관리 ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -803,7 +794,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
||||||
private _spawnFailureError(
|
private _spawnFailureError(
|
||||||
err: Error,
|
err: Error,
|
||||||
launch: { command: string; source: 'bundled' | 'venv' | 'python' },
|
launch: { command: string; source: 'bundled' | 'provisioned' | 'venv' | 'python' },
|
||||||
): D3ROError {
|
): D3ROError {
|
||||||
const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
||||||
if (!enoent) {
|
if (!enoent) {
|
||||||
|
|
@ -814,7 +805,7 @@ class LocalSTTService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hint =
|
const hint =
|
||||||
launch.source === 'bundled'
|
launch.source === 'bundled' || launch.source === 'provisioned'
|
||||||
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
||||||
: launch.source === 'venv'
|
: launch.source === 'venv'
|
||||||
? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.'
|
? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.'
|
||||||
|
|
@ -1058,31 +1049,30 @@ class LocalSTTService extends EventEmitter {
|
||||||
)
|
)
|
||||||
this._setState(STTState.Error)
|
this._setState(STTState.Error)
|
||||||
this._emitError(err)
|
this._emitError(err)
|
||||||
|
|
||||||
// pending promise reject
|
|
||||||
if (this._pendingReject) {
|
|
||||||
this._pendingReject(err)
|
|
||||||
this._pendingResolve = null
|
|
||||||
this._pendingReject = null
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비동기 재시작
|
// 비동기 재시작
|
||||||
const modelToReload = this._currentModelId
|
const modelToReload = this._currentModelId ?? configGet('sttModelId')
|
||||||
this._currentModelId = null
|
this._currentModelId = null
|
||||||
this._modelReady = false
|
this._modelReady = false
|
||||||
this._auxModelId = null
|
this._auxModelId = null
|
||||||
|
|
||||||
// setTimeout으로 이벤트 루프에 양보
|
// 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가
|
||||||
setTimeout(() => {
|
// 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
|
||||||
if (this._disposed) return
|
const delayMs = 1000 * this._restartCount // 점진적 백오프
|
||||||
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
|
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(
|
logger.error(
|
||||||
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
|
`Sidecar 재시작 실패: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, 1000 * this._restartCount) // 점진적 백오프
|
this._trackInitialization(modelToReload, restart)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _shutdownSidecar(): Promise<void> {
|
private async _shutdownSidecar(): Promise<void> {
|
||||||
|
|
@ -1172,4 +1162,13 @@ export function resetLocalSTTServiceForTests(): void {
|
||||||
instance = null
|
instance = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 종료용: 이미 만들어진 인스턴스만 정리한다(사이드카 /shutdown → kill).
|
||||||
|
* 한 번도 쓰지 않았으면 종료 때문에 인스턴스를 새로 만들지 않는다.
|
||||||
|
*/
|
||||||
|
export async function disposeLocalSTTServiceIfStarted(): Promise<void> {
|
||||||
|
if (!instance) return
|
||||||
|
await instance.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
export { LocalSTTService, STTState }
|
export { LocalSTTService, STTState }
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { BrowserWindow, Notification, dialog } from 'electron'
|
import { BrowserWindow, Notification, dialog } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import { dirname as pathDirname } from 'path'
|
||||||
import { eq, desc, sql } from 'drizzle-orm'
|
import { eq, desc, sql } from 'drizzle-orm'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { configGet, configSet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { getCloudSyncService } from './CloudSyncService'
|
import { getCloudSyncService } from './CloudSyncService'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import { getDatabase } from '../db'
|
import { getDatabase } from '../db'
|
||||||
|
|
@ -16,11 +17,19 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import {
|
import {
|
||||||
buildExportMarkdown,
|
buildExportMarkdown,
|
||||||
markdownToSimpleHtml,
|
renderMeetingPdfHtml,
|
||||||
formatTime,
|
formatTime,
|
||||||
formatDateFile,
|
formatDateFile,
|
||||||
formatDateRange,
|
formatDateRange,
|
||||||
} from '@d3ro/core/utils/meeting-markdown'
|
} 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 { markdownToDocx } from '@d3ro/core/utils/markdown-to-docx'
|
||||||
import type {
|
import type {
|
||||||
MeetingModeState,
|
MeetingModeState,
|
||||||
|
|
@ -42,6 +51,31 @@ import type {
|
||||||
|
|
||||||
const logger = getLogger('MeetingModeService')
|
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
|
let isShowingMeetingSaveDialog = false
|
||||||
|
|
||||||
class MeetingModeService extends EventEmitter {
|
class MeetingModeService extends EventEmitter {
|
||||||
|
|
@ -60,8 +94,8 @@ class MeetingModeService extends EventEmitter {
|
||||||
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
||||||
private _audioBuffersForFile: Buffer[] = []
|
private _audioBuffersForFile: Buffer[] = []
|
||||||
|
|
||||||
// Phase 15: Meeting AI Chat
|
// Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게)
|
||||||
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
private _chatHistories = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
|
||||||
private _chatAbortController: AbortController | null = null
|
private _chatAbortController: AbortController | null = null
|
||||||
|
|
||||||
// ── 공개 접근자 ──
|
// ── 공개 접근자 ──
|
||||||
|
|
@ -101,11 +135,12 @@ class MeetingModeService extends EventEmitter {
|
||||||
try {
|
try {
|
||||||
const { getCaptionService } = await import('./CaptionService')
|
const { getCaptionService } = await import('./CaptionService')
|
||||||
const captionService = getCaptionService()
|
const captionService = getCaptionService()
|
||||||
|
// 강제 정지(소유자 무시) 후 구독을 푼다
|
||||||
|
await captionService.stop().catch(() => null)
|
||||||
if (this._segmentHandler) {
|
if (this._segmentHandler) {
|
||||||
captionService.off('segment', this._segmentHandler)
|
captionService.off('segment', this._segmentHandler)
|
||||||
this._segmentHandler = null
|
this._segmentHandler = null
|
||||||
}
|
}
|
||||||
await captionService.stop().catch(() => {})
|
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -180,25 +215,16 @@ class MeetingModeService extends EventEmitter {
|
||||||
}
|
}
|
||||||
captionService.on('segment', this._segmentHandler)
|
captionService.on('segment', this._segmentHandler)
|
||||||
|
|
||||||
// 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경
|
|
||||||
// (CaptionService.start()가 ConfigService에서 다시 읽기 때문)
|
|
||||||
const prevAudioSource = configGet('captionAudioSource')
|
|
||||||
configSet('captionAudioSource', 'mic')
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await captionService.start()
|
// 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음
|
||||||
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김
|
// (회의 UI 가 자체 패널에 표시), 'caption' 히스토리 중복 저장 없음, 핫키로 끌 수 없음.
|
||||||
const { hideCaptionOverlay } = await import('../windows/WindowManager')
|
await captionService.start({
|
||||||
hideCaptionOverlay()
|
owner: 'meeting',
|
||||||
// ConfigService 원래 값 복원
|
audioSource: 'mic',
|
||||||
if (prevAudioSource) {
|
showOverlay: false,
|
||||||
configSet('captionAudioSource', prevAudioSource)
|
persistHistory: false,
|
||||||
}
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ConfigService 원래 값 복원
|
|
||||||
if (prevAudioSource) {
|
|
||||||
configSet('captionAudioSource', prevAudioSource)
|
|
||||||
}
|
|
||||||
// 시작 실패 시 복원
|
// 시작 실패 시 복원
|
||||||
captionService.off('segment', this._segmentHandler)
|
captionService.off('segment', this._segmentHandler)
|
||||||
this._segmentHandler = null
|
this._segmentHandler = null
|
||||||
|
|
@ -300,16 +326,26 @@ class MeetingModeService extends EventEmitter {
|
||||||
this._audioDataHandler = null
|
this._audioDataHandler = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptionService 종료
|
// CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다)
|
||||||
|
// 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다.
|
||||||
const { getCaptionService } = await import('./CaptionService')
|
const { getCaptionService } = await import('./CaptionService')
|
||||||
const captionService = getCaptionService()
|
const captionService = getCaptionService()
|
||||||
|
|
||||||
|
const captionSummary = await captionService.stop('meeting')
|
||||||
|
|
||||||
if (this._segmentHandler) {
|
if (this._segmentHandler) {
|
||||||
captionService.off('segment', this._segmentHandler)
|
captionService.off('segment', this._segmentHandler)
|
||||||
this._segmentHandler = null
|
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()
|
const db = getDatabase()
|
||||||
|
|
@ -345,9 +381,9 @@ class MeetingModeService extends EventEmitter {
|
||||||
|
|
||||||
// Step 1: 전사 텍스트 합산
|
// Step 1: 전사 텍스트 합산
|
||||||
this._sendProgress(sessionId, 'merging', 40)
|
this._sendProgress(sessionId, 'merging', 40)
|
||||||
const rawTranscript = this._segments
|
// 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다
|
||||||
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`)
|
const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0
|
||||||
.join('\n')
|
const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt)
|
||||||
|
|
||||||
// Step 2: DB 저장 (rawTranscript + completed)
|
// Step 2: DB 저장 (rawTranscript + completed)
|
||||||
this._sendProgress(sessionId, 'saving', 80)
|
this._sendProgress(sessionId, 'saving', 80)
|
||||||
|
|
@ -363,11 +399,8 @@ class MeetingModeService extends EventEmitter {
|
||||||
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
||||||
if (this._audioBuffersForFile.length > 0) {
|
if (this._audioBuffersForFile.length > 0) {
|
||||||
try {
|
try {
|
||||||
const { app } = await import('electron')
|
const wavPath = await meetingAudioFilePath(sessionId)
|
||||||
const path = await import('path')
|
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
|
||||||
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`)
|
|
||||||
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
||||||
getCloudSyncService().pushOne('meeting_audio', sessionId)
|
getCloudSyncService().pushOne('meeting_audio', sessionId)
|
||||||
logger.info(`회의 오디오 저장: ${wavPath}`)
|
logger.info(`회의 오디오 저장: ${wavPath}`)
|
||||||
|
|
@ -485,9 +518,21 @@ class MeetingModeService extends EventEmitter {
|
||||||
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
||||||
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
|
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
|
||||||
getCloudSyncService().pushDelete('meetings', sessionId)
|
getCloudSyncService().pushDelete('meetings', sessionId)
|
||||||
|
this._chatHistories.delete(sessionId)
|
||||||
|
// 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort)
|
||||||
|
void this._deleteMeetingAudio(sessionId)
|
||||||
logger.info(`회의 세션 삭제: ${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 {
|
updateTitle(sessionId: string, title: string): void {
|
||||||
const db = getDatabase()
|
const db = getDatabase()
|
||||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||||
|
|
@ -615,16 +660,7 @@ class MeetingModeService extends EventEmitter {
|
||||||
* 회의 문서/폴리시/채팅/화자추정 LLM.
|
* 회의 문서/폴리시/채팅/화자추정 LLM.
|
||||||
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
|
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
|
||||||
*/
|
*/
|
||||||
private async _resolveMeetingLlm(): Promise<{
|
private async _resolveMeetingLlm(): Promise<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>
|
|
||||||
}> {
|
|
||||||
const backend = configGet('llmBackend')
|
const backend = configGet('llmBackend')
|
||||||
if (backend === 'online') {
|
if (backend === 'online') {
|
||||||
try {
|
try {
|
||||||
|
|
@ -871,24 +907,7 @@ class MeetingModeService extends EventEmitter {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'pdf': {
|
case 'pdf': {
|
||||||
const html = `<!DOCTYPE html>
|
const html = renderMeetingPdfHtml({ title: row.title, markdown: row.content })
|
||||||
<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 win = new BrowserWindow({ show: false, width: 800, height: 600 })
|
const win = new BrowserWindow({ show: false, width: 800, height: 600 })
|
||||||
try {
|
try {
|
||||||
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
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, '전사 텍스트가 없습니다')
|
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
|
||||||
|
|
||||||
const llm = await this._resolveMeetingLlm()
|
const llm = await this._resolveMeetingLlm()
|
||||||
const result = await llm.generate(transcript, {
|
const polished = await this._rewriteTranscriptInChunks(
|
||||||
systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
llm,
|
||||||
temperature: 0.3,
|
transcript,
|
||||||
})
|
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
||||||
|
ErrorCode.MeetingPolishFailed,
|
||||||
|
)
|
||||||
|
|
||||||
db.update(meetingSessions).set({
|
db.update(meetingSessions).set({
|
||||||
editedTranscript: result.text,
|
editedTranscript: polished,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||||
getCloudSyncService().pushOne('meetings', sessionId)
|
getCloudSyncService().pushOne('meetings', sessionId)
|
||||||
|
|
||||||
logger.info(`Auto Polish 완료: sessionId=${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 ──
|
// ── Phase 15: Meeting AI Chat ──
|
||||||
|
|
@ -1044,42 +1100,55 @@ class MeetingModeService extends EventEmitter {
|
||||||
## 회의 전사
|
## 회의 전사
|
||||||
${transcript}`
|
${transcript}`
|
||||||
|
|
||||||
this._chatHistory.push({ role: 'user', content: userMessage })
|
// 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다
|
||||||
|
// (실패·취소 시 짝 없는 사용자 턴이 남지 않게).
|
||||||
const llm = await this._resolveMeetingLlm()
|
const history = this._chatHistories.get(sessionId) ?? []
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system' as const, content: systemPrompt },
|
{ role: 'system' as const, content: systemPrompt },
|
||||||
...this._chatHistory,
|
...history,
|
||||||
|
{ role: 'user' as const, content: userMessage },
|
||||||
]
|
]
|
||||||
|
|
||||||
let fullResponse = ''
|
// 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다
|
||||||
this._chatAbortController = new AbortController()
|
this._chatAbortController?.abort()
|
||||||
const signal = this._chatAbortController.signal
|
const controller = new AbortController()
|
||||||
|
this._chatAbortController = controller
|
||||||
|
const signal = controller.signal
|
||||||
|
|
||||||
|
let fullResponse = ''
|
||||||
try {
|
try {
|
||||||
|
const llm = await this._resolveMeetingLlm()
|
||||||
const generator = llm.chatStream(messages, { temperature: 0.5 })
|
const generator = llm.chatStream(messages, { temperature: 0.5 })
|
||||||
|
|
||||||
for await (const token of generator) {
|
for await (const token of generator) {
|
||||||
if (signal.aborted) break
|
if (signal.aborted) break
|
||||||
fullResponse += token
|
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, {
|
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, {
|
||||||
|
sessionId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: fullResponse,
|
content: fullResponse,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if (!signal.aborted && (err as Error).name !== 'AbortError') {
|
||||||
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, {
|
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, {
|
||||||
|
sessionId,
|
||||||
message: err instanceof Error ? err.message : String(err),
|
message: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this._chatAbortController = null
|
if (this._chatAbortController === controller) this._chatAbortController = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1090,8 +1159,18 @@ ${transcript}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMeetingChatHistory(): void {
|
/** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */
|
||||||
this._chatHistory = []
|
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: 화자 구분 ──
|
// ── Phase 15.5: 화자 구분 ──
|
||||||
|
|
@ -1105,9 +1184,7 @@ ${transcript}`
|
||||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
||||||
|
|
||||||
// 오디오 파일 존재 확인
|
// 오디오 파일 존재 확인
|
||||||
const { app } = await import('electron')
|
const audioPath = await meetingAudioFilePath(sessionId)
|
||||||
const path = await import('path')
|
|
||||||
const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
|
||||||
const hasAudioFile = fs.existsSync(audioPath)
|
const hasAudioFile = fs.existsSync(audioPath)
|
||||||
|
|
||||||
const hfToken = configGet('hfToken') as string
|
const hfToken = configGet('hfToken') as string
|
||||||
|
|
@ -1147,31 +1224,19 @@ ${transcript}`
|
||||||
|
|
||||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 })
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 })
|
||||||
|
|
||||||
// 전사 세그먼트와 화자 세그먼트 매칭
|
// 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다.
|
||||||
|
// 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다.
|
||||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||||
const lines = transcript.split('\n').filter((l) => l.trim())
|
const labeledLines = applySpeakerLabels(
|
||||||
const diarSegments = diarResult.segments
|
parseTranscriptLines(transcript),
|
||||||
|
diarResult.segments,
|
||||||
const labeledLines = lines.map((line) => {
|
(raw) => raw.replace('SPEAKER_', '화자 '),
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
||||||
|
|
||||||
db.update(meetingSessions).set({
|
db.update(meetingSessions).set({
|
||||||
editedTranscript: labeledLines.join('\n'),
|
editedTranscript: formatTranscriptLines(labeledLines),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||||
getCloudSyncService().pushOne('meetings', sessionId)
|
getCloudSyncService().pushOne('meetings', sessionId)
|
||||||
|
|
@ -1205,22 +1270,23 @@ ${transcript}`
|
||||||
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
||||||
: '화자 수는 문맥에서 추정하세요.'
|
: '화자 수는 문맥에서 추정하세요.'
|
||||||
|
|
||||||
const result = await llm.generate(transcript, {
|
const labeled = await this._rewriteTranscriptInChunks(
|
||||||
systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
|
llm,
|
||||||
|
transcript,
|
||||||
|
`다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
|
||||||
${speakerHint}
|
${speakerHint}
|
||||||
각 줄을 [시간] [화자] 내용 형식으로 변환하세요.
|
각 줄을 [시간] [화자] 내용 형식으로 변환하세요.
|
||||||
화자는 "화자 1", "화자 2" 등으로 표시하세요.
|
화자는 "화자 1", "화자 2" 등으로 표시하세요.
|
||||||
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
||||||
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
||||||
temperature: 0.3,
|
ErrorCode.DiarizationFailed,
|
||||||
maxTokens: 8192,
|
)
|
||||||
})
|
|
||||||
|
|
||||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
||||||
|
|
||||||
const db = getDatabase()
|
const db = getDatabase()
|
||||||
db.update(meetingSessions).set({
|
db.update(meetingSessions).set({
|
||||||
editedTranscript: result.text,
|
editedTranscript: labeled,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||||
getCloudSyncService().pushOne('meetings', sessionId)
|
getCloudSyncService().pushOne('meetings', sessionId)
|
||||||
|
|
@ -1228,33 +1294,12 @@ ${speakerHint}
|
||||||
|
|
||||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||||
const minutesHtml = session.minutesMarkdown
|
return renderMeetingPdfHtml({
|
||||||
? markdownToSimpleHtml(session.minutesMarkdown)
|
title: session.title ?? '무제 회의',
|
||||||
: '<p>회의록이 생성되지 않았습니다.</p>'
|
meta: `일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분`,
|
||||||
|
markdown: session.minutesMarkdown,
|
||||||
return `<!DOCTYPE html>
|
emptyMessage: '회의록이 생성되지 않았습니다.',
|
||||||
<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>`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -187,17 +187,24 @@ class MeetingSummaryService extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
* CaptionService 세션 종료 시 자동 요약 (옵션)
|
* CaptionService 세션 종료 시 자동 요약 (옵션)
|
||||||
*/
|
*/
|
||||||
async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise<void> {
|
async onCaptionSessionSaved(sessionSummary: { sessionId: string; historyId?: string | null }): Promise<void> {
|
||||||
// caption 모드 히스토리에서 해당 세션 찾기
|
// 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 db = getDatabase()
|
||||||
const rows = db
|
const rows = db
|
||||||
.select()
|
.select()
|
||||||
.from(history)
|
.from(history)
|
||||||
.where(eq(history.id, sessionSummary.sessionId))
|
.where(eq(history.id, historyId))
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
logger.warn(`Caption session not found in history: ${sessionSummary.sessionId}`)
|
logger.warn(`Caption session not found in history: ${historyId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { asc, eq } from 'drizzle-orm'
|
import { asc, eq } from 'drizzle-orm'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getPremiumLLMService } from './PremiumLLMService'
|
import { getLlmGateway } from './llm/LlmGateway'
|
||||||
|
import { extractDocxText } from './rag/docx-text'
|
||||||
import { getOllamaServerUrl } from './LocalLLMService'
|
import { getOllamaServerUrl } from './LocalLLMService'
|
||||||
import { getDatabase } from '../db'
|
import { getDatabase } from '../db'
|
||||||
import { ragDocuments, ragChunks } from '../db/schema'
|
import { ragDocuments, ragChunks } from '../db/schema'
|
||||||
|
|
@ -291,8 +292,8 @@ class RAGService extends EventEmitter {
|
||||||
Documents:
|
Documents:
|
||||||
${context}`
|
${context}`
|
||||||
|
|
||||||
const llmService = getPremiumLLMService()
|
// 답변 LLM 은 사용자가 고른 백엔드를 따른다(로컬 선택 시 문서 조각을 클라우드로 보내지 않는다)
|
||||||
const result = await llmService.generate(queryText, { systemPrompt })
|
const result = await getLlmGateway().generate(queryText, { systemPrompt })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query: queryText,
|
query: queryText,
|
||||||
|
|
@ -487,18 +488,11 @@ ${context}`
|
||||||
if (fileType === 'docx') {
|
if (fileType === 'docx') {
|
||||||
try {
|
try {
|
||||||
const buffer = await fsp.readFile(filePath)
|
const buffer = await fsp.readFile(filePath)
|
||||||
// DOCX는 ZIP 내 XML — w:t 태그에서 텍스트 추출
|
// DOCX는 ZIP 안의 (deflate 압축된) word/document.xml — 압축을 풀어 문단 텍스트를 뽑는다.
|
||||||
const raw = buffer.toString('utf-8')
|
// 텍스트를 찾지 못하면 바이너리 잡음을 색인하지 않고 실패시킨다.
|
||||||
const matches = raw.match(/<w:t[^>]*>([^<]*)<\/w:t>/g)
|
return extractDocxText(buffer).slice(0, 500000)
|
||||||
if (matches) {
|
} catch (err) {
|
||||||
const text = matches
|
logger.warn(`DOCX parsing failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
.map((m) => m.replace(/<[^>]+>/g, ''))
|
|
||||||
.join(' ')
|
|
||||||
return text.trim()
|
|
||||||
}
|
|
||||||
// XML 태그 제거 폴백
|
|
||||||
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500000)
|
|
||||||
} catch {
|
|
||||||
throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed')
|
throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,17 @@ import { getLogger } from './LoggerService'
|
||||||
import { configGet, configSet } from './ConfigService'
|
import { configGet, configSet } from './ConfigService'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types'
|
import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types'
|
||||||
|
import { waitForModifiersReleased } from './modifier-state'
|
||||||
|
|
||||||
const logger = getLogger('screen-context')
|
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)
|
// nut-js 타입 (lazy import)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
@ -107,7 +115,7 @@ class ScreenContextService {
|
||||||
if (captureSelectedText) {
|
if (captureSelectedText) {
|
||||||
selectedTextAttempted = true
|
selectedTextAttempted = true
|
||||||
try {
|
try {
|
||||||
selectedText = await this._captureSelectedText()
|
selectedText = await this.captureSelectedText()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
|
@ -130,6 +138,20 @@ class ScreenContextService {
|
||||||
return { context, selectedTextAttempted }
|
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 시스템 프롬프트 접두사로 변환한다.
|
* ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다.
|
||||||
* 빈 컨텍스트면 빈 문자열을 반환.
|
* 빈 컨텍스트면 빈 문자열을 반환.
|
||||||
|
|
@ -371,7 +393,9 @@ end try
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 클립보드 복원 (TextInsertService와 동일 패턴).
|
* 클립보드 복원 — 저장해 둔 모든 형식(text/html/rtf/image)을 한 번에 되돌린다.
|
||||||
|
* 예전엔 첫 번째로 있는 형식 하나만 썼기 때문에 서식 있는 복사본이나 텍스트+이미지가
|
||||||
|
* 캡처 한 번에 평문으로 바뀌었다.
|
||||||
*/
|
*/
|
||||||
private _restoreClipboard(snapshot: ClipboardSnapshot): void {
|
private _restoreClipboard(snapshot: ClipboardSnapshot): void {
|
||||||
if (!snapshot.hasContent) {
|
if (!snapshot.hasContent) {
|
||||||
|
|
@ -379,15 +403,12 @@ end try
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.text) {
|
const data: Electron.Data = {}
|
||||||
clipboard.writeText(snapshot.text)
|
if (snapshot.text) data.text = snapshot.text
|
||||||
} else if (snapshot.html) {
|
if (snapshot.html) data.html = snapshot.html
|
||||||
clipboard.writeHTML(snapshot.html)
|
if (snapshot.rtf) data.rtf = snapshot.rtf
|
||||||
} else if (snapshot.rtf) {
|
if (snapshot.image) data.image = snapshot.image
|
||||||
clipboard.writeRTF(snapshot.rtf)
|
clipboard.write(data)
|
||||||
} else if (snapshot.image) {
|
|
||||||
clipboard.writeImage(snapshot.image)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _sleep(ms: number): Promise<void> {
|
private _sleep(ms: number): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
||||||
// 5. staged rollout + 원격 킬 스위치
|
// 5. staged rollout + 원격 킬 스위치
|
||||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||||
|
//
|
||||||
|
// 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가
|
||||||
|
// 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다.
|
||||||
|
// 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다.
|
||||||
|
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
@ -25,13 +29,12 @@ import {
|
||||||
} from '../update-feed'
|
} from '../update-feed'
|
||||||
import {
|
import {
|
||||||
DEFAULT_UPDATE_POLICY,
|
DEFAULT_UPDATE_POLICY,
|
||||||
decideUpdate,
|
evaluateUpdateOffer,
|
||||||
isChannelAcceptable,
|
|
||||||
isWithinRollout,
|
|
||||||
majorOf,
|
|
||||||
parseUpdatePolicy,
|
parseUpdatePolicy,
|
||||||
|
shouldPromptForUpdate,
|
||||||
type UpdateDecision,
|
type UpdateDecision,
|
||||||
type UpdatePolicy,
|
type UpdatePolicy,
|
||||||
|
type UpdatePromptRecord,
|
||||||
} from '../update-policy'
|
} from '../update-policy'
|
||||||
|
|
||||||
const logger = getLogger('UpdateService')
|
const logger = getLogger('UpdateService')
|
||||||
|
|
@ -76,124 +79,162 @@ export interface UpdateServiceEvents {
|
||||||
'update-error': { message: string }
|
'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 {
|
class UpdateService extends EventEmitter {
|
||||||
private _initialized = false
|
private _initialized = false
|
||||||
private _initialTimer: NodeJS.Timeout | null = null
|
private _initialTimer: NodeJS.Timeout | null = null
|
||||||
private _intervalTimer: 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 _downloading = false
|
||||||
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
|
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
|
||||||
private _channel: UpdateChannel = 'latest'
|
private _channel: UpdateChannel = 'latest'
|
||||||
private _forceFullDownload = false
|
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. */
|
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
if (this._initialized) return
|
if (this._initialized) return
|
||||||
this._initialized = true
|
this._initialized = true
|
||||||
|
|
||||||
if (!app.isPackaged) {
|
this._updater = this._deps.createUpdater({
|
||||||
logger.info('dev 실행 — 자동 업데이트 비활성')
|
onChecking: () => {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
|
||||||
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!UPDATE_FEED_URL) {
|
|
||||||
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
|
|
||||||
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('업데이트 확인 중...')
|
logger.info('업데이트 확인 중...')
|
||||||
this.emit('checking-for-update', undefined as unknown as void)
|
this.emit('checking-for-update', undefined as unknown as void)
|
||||||
})
|
},
|
||||||
|
onAvailable: (info) => {
|
||||||
updater.on('update-available', (info) => {
|
|
||||||
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
||||||
void this._handleUpdateAvailable(info)
|
void this.handleUpdateAvailable(info)
|
||||||
})
|
},
|
||||||
|
onNotAvailable: (version) => {
|
||||||
updater.on('update-not-available', (info) => {
|
logger.info(`최신 버전 사용 중: v${version}`)
|
||||||
logger.info(`최신 버전 사용 중: v${info.version}`)
|
this.emit('update-not-available', { version })
|
||||||
this.emit('update-not-available', { version: info.version })
|
},
|
||||||
})
|
onProgress: (progress) => this.emit('download-progress', progress),
|
||||||
|
onDownloaded: (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
|
this._downloading = false
|
||||||
logger.info(`업데이트 다운로드 완료: v${info.version}`)
|
logger.info(`업데이트 다운로드 완료: v${version}`)
|
||||||
this.emit('update-downloaded', { version: info.version })
|
this.emit('update-downloaded', { version })
|
||||||
void this._promptRestart(info.version)
|
void this._promptRestart(version)
|
||||||
})
|
},
|
||||||
|
onError: (message) => {
|
||||||
updater.on('error', (err) => {
|
// 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다
|
||||||
|
if (this._downloading) this._lastPrompt = null
|
||||||
this._downloading = false
|
this._downloading = false
|
||||||
logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`)
|
logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`)
|
||||||
this.emit('update-error', { message: err.message })
|
this.emit('update-error', { message })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
if (!this._updater) return
|
||||||
|
|
||||||
// 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다.
|
this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
|
||||||
void this._loadPolicy()
|
this._initialTimer = setTimeout(() => void this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||||
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
this._intervalTimer = setInterval(() => void this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||||
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
|
|
||||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
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) {
|
if (this._policy.killSwitch) {
|
||||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this._autoUpdater.checkForUpdates().catch((err: unknown) => {
|
try {
|
||||||
|
await this._updater.checkForUpdates()
|
||||||
|
} catch (err) {
|
||||||
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 사용자 수락 시 업데이트 다운로드 시작 */
|
/** 사용자 수락 시 업데이트 다운로드 시작 */
|
||||||
async startDownload(): Promise<void> {
|
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
|
this._downloading = true
|
||||||
logger.info(
|
logger.info(
|
||||||
this._forceFullDownload
|
this._forceFullDownload
|
||||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
: '차분 업데이트 다운로드 시작 (.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
|
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 {
|
private _resolveChannel(policy: UpdatePolicy): UpdateChannel {
|
||||||
const configured = configGet('updateChannel')
|
const configured = configGet('updateChannel')
|
||||||
return isUpdateChannel(configured) ? configured : policy.defaultChannel
|
return isUpdateChannel(configured) ? configured : policy.defaultChannel
|
||||||
|
|
@ -233,38 +339,9 @@ class UpdateService extends EventEmitter {
|
||||||
|
|
||||||
private _applyChannel(channel: UpdateChannel): void {
|
private _applyChannel(channel: UpdateChannel): void {
|
||||||
this._channel = channel
|
this._channel = channel
|
||||||
if (!this._autoUpdater) return
|
if (!this._updater) return
|
||||||
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
|
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
|
||||||
this._autoUpdater.channel = channel
|
this._updater.setChannel(channel, channelPolicy.allowPrerelease)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _deviceId(): string {
|
private _deviceId(): string {
|
||||||
|
|
@ -282,142 +359,44 @@ class UpdateService extends EventEmitter {
|
||||||
return typeof legacy === 'string' && legacy ? legacy : null
|
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단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||||
private async _promptUserConsent(
|
private async _promptUserConsent(
|
||||||
version: string,
|
version: string,
|
||||||
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined,
|
releaseNotes: ReleaseNotes,
|
||||||
decision: UpdateDecision,
|
decision: UpdateDecision,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this._downloading) return
|
if (this._prompting) return
|
||||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
const now = this._deps.now()
|
||||||
if (decision.mandatory && decision.reason === 'below-force-install') {
|
if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) {
|
||||||
logger.info(`v${version} 강제 설치 (${decision.reason})`)
|
logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`)
|
||||||
void this.startDownload()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this._promptShown) return
|
this._lastPrompt = { version, mandatory: decision.mandatory, at: now }
|
||||||
this._promptShown = true
|
this._prompting = true
|
||||||
|
try {
|
||||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
const answer = await this._deps.prompter.askConsent({
|
||||||
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
version,
|
||||||
const mandatoryNote = decision.mandatory
|
releaseNotes,
|
||||||
? isKo
|
mandatory: decision.mandatory,
|
||||||
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
|
})
|
||||||
: '\n\nThis update is required (end of support).'
|
if (answer === 'download') {
|
||||||
: ''
|
|
||||||
|
|
||||||
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()
|
void this.startDownload()
|
||||||
} else if (!decision.mandatory && response === 2) {
|
} else if (!decision.mandatory && answer === 'skip') {
|
||||||
this.skipVersion(version)
|
this.skipVersion(version)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this._prompting = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
|
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
|
||||||
private async _promptRestart(version: string): Promise<void> {
|
private async _promptRestart(version: string): Promise<void> {
|
||||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
const answer = await this._deps.prompter.askRestart(version)
|
||||||
const options = {
|
if (answer === 'restart' && this._updater) {
|
||||||
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) {
|
|
||||||
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
|
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
|
||||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행
|
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
|
||||||
this._autoUpdater.quitAndInstall(false, 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
|
let instance: UpdateService | null = null
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
import { EventEmitter } from 'events'
|
import { EventEmitter } from 'events'
|
||||||
import { getLogger } from './LoggerService'
|
import { getLogger } from './LoggerService'
|
||||||
import { getPremiumLLMService } from './PremiumLLMService'
|
import { getPremiumLLMService } from './PremiumLLMService'
|
||||||
import { getLocalLLMService } from './LocalLLMService'
|
|
||||||
import { getLocalSTTService } from './LocalSTTService'
|
import { getLocalSTTService } from './LocalSTTService'
|
||||||
import { getAudioCaptureService } from './AudioCaptureService'
|
import { getAudioCaptureService } from './AudioCaptureService'
|
||||||
import { getTTSPlaybackService } from './TTSPlaybackService'
|
import { getTTSPlaybackService } from './TTSPlaybackService'
|
||||||
import { getSoundEffectService } from './SoundEffectService'
|
import { getSoundEffectService } from './SoundEffectService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
|
import { getLlmGateway } from './llm/LlmGateway'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
|
@ -386,45 +386,22 @@ class VoiceConversationService extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM 백엔드 선택 + chatStream 생성.
|
* LLM 백엔드 선택 + chatStream 생성 — 선택 정책(online+가용 → Premium, 아니면 Local)은
|
||||||
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback.
|
* LlmGateway 가 소유한다. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다.
|
||||||
*/
|
*/
|
||||||
private async _createChatStream(signal: AbortSignal): Promise<{
|
private async _createChatStream(signal: AbortSignal): Promise<{
|
||||||
generator: AsyncGenerator<string, string>
|
generator: AsyncGenerator<string, string>
|
||||||
backend: 'local' | 'premium'
|
backend: 'local' | 'premium'
|
||||||
}> {
|
}> {
|
||||||
const chatMessages = this._buildChatMessages()
|
const chatMessages = this._buildChatMessages()
|
||||||
const backend = configGet('llmBackend')
|
return getLlmGateway().openChatStream(chatMessages, {
|
||||||
|
|
||||||
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,
|
signal,
|
||||||
|
local: {
|
||||||
maxTokens: 512,
|
maxTokens: 512,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
keepAlive: '2m',
|
keepAlive: '2m',
|
||||||
}),
|
},
|
||||||
backend: 'local',
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _isCurrentResponse(responseAbortController: AbortController): boolean {
|
private _isCurrentResponse(responseAbortController: AbortController): boolean {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
236
apps/desktop/src/main/services/llm/LlmGateway.ts
Normal file
236
apps/desktop/src/main/services/llm/LlmGateway.ts
Normal 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
|
||||||
|
}
|
||||||
116
apps/desktop/src/main/services/rag/docx-text.ts
Normal file
116
apps/desktop/src/main/services/rag/docx-text.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,8 @@ import type {
|
||||||
TestSTTConnectionResult,
|
TestSTTConnectionResult,
|
||||||
STTStatus,
|
STTStatus,
|
||||||
} from '@d3ro/core/types'
|
} 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 { OpenAIDriver } from './drivers/OpenAIDriver'
|
||||||
import { GroqDriver } from './drivers/GroqDriver'
|
import { GroqDriver } from './drivers/GroqDriver'
|
||||||
import { DeepgramDriver } from './drivers/DeepgramDriver'
|
import { DeepgramDriver } from './drivers/DeepgramDriver'
|
||||||
|
|
@ -26,6 +27,23 @@ import { normalizeLoopbackUrl } from '../../utils/loopback'
|
||||||
|
|
||||||
const logger = getLogger('STTManager')
|
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[] = [
|
export const STT_PROVIDERS_META: STTProviderInfo[] = [
|
||||||
{
|
{
|
||||||
id: 'local',
|
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>()
|
private _drivers = new Map<STTProviderType, ISTTDriver>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
@ -210,7 +228,7 @@ export class STTManager extends EventEmitter {
|
||||||
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
|
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
|
||||||
const provider = this.getActiveProvider()
|
const provider = this.getActiveProvider()
|
||||||
|
|
||||||
// 1. Local STT
|
// 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다
|
||||||
if (provider === 'local') {
|
if (provider === 'local') {
|
||||||
return getLocalSTTService().transcribe(audioBuffer, options)
|
return getLocalSTTService().transcribe(audioBuffer, options)
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +237,7 @@ export class STTManager extends EventEmitter {
|
||||||
const driver = this._drivers.get(provider)
|
const driver = this._drivers.get(provider)
|
||||||
if (!driver) {
|
if (!driver) {
|
||||||
logger.warn(`Driver not found for provider ${provider}, falling back to local`)
|
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)
|
const config = this.getProviderConfig(provider)
|
||||||
|
|
@ -236,7 +254,7 @@ export class STTManager extends EventEmitter {
|
||||||
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
|
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
|
||||||
this.emit('fallback-to-local', { provider, reason: errorMsg })
|
this.emit('fallback-to-local', { provider, reason: errorMsg })
|
||||||
try {
|
try {
|
||||||
return await getLocalSTTService().transcribe(audioBuffer, options)
|
return await this._transcribeLocalFallback(audioBuffer, options)
|
||||||
} catch (localError) {
|
} catch (localError) {
|
||||||
logger.error(`Local STT fallback also failed: ${localError}`)
|
logger.error(`Local STT fallback also failed: ${localError}`)
|
||||||
throw error // 원래 에러 유지
|
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에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.
|
* 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService'
|
import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService'
|
||||||
import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types'
|
import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types'
|
||||||
|
|
||||||
|
export type { TranscriptionResult, TranscribeOptions }
|
||||||
|
|
||||||
export interface ISTTDriver {
|
export interface ISTTDriver {
|
||||||
readonly id: STTProviderType
|
readonly id: STTProviderType
|
||||||
readonly name: string
|
readonly name: string
|
||||||
|
|
@ -18,3 +20,26 @@ export interface ISTTDriver {
|
||||||
message: string
|
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>
|
||||||
|
}
|
||||||
|
|
|
||||||
113
apps/desktop/src/main/shutdown-registry.ts
Normal file
113
apps/desktop/src/main/shutdown-registry.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -259,3 +259,84 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
||||||
|
|
||||||
return policy
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
157
apps/desktop/src/main/windows/popup-navigation.ts
Normal file
157
apps/desktop/src/main/windows/popup-navigation.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
apps/desktop/src/main/windows/recording-tip-presenter.ts
Normal file
133
apps/desktop/src/main/windows/recording-tip-presenter.ts
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,17 @@ function stripThinkTags(text: string): string {
|
||||||
return result.trim()
|
return result.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* main 이 실어 보낸 sessionId 가 이 패널의 회의인지 확인한다.
|
||||||
|
* sessionId 가 없는(구버전) 이벤트는 그대로 받아들인다.
|
||||||
|
*/
|
||||||
|
function isForSession(payload: object, sessionId: string): boolean {
|
||||||
|
if ('sessionId' in payload && typeof payload.sessionId === 'string') {
|
||||||
|
return payload.sessionId === sessionId
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// 타이핑 인디케이터 점 3개 애니메이션
|
// 타이핑 인디케이터 점 3개 애니메이션
|
||||||
function TypingIndicator(): React.ReactElement {
|
function TypingIndicator(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
|
|
@ -74,21 +85,38 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [messages, streamingContent, scrollToBottom])
|
}, [messages, streamingContent, scrollToBottom])
|
||||||
|
|
||||||
// 이벤트 구독
|
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
|
||||||
|
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages([])
|
||||||
|
setStreaming(false)
|
||||||
|
setStreamingContent('')
|
||||||
|
setError(null)
|
||||||
|
isSendingChatRef.current = false
|
||||||
|
return () => {
|
||||||
|
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||||
|
void window.electronAPI.meetingChat.cancel()
|
||||||
|
}
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
// 이벤트 구독 — 다른 회의로 향한 이벤트는 무시한다
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => {
|
const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => {
|
||||||
|
if (!isForSession(data, sessionId)) return
|
||||||
setStreaming(true)
|
setStreaming(true)
|
||||||
setStreamingContent((prev) => prev + data.token)
|
setStreamingContent((prev) => prev + data.token)
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => {
|
const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => {
|
||||||
setMessages((prev) => [...prev, msg])
|
if (!isForSession(msg, sessionId)) return
|
||||||
|
setMessages((prev) => [...prev, { role: msg.role, content: msg.content, timestamp: msg.timestamp }])
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
setStreamingContent('')
|
setStreamingContent('')
|
||||||
isSendingChatRef.current = false
|
isSendingChatRef.current = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const unsubError = window.electronAPI.meetingChat.onError((err) => {
|
const unsubError = window.electronAPI.meetingChat.onError((err) => {
|
||||||
|
if (!isForSession(err, sessionId)) return
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
setStreamingContent('')
|
setStreamingContent('')
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|
@ -139,7 +167,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setStreamingContent('')
|
setStreamingContent('')
|
||||||
setStreaming(false)
|
setStreaming(false)
|
||||||
window.electronAPI.meetingChat.clear({ sessionId })
|
window.electronAPI.meetingChat.clear()
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
const handleToggleCollapse = useCallback(() => {
|
const handleToggleCollapse = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// tests/main/services/audio-capture-redteam-r1-3.test.ts
|
||||||
|
// SoX 가 장치를 못 열고 끝났을 때 'error' 리스너가 없어 ERR_UNHANDLED_ERROR 가 나고
|
||||||
|
// 뒤이은 'stopped' 가 영영 나가지 않던 버그의 회귀 테스트.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn(() => 'usb-headset-gone'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/utils/paths', () => ({
|
||||||
|
getSoxPath: () => 'sox',
|
||||||
|
}))
|
||||||
|
|
||||||
|
class FakeProcess extends EventEmitter {
|
||||||
|
stdout = new EventEmitter()
|
||||||
|
stderr = new EventEmitter()
|
||||||
|
kill = vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawned = vi.hoisted(() => ({ list: [] as unknown[] }))
|
||||||
|
|
||||||
|
vi.mock('child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('child_process')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
spawn: vi.fn(() => {
|
||||||
|
const proc = new FakeProcess()
|
||||||
|
spawned.list.push(proc)
|
||||||
|
return proc
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type Module = typeof import('../../../src/main/services/AudioCaptureService')
|
||||||
|
let mod: Module
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
spawned.list = []
|
||||||
|
mod = await import('../../../src/main/services/AudioCaptureService')
|
||||||
|
mod.resetAudioCaptureServiceForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AudioCaptureService — 캡처 프로세스 종료', () => {
|
||||||
|
it("SoX 가 녹음 중 끝나도 예외 없이 'stopped'(device-lost) 를 알린다", async () => {
|
||||||
|
const audio = mod.getAudioCaptureService()
|
||||||
|
const stopped: string[] = []
|
||||||
|
audio.on('stopped', ({ reason }) => stopped.push(reason))
|
||||||
|
|
||||||
|
await audio.start()
|
||||||
|
expect(audio.state).toBe('capturing')
|
||||||
|
|
||||||
|
const proc = spawned.list[0] as FakeProcess
|
||||||
|
expect(() => proc.emit('close', 1)).not.toThrow()
|
||||||
|
|
||||||
|
expect(stopped).toEqual(['device-lost'])
|
||||||
|
expect(audio.state).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('실패 뒤에도 다시 시작할 수 있다', async () => {
|
||||||
|
const audio = mod.getAudioCaptureService()
|
||||||
|
await audio.start()
|
||||||
|
;(spawned.list[0] as FakeProcess).emit('close', 1)
|
||||||
|
|
||||||
|
await audio.start()
|
||||||
|
expect(audio.state).toBe('capturing')
|
||||||
|
expect(spawned.list).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
// tests/main/services/caption-owner-redteam-r1-3.test.ts
|
||||||
|
// CaptionService 세션 계약 회귀 테스트:
|
||||||
|
// - 회의 모드가 소유한 자막 세션은 사용자 요청(stop('user'))으로 멈추지 않는다
|
||||||
|
// - stop() 은 flush 중 확정된 마지막 문장까지 담은 요약을 돌려준다
|
||||||
|
// - persistHistory:false 면 'caption' 히스토리를 만들지 않고 session-saved 도 없다
|
||||||
|
// - 저장한 경우 요약에 히스토리 행 id 가 실린다 (자동 요약이 그 id 로 찾는다)
|
||||||
|
// - audioSource 옵션은 전역 설정/다음 세션으로 새지 않는다
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn((key: string) => config.values[key]),
|
||||||
|
configSet: vi.fn((key: string, value: unknown) => {
|
||||||
|
config.values[key] = value
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||||
|
getLocalSTTService: () => ({
|
||||||
|
initialize: vi.fn(async () => undefined),
|
||||||
|
ensureAuxModel: vi.fn(async () => undefined),
|
||||||
|
transcribe: vi.fn(async () => ({ text: '', segments: [], language: 'ko', duration: 0, processingTime: 0 })),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }))
|
||||||
|
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||||
|
getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }),
|
||||||
|
calculateRMS: () => 0.1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/SoundEffectService', () => ({
|
||||||
|
getSoundEffectService: () => ({ play: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||||
|
getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const history = vi.hoisted(() => ({ create: vi.fn() }))
|
||||||
|
vi.mock('../../../src/main/services/HistoryService', () => ({
|
||||||
|
getHistoryService: () => history,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 트랙: flush 하면 말하던 마지막 문장을 확정한다
|
||||||
|
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
|
||||||
|
StreamingCaptionTrack: class {
|
||||||
|
constructor(private readonly deps: { onFinal: (text: string) => void }) {}
|
||||||
|
push(): void {}
|
||||||
|
async tick(): Promise<void> {}
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
this.deps.onFinal('정지 직전 마지막 문장')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
|
||||||
|
let mod: CaptionModule
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
config.values = { captionAudioSource: 'system' }
|
||||||
|
history.create.mockReturnValue({ id: 'history-row-1' })
|
||||||
|
mod = await import('../../../src/main/services/CaptionService')
|
||||||
|
mod.resetCaptionServiceForTests()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CaptionService 세션 소유권', () => {
|
||||||
|
it('회의가 소유한 세션은 stop(\'user\') 를 거부하고 계속 돈다', async () => {
|
||||||
|
const caption = mod.getCaptionService()
|
||||||
|
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||||
|
|
||||||
|
expect(await caption.stop('user')).toBe(false)
|
||||||
|
expect(caption.getState()).toBe('active')
|
||||||
|
expect(audio.stop).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// 소유자와 강제 정지(인자 없음)는 멈출 수 있다
|
||||||
|
expect(await caption.stop('meeting')).not.toBe(false)
|
||||||
|
expect(caption.getState()).toBe('inactive')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('사용자 세션은 stop(\'user\') 로 멈춘다', async () => {
|
||||||
|
const caption = mod.getCaptionService()
|
||||||
|
await caption.start()
|
||||||
|
expect(caption.getOwner()).toBe('user')
|
||||||
|
await caption.stop('user')
|
||||||
|
expect(caption.getState()).toBe('inactive')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CaptionService 정지 요약', () => {
|
||||||
|
it('flush 중 확정된 문장을 요약에 담아 돌려주고, persistHistory:false 면 히스토리를 만들지 않는다', async () => {
|
||||||
|
const caption = mod.getCaptionService()
|
||||||
|
const saved = vi.fn()
|
||||||
|
caption.on('session-saved', saved)
|
||||||
|
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||||
|
|
||||||
|
const summary = await caption.stop('meeting')
|
||||||
|
|
||||||
|
expect(summary).not.toBe(false)
|
||||||
|
expect(summary && summary.segments.map((s) => s.text)).toEqual(['정지 직전 마지막 문장'])
|
||||||
|
expect(summary && summary.historyId).toBeNull()
|
||||||
|
expect(history.create).not.toHaveBeenCalled()
|
||||||
|
expect(saved).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('저장한 세션은 요약에 히스토리 행 id 를 싣는다', async () => {
|
||||||
|
const caption = mod.getCaptionService()
|
||||||
|
const saved = vi.fn()
|
||||||
|
caption.on('session-saved', saved)
|
||||||
|
await caption.start({ audioSource: 'mic' })
|
||||||
|
|
||||||
|
const summary = await caption.stop()
|
||||||
|
|
||||||
|
expect(history.create).toHaveBeenCalledTimes(1)
|
||||||
|
expect(summary && summary.historyId).toBe('history-row-1')
|
||||||
|
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ historyId: 'history-row-1' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audioSource 옵션은 다음 사용자 세션의 설정으로 새지 않는다', async () => {
|
||||||
|
const caption = mod.getCaptionService()
|
||||||
|
caption.setConfig({ audioSource: 'system' })
|
||||||
|
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
|
||||||
|
await caption.stop()
|
||||||
|
expect(config.values.captionAudioSource).toBe('system')
|
||||||
|
expect(caption.getConfig().audioSource).toBe('system')
|
||||||
|
})
|
||||||
|
})
|
||||||
131
apps/desktop/tests/main/services/llm-gateway.test.ts
Normal file
131
apps/desktop/tests/main/services/llm-gateway.test.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// tests/main/services/llm-gateway.test.ts
|
||||||
|
// LLM 백엔드 선택 정책(단일 정본)의 local / online / 불가 / 실패 매트릭스.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import {
|
||||||
|
createRoutedLlmGateway,
|
||||||
|
type LocalLlmAdapter,
|
||||||
|
type PremiumLlmAdapter,
|
||||||
|
} from '../../../src/main/services/llm/LlmGateway'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
|
||||||
|
for (const v of values) yield v
|
||||||
|
return values.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
|
||||||
|
return {
|
||||||
|
isAvailable: vi.fn(() => true),
|
||||||
|
processText: vi.fn(async () => 'premium'),
|
||||||
|
generate: vi.fn(async () => ({ text: 'premium' })),
|
||||||
|
chatStream: vi.fn(() => tokens('p')),
|
||||||
|
cancelGeneration: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
|
||||||
|
return {
|
||||||
|
isAvailable: vi.fn(() => true),
|
||||||
|
processText: vi.fn(async () => 'local'),
|
||||||
|
generate: vi.fn(async () => ({ text: 'local', model: 'gemma' })),
|
||||||
|
chatStream: vi.fn(() => tokens('l')),
|
||||||
|
cancelGeneration: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateway(backend: string | undefined, p: PremiumLlmAdapter, l: LocalLlmAdapter) {
|
||||||
|
return createRoutedLlmGateway({
|
||||||
|
getBackend: () => backend,
|
||||||
|
loadPremium: async () => p,
|
||||||
|
getLocal: () => l,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LlmGateway 라우팅', () => {
|
||||||
|
it('local 백엔드는 Premium 을 절대 건드리지 않는다', async () => {
|
||||||
|
const p = premium()
|
||||||
|
const l = local()
|
||||||
|
const gw = gateway('local', p, l)
|
||||||
|
|
||||||
|
expect(await gw.processText('t', 'refine')).toBe('local')
|
||||||
|
expect((await gw.generate('q', { systemPrompt: 'docs' })).text).toBe('local')
|
||||||
|
expect(p.processText).not.toHaveBeenCalled()
|
||||||
|
expect(p.generate).not.toHaveBeenCalled()
|
||||||
|
expect(p.isAvailable).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('online + 가용이면 Premium 을 쓴다', async () => {
|
||||||
|
const p = premium()
|
||||||
|
const l = local()
|
||||||
|
const gw = gateway('online', p, l)
|
||||||
|
expect(await gw.processText('t', 'custom', undefined, 'sys')).toBe('premium')
|
||||||
|
expect(p.processText).toHaveBeenCalledWith('t', 'custom', undefined, 'sys')
|
||||||
|
expect(l.processText).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('online + 불가면 로컬로 폴백하고 사유를 알린다', async () => {
|
||||||
|
const onFallback = vi.fn()
|
||||||
|
const gw = gateway('online', premium({ isAvailable: vi.fn(() => false) }), local())
|
||||||
|
expect(await gw.processText('t', 'refine', undefined, undefined, { onFallback })).toBe('local')
|
||||||
|
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('unavailable'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('online + Premium 호출 실패면 로컬로 폴백한다', async () => {
|
||||||
|
const onFallback = vi.fn()
|
||||||
|
const gw = gateway(
|
||||||
|
'online',
|
||||||
|
premium({ generate: vi.fn(async () => { throw new Error('quota') }) }),
|
||||||
|
local(),
|
||||||
|
)
|
||||||
|
expect((await gw.generate('q', undefined, { onFallback })).text).toBe('local')
|
||||||
|
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('quota'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('로컬 실패는 그대로 올린다 (성공으로 위장하지 않는다)', async () => {
|
||||||
|
const gw = gateway('local', premium(), local({ processText: vi.fn(async () => { throw new Error('ollama down') }) }))
|
||||||
|
await expect(gw.processText('t', 'refine')).rejects.toThrow('ollama down')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('채팅 스트림: Premium 가용이면 Premium, 로컬은 옵션과 signal 을 받는다', async () => {
|
||||||
|
const p = premium()
|
||||||
|
const l = local()
|
||||||
|
const signal = new AbortController().signal
|
||||||
|
|
||||||
|
const online = await gateway('online', p, l).openChatStream([{ role: 'user', content: 'hi' }], { signal })
|
||||||
|
expect(online.backend).toBe('premium')
|
||||||
|
|
||||||
|
const offline = await gateway('local', p, l).openChatStream([{ role: 'user', content: 'hi' }], {
|
||||||
|
signal,
|
||||||
|
local: { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m' },
|
||||||
|
})
|
||||||
|
expect(offline.backend).toBe('local')
|
||||||
|
expect(l.chatStream).toHaveBeenCalledWith(
|
||||||
|
[{ role: 'user', content: 'hi' }],
|
||||||
|
{ maxTokens: 512, timeoutMs: 60000, keepAlive: '2m', signal },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('채팅 스트림: 로컬이 불가하면 LLMServerUnreachable', async () => {
|
||||||
|
const gw = gateway('local', premium(), local({ isAvailable: vi.fn(() => false) }))
|
||||||
|
await expect(
|
||||||
|
gw.openChatStream([], { signal: new AbortController().signal }),
|
||||||
|
).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel 은 로드된 두 어댑터를 모두 취소한다', async () => {
|
||||||
|
const p = premium()
|
||||||
|
const l = local()
|
||||||
|
const gw = gateway('online', p, l)
|
||||||
|
await gw.processText('t', 'refine')
|
||||||
|
gw.cancel()
|
||||||
|
expect(p.cancelGeneration).toHaveBeenCalled()
|
||||||
|
expect(l.cancelGeneration).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
// tests/main/services/rag-docx-redteam-r1-3.test.ts
|
||||||
|
// Word 가 만든(deflate 압축) .docx 에서 본문을 뽑는지, ZIP 이 아니면 바이너리 잡음을
|
||||||
|
// 색인하지 않고 실패하는지 확인한다.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { Document, Packer, Paragraph, TextRun } from 'docx'
|
||||||
|
import {
|
||||||
|
extractDocxText,
|
||||||
|
decodeXmlEntities,
|
||||||
|
wordXmlToText,
|
||||||
|
DocxExtractionError,
|
||||||
|
} from '../../../src/main/services/rag/docx-text'
|
||||||
|
|
||||||
|
async function buildDocx(paragraphs: string[]): Promise<Buffer> {
|
||||||
|
const doc = new Document({
|
||||||
|
sections: [{ children: paragraphs.map((p) => new Paragraph({ children: [new TextRun(p)] })) }],
|
||||||
|
})
|
||||||
|
return Buffer.from(await Packer.toBuffer(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('extractDocxText', () => {
|
||||||
|
it('압축된 word/document.xml 에서 문단 텍스트를 뽑는다', async () => {
|
||||||
|
const buffer = await buildDocx(['분기 매출 보고서', 'List<String> 타입을 쓰고 A & B 를 비교한다'])
|
||||||
|
// 원본 ZIP 바이트에는 본문이 평문으로 보이지 않는다 (예전 정규식이 실패한 이유)
|
||||||
|
expect(buffer.toString('utf-8')).not.toContain('분기 매출 보고서')
|
||||||
|
|
||||||
|
const text = extractDocxText(buffer)
|
||||||
|
expect(text).toBe('분기 매출 보고서\nList<String> 타입을 쓰고 A & B 를 비교한다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ZIP 이 아니면 잡음을 돌려주지 않고 실패한다', () => {
|
||||||
|
const garbage = Buffer.from('PK\u0003\u0004 this is not really a zip archive at all, just junk bytes', 'utf-8')
|
||||||
|
expect(() => extractDocxText(garbage)).toThrow(DocxExtractionError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('XML 엔티티와 탭/줄바꿈을 복원한다', () => {
|
||||||
|
expect(decodeXmlEntities('a & b <c> AB')).toBe('a & b <c> AB')
|
||||||
|
const xml =
|
||||||
|
'<w:body><w:p><w:r><w:t>하나</w:t><w:tab/><w:t xml:space="preserve"> 둘</w:t></w:r></w:p>' +
|
||||||
|
'<w:p><w:pPr><w:pStyle w:val="x"/></w:pPr><w:r><w:t>셋</w:t><w:br/><w:t>넷</w:t></w:r></w:p><w:p/></w:body>'
|
||||||
|
expect(wordXmlToText(xml)).toBe('하나\t 둘\n셋\n넷')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
// tests/main/services/screen-context-redteam-r1-3.test.ts
|
||||||
|
// 선택 텍스트 캡처 회귀 테스트:
|
||||||
|
// - 핫키 수정자(Alt 등)를 쥔 상태면 Ctrl+C 를 보내지 않고 클립보드도 건드리지 않는다
|
||||||
|
// - 캡처 후 클립보드는 저장한 모든 형식(text/html/rtf/image)을 한 번에 복원한다
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { clipboard } from 'electron'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn(() => true),
|
||||||
|
configSet: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const modifiers = vi.hoisted(() => ({ released: true }))
|
||||||
|
vi.mock('../../../src/main/services/modifier-state', () => ({
|
||||||
|
waitForModifiersReleased: vi.fn(async () => modifiers.released),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const nut = vi.hoisted(() => ({ pressKey: vi.fn(async () => undefined), releaseKey: vi.fn(async () => undefined) }))
|
||||||
|
vi.mock('@nut-tree-fork/nut-js', () => ({
|
||||||
|
keyboard: nut,
|
||||||
|
Key: { LeftControl: 'LeftControl', C: 'C' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
type Module = typeof import('../../../src/main/services/ScreenContextService')
|
||||||
|
let getScreenContextService: Module['getScreenContextService']
|
||||||
|
|
||||||
|
// 공용 electron 목에는 clipboard.clear 가 없다 — 이 테스트에서만 채운다
|
||||||
|
const clipboardClear = vi.fn()
|
||||||
|
Object.assign(clipboard, { clear: clipboardClear })
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
modifiers.released = true
|
||||||
|
getScreenContextService = (await import('../../../src/main/services/ScreenContextService')).getScreenContextService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ScreenContextService.captureSelectedText', () => {
|
||||||
|
it('수정자를 쥐고 있으면 Ctrl+C 를 보내지 않고 클립보드도 비우지 않는다', async () => {
|
||||||
|
modifiers.released = false
|
||||||
|
const text = await getScreenContextService().captureSelectedText()
|
||||||
|
expect(text).toBeNull()
|
||||||
|
expect(nut.pressKey).not.toHaveBeenCalled()
|
||||||
|
expect(clipboardClear).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('수정자를 뗀 뒤에는 복사하고, 모든 형식을 한 번에 복원한다', async () => {
|
||||||
|
const image = { isEmpty: () => false, toPNG: () => Buffer.alloc(1) }
|
||||||
|
vi.mocked(clipboard.readText).mockReturnValueOnce('원래 텍스트').mockReturnValueOnce('선택한 문장')
|
||||||
|
vi.mocked(clipboard.readHTML).mockReturnValueOnce('<b>원래</b>')
|
||||||
|
vi.mocked(clipboard.readRTF).mockReturnValueOnce('{\\rtf1 원래}')
|
||||||
|
vi.mocked(clipboard.readImage).mockReturnValueOnce(image as unknown as Electron.NativeImage)
|
||||||
|
|
||||||
|
const text = await getScreenContextService().captureSelectedText()
|
||||||
|
|
||||||
|
expect(text).toBe('선택한 문장')
|
||||||
|
expect(nut.pressKey).toHaveBeenCalledTimes(1)
|
||||||
|
expect(clipboard.write).toHaveBeenCalledWith({
|
||||||
|
text: '원래 텍스트',
|
||||||
|
html: '<b>원래</b>',
|
||||||
|
rtf: '{\\rtf1 원래}',
|
||||||
|
image,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
// tests/main/services/stt-fallback-redteam-r1-3.test.ts
|
||||||
|
// 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트.
|
||||||
|
// - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다
|
||||||
|
// - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다
|
||||||
|
// - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음)
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
|
||||||
|
import {
|
||||||
|
getLocalSTTService,
|
||||||
|
resetLocalSTTServiceForTests,
|
||||||
|
} from '../../../src/main/services/LocalSTTService'
|
||||||
|
import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService'
|
||||||
|
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
|
||||||
|
|
||||||
|
type LocalInternals = {
|
||||||
|
_modelReady: boolean
|
||||||
|
_sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise<TranscriptionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
function internals(): LocalInternals {
|
||||||
|
return getLocalSTTService() as unknown as LocalInternals
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */
|
||||||
|
function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const echo = (buf: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> =>
|
||||||
|
Promise.resolve({
|
||||||
|
text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`,
|
||||||
|
segments: [],
|
||||||
|
language: options?.language ?? 'ko',
|
||||||
|
duration: 1,
|
||||||
|
processingTime: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initInMemoryConfig()
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
resetInMemoryConfig()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => {
|
||||||
|
const local = getLocalSTTService()
|
||||||
|
vi.spyOn(local, 'initialize').mockRejectedValue(
|
||||||
|
new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => {
|
||||||
|
const local = getLocalSTTService()
|
||||||
|
const init = vi.spyOn(local, 'initialize').mockImplementation(async () => {
|
||||||
|
internals()._modelReady = true
|
||||||
|
})
|
||||||
|
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
|
||||||
|
|
||||||
|
const result = await settleWithin(
|
||||||
|
local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }),
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(init).toHaveBeenCalledTimes(1)
|
||||||
|
expect(result.text).toBe('100:en:hint')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => {
|
||||||
|
const local = getLocalSTTService()
|
||||||
|
let release: () => void = () => undefined
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
vi.spyOn(local, 'initialize').mockImplementation(async () => {
|
||||||
|
await gate
|
||||||
|
internals()._modelReady = true
|
||||||
|
})
|
||||||
|
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
|
||||||
|
|
||||||
|
const first = local.transcribe(Buffer.alloc(10), { language: 'ko' })
|
||||||
|
const second = local.transcribe(Buffer.alloc(20), { language: 'ja' })
|
||||||
|
release()
|
||||||
|
|
||||||
|
const [a, b] = await settleWithin(Promise.all([first, second]), 1000)
|
||||||
|
expect(a.text).toBe('10:ko:-')
|
||||||
|
expect(b.text).toBe('20:ja:-')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('STTManager — 클라우드 실패 후 로컬 폴백', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initInMemoryConfig()
|
||||||
|
resetSTTManagerForTests()
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
|
resetInMemoryConfig()
|
||||||
|
resetSTTManagerForTests()
|
||||||
|
resetLocalSTTServiceForTests()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function failCloud(): void {
|
||||||
|
const mgr = getSTTManager()
|
||||||
|
mgr.setProvider('openai')
|
||||||
|
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
|
||||||
|
configSet('sttFallbackToLocal', true)
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => {
|
||||||
|
failCloud()
|
||||||
|
vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue(
|
||||||
|
new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000)
|
||||||
|
await expect(outcome).rejects.toBeInstanceOf(Error)
|
||||||
|
await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => {
|
||||||
|
failCloud()
|
||||||
|
vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise<never>(() => undefined))
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
|
||||||
|
const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager')
|
||||||
|
const pending = getSTTManager().transcribe(Buffer.alloc(32000))
|
||||||
|
const assertion = expect(pending).rejects.toBeInstanceOf(Error)
|
||||||
|
await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1)
|
||||||
|
await assertion
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => {
|
||||||
|
const mgr = getSTTManager()
|
||||||
|
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
mgr.setProvider('openai')
|
||||||
|
await mgr.ensureReady()
|
||||||
|
expect(init).not.toHaveBeenCalled()
|
||||||
|
expect(mgr.supportsPartial()).toBe(false)
|
||||||
|
expect(mgr.getMissingModel()).toBeNull()
|
||||||
|
|
||||||
|
mgr.setProvider('local')
|
||||||
|
await mgr.ensureReady()
|
||||||
|
expect(init).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mgr.supportsPartial()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
// tests/main/services/update-service-redteam-r1-3.test.ts
|
||||||
|
// 자동 업데이트 회귀 테스트 (가짜 포트 주입):
|
||||||
|
// - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영)
|
||||||
|
// - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다
|
||||||
|
// - evaluateUpdateOffer 게이팅 순서
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEFAULT_UPDATE_POLICY,
|
||||||
|
evaluateUpdateOffer,
|
||||||
|
shouldPromptForUpdate,
|
||||||
|
UPDATE_REPROMPT_INTERVAL_MS,
|
||||||
|
type UpdatePolicy,
|
||||||
|
} from '../../../src/main/update-policy'
|
||||||
|
import type {
|
||||||
|
ConsentAnswer,
|
||||||
|
PolicySource,
|
||||||
|
UpdatePrompter,
|
||||||
|
UpdaterEventHandlers,
|
||||||
|
UpdaterPort,
|
||||||
|
} from '../../../src/main/services/UpdateService'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const store = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn((key: string) => store.values[key]),
|
||||||
|
configSet: vi.fn((key: string, value: unknown) => {
|
||||||
|
store.values[key] = value
|
||||||
|
}),
|
||||||
|
configGetAll: vi.fn(() => store.values),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type ServiceModule = typeof import('../../../src/main/services/UpdateService')
|
||||||
|
let UpdateService: ServiceModule['UpdateService']
|
||||||
|
|
||||||
|
function policy(overrides: Partial<UpdatePolicy> = {}): UpdatePolicy {
|
||||||
|
return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) {
|
||||||
|
const answers = [...(opts.answers ?? [])]
|
||||||
|
const policies = [...(opts.policies ?? [policy()])]
|
||||||
|
let clock = 1_000_000
|
||||||
|
let handlers: UpdaterEventHandlers | null = null
|
||||||
|
const updater: UpdaterPort & { checks: number; downloads: number } = {
|
||||||
|
checks: 0,
|
||||||
|
downloads: 0,
|
||||||
|
checkForUpdates: vi.fn(async () => {
|
||||||
|
updater.checks++
|
||||||
|
}),
|
||||||
|
downloadUpdate: vi.fn(async () => {
|
||||||
|
updater.downloads++
|
||||||
|
}),
|
||||||
|
quitAndInstall: vi.fn(),
|
||||||
|
setChannel: vi.fn(),
|
||||||
|
setDifferentialDisabled: vi.fn(),
|
||||||
|
}
|
||||||
|
const policySource: PolicySource & { loads: number } = {
|
||||||
|
loads: 0,
|
||||||
|
load: vi.fn(async () => {
|
||||||
|
policySource.loads++
|
||||||
|
return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const consents: string[] = []
|
||||||
|
const prompter: UpdatePrompter = {
|
||||||
|
askConsent: vi.fn(async ({ version }) => {
|
||||||
|
consents.push(version)
|
||||||
|
return answers.shift() ?? 'later'
|
||||||
|
}),
|
||||||
|
askRestart: vi.fn(async () => 'later' as const),
|
||||||
|
}
|
||||||
|
const service = new UpdateService({
|
||||||
|
createUpdater: (h) => {
|
||||||
|
handlers = h
|
||||||
|
return updater
|
||||||
|
},
|
||||||
|
policySource,
|
||||||
|
prompter,
|
||||||
|
currentVersion: () => '1.9.0',
|
||||||
|
now: () => clock,
|
||||||
|
})
|
||||||
|
service.initialize()
|
||||||
|
service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
updater,
|
||||||
|
policySource,
|
||||||
|
consents,
|
||||||
|
handlers: () => handlers as UpdaterEventHandlers,
|
||||||
|
advance: (ms: number) => {
|
||||||
|
clock += ms
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
store.values = { updateDeviceId: 'device-1' }
|
||||||
|
UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UpdateService — 원격 정책 갱신', () => {
|
||||||
|
it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => {
|
||||||
|
const h = harness({ policies: [policy(), policy({ killSwitch: true })] })
|
||||||
|
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
expect(h.updater.checks).toBe(1)
|
||||||
|
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
expect(h.policySource.loads).toBe(2)
|
||||||
|
expect(h.updater.checks).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => {
|
||||||
|
const h = harness({ policies: [policy({ killSwitch: true })] })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('UpdateService — 다시 묻기', () => {
|
||||||
|
it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => {
|
||||||
|
const h = harness({ answers: ['later', 'later'] })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.2' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1', '1.9.2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => {
|
||||||
|
const h = harness({ answers: ['later', 'later'] })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1'])
|
||||||
|
|
||||||
|
h.advance(UPDATE_REPROMPT_INTERVAL_MS)
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => {
|
||||||
|
const h = harness({
|
||||||
|
answers: ['later', 'download'],
|
||||||
|
policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })],
|
||||||
|
})
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||||
|
expect(h.updater.downloads).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => {
|
||||||
|
const h = harness({ answers: ['download', 'later'] })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.updater.downloads).toBe(1)
|
||||||
|
|
||||||
|
h.handlers().onError('network down')
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => {
|
||||||
|
const h = harness({ answers: ['skip'] })
|
||||||
|
await h.service.checkForUpdates()
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2)
|
||||||
|
await h.service.handleUpdateAvailable({ version: '1.9.1' })
|
||||||
|
expect(h.consents).toEqual(['1.9.1'])
|
||||||
|
expect(store.values.skippedUpdateVersion).toBe('1.9.1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('evaluateUpdateOffer 게이팅 순서', () => {
|
||||||
|
const base = {
|
||||||
|
channel: 'latest' as const,
|
||||||
|
currentVersion: '1.9.0',
|
||||||
|
skippedVersion: null,
|
||||||
|
deviceId: 'device-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('킬 스위치가 가장 먼저다', () => {
|
||||||
|
const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' })
|
||||||
|
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stable 채널은 prerelease 를 거부한다', () => {
|
||||||
|
const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' })
|
||||||
|
expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => {
|
||||||
|
const offer = evaluateUpdateOffer({
|
||||||
|
...base,
|
||||||
|
policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }),
|
||||||
|
targetVersion: '1.9.1',
|
||||||
|
skippedVersion: '1.9.1',
|
||||||
|
})
|
||||||
|
expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('건너뛴 버전 / rollout 밖은 무시한다', () => {
|
||||||
|
expect(
|
||||||
|
evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }),
|
||||||
|
).toMatchObject({ action: 'ignore', reason: 'skipped' })
|
||||||
|
expect(
|
||||||
|
evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }),
|
||||||
|
).toMatchObject({ action: 'ignore', reason: 'rollout' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => {
|
||||||
|
const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' })
|
||||||
|
expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => {
|
||||||
|
const last = { version: '1.9.1', mandatory: false, at: 0 }
|
||||||
|
expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true)
|
||||||
|
expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true)
|
||||||
|
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true)
|
||||||
|
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false)
|
||||||
|
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
389
apps/desktop/tests/main/services/voice-mode-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,389 @@
|
||||||
|
// tests/main/services/voice-mode-redteam-r1-3.test.ts
|
||||||
|
// VoiceModeService 세션 런(SessionRun) 회귀 테스트:
|
||||||
|
// - 취소된 세션의 늦은 STT 결과가 다음 세션에 삽입되지 않는다
|
||||||
|
// - 지연 전사 중 두 번째 release 가 받아쓰기를 취소하지 않는다
|
||||||
|
// - 오디오 캡처 참조는 세션당 정확히 한 번 놓는다 (start 대기 중 취소 포함)
|
||||||
|
// - 녹음 중 캡처가 죽으면(장치 분리) 에러로 알린다
|
||||||
|
// - 회의 모드가 소유한 자막은 자막 핫키로 끌 수 없다
|
||||||
|
// - 표시 계층 이벤트(phase / insert-failed)
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||||
|
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T) => void
|
||||||
|
reject: (err: unknown) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
let reject!: (err: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (text: string): { text: string; segments: never[]; language: string; duration: number; processingTime: number } => ({
|
||||||
|
text,
|
||||||
|
segments: [],
|
||||||
|
language: 'ko',
|
||||||
|
duration: 1,
|
||||||
|
processingTime: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockSTT = vi.hoisted(() => ({
|
||||||
|
initialize: vi.fn(),
|
||||||
|
transcribe: vi.fn(),
|
||||||
|
transcribePartial: vi.fn(async () => ''),
|
||||||
|
getModels: vi.fn(() => [
|
||||||
|
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||||
|
]),
|
||||||
|
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||||
|
getLocalSTTService: () => mockSTT,
|
||||||
|
resetLocalSTTServiceForTests: () => undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const audioBus = new EventEmitter()
|
||||||
|
const mockAudio = vi.hoisted(() => ({
|
||||||
|
start: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||||
|
getAudioCaptureService: () => mockAudio,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const keyBinding = vi.hoisted(() => ({
|
||||||
|
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/KeyBindingService', () => ({
|
||||||
|
getKeyBindingService: () => ({
|
||||||
|
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
|
||||||
|
keyBinding.handler = fn
|
||||||
|
},
|
||||||
|
off: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||||
|
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||||
|
configGet: vi.fn((key: string) => config.values[key]),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
|
||||||
|
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||||
|
getTextInsertService: () => mockInsert,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||||
|
getLocalLLMService: () => ({
|
||||||
|
isAvailable: () => true,
|
||||||
|
processText: vi.fn(async (text: string) => text),
|
||||||
|
generate: vi.fn(),
|
||||||
|
chatStream: vi.fn(),
|
||||||
|
cancelGeneration: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const caption = vi.hoisted(() => ({
|
||||||
|
state: 'inactive' as string,
|
||||||
|
stop: vi.fn(),
|
||||||
|
start: vi.fn(async () => undefined),
|
||||||
|
}))
|
||||||
|
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||||
|
getCaptionService: () => ({
|
||||||
|
getState: () => caption.state,
|
||||||
|
stop: caption.stop,
|
||||||
|
start: caption.start,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||||
|
getMeetingModeService: () => ({ getState: () => 'recording' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
|
||||||
|
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
|
||||||
|
|
||||||
|
const SPEECH = Buffer.alloc(16000 * 2) // 1초
|
||||||
|
|
||||||
|
async function flush(times = 5): Promise<void> {
|
||||||
|
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Date.now 를 앞으로 당겨 accidental-press(700ms) 판정을 건너뛴다 */
|
||||||
|
function advanceClock(ms: number): void {
|
||||||
|
const base = Date.now()
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.resetModules()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
audioBus.removeAllListeners()
|
||||||
|
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
|
||||||
|
caption.state = 'inactive'
|
||||||
|
keyBinding.handler = null
|
||||||
|
// once 큐가 테스트 사이로 새지 않게 완전히 초기화한다
|
||||||
|
for (const fn of [mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, mockAudio.start, mockAudio.stop, mockInsert.insertText, caption.stop]) {
|
||||||
|
fn.mockReset()
|
||||||
|
}
|
||||||
|
mockSTT.initialize.mockResolvedValue(undefined)
|
||||||
|
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
|
||||||
|
mockSTT.transcribePartial.mockResolvedValue('')
|
||||||
|
mockSTT.getModels.mockReturnValue([
|
||||||
|
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||||
|
])
|
||||||
|
mockAudio.start.mockResolvedValue(undefined)
|
||||||
|
mockAudio.stop.mockResolvedValue(undefined)
|
||||||
|
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||||
|
audioBus.on(ev, fn)
|
||||||
|
})
|
||||||
|
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||||
|
audioBus.off(ev, fn)
|
||||||
|
})
|
||||||
|
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
|
||||||
|
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||||
|
mod.resetVoiceModeServiceForTests()
|
||||||
|
getVoiceModeService = mod.getVoiceModeService
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('세션 런 — 늦게 도착한 결과', () => {
|
||||||
|
it('취소된 세션 A 의 STT 결과는 새 세션 B 에 삽입되지 않는다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const sttA = deferred<ReturnType<typeof result>>()
|
||||||
|
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||||
|
const completed: string[] = []
|
||||||
|
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||||
|
|
||||||
|
// 세션 A: 녹음 → 정지 → 전사 진행 중
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
const stopA = svc.stopSession()
|
||||||
|
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
// A 취소 → B 시작
|
||||||
|
svc.cancelSession()
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
const sessionB = svc.currentSession
|
||||||
|
expect(sessionB).not.toBeNull()
|
||||||
|
|
||||||
|
// A 의 결과가 늦게 도착
|
||||||
|
sttA.resolve(result('세션 A 텍스트'))
|
||||||
|
await stopA
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(completed).toEqual([])
|
||||||
|
expect(mockInsert.insertText).not.toHaveBeenCalled()
|
||||||
|
expect(svc.currentSession?.id).toBe(sessionB?.id)
|
||||||
|
expect(svc.isActive).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('취소된 세션의 늦은 STT 실패는 새 세션을 에러로 끝내지 않는다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const sttA = deferred<ReturnType<typeof result>>()
|
||||||
|
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
|
||||||
|
const errors: number[] = []
|
||||||
|
svc.on('error', ({ error }) => errors.push(error.code))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
const stopA = svc.stopSession()
|
||||||
|
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||||
|
svc.cancelSession()
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
|
||||||
|
sttA.reject(new Error('sidecar exploded'))
|
||||||
|
await stopA
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(svc.isActive).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('세션 런 — 정지 멱등성', () => {
|
||||||
|
it('지연 전사 중 두 번째 정지는 받아쓰기를 취소하지 않는다', async () => {
|
||||||
|
const init = deferred<void>()
|
||||||
|
mockSTT.initialize.mockReturnValueOnce(init.promise)
|
||||||
|
const stt = deferred<ReturnType<typeof result>>()
|
||||||
|
mockSTT.transcribe.mockReturnValueOnce(stt.promise)
|
||||||
|
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const cancelled: string[] = []
|
||||||
|
const completed: string[] = []
|
||||||
|
svc.on('session-cancelled', ({ reason }) => cancelled.push(reason))
|
||||||
|
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
await svc.stopSession() // STT 미준비 → 대기
|
||||||
|
|
||||||
|
init.resolve() // 모델 로딩 완료 → 지연 전사 시작 (버퍼 비움)
|
||||||
|
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
await svc.stopSession() // 두 번째 release
|
||||||
|
expect(cancelled).toEqual([])
|
||||||
|
|
||||||
|
stt.resolve(result('살아남은 받아쓰기'))
|
||||||
|
await vi.waitFor(() => expect(completed).toEqual(['살아남은 받아쓰기']))
|
||||||
|
expect(mockInsert.insertText).toHaveBeenCalledWith('살아남은 받아쓰기', undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('세션 런 — 오디오 참조 소유', () => {
|
||||||
|
it('정지 후 "No speech" 에러가 나도 audio.stop 은 세션당 한 번이다', async () => {
|
||||||
|
mockSTT.transcribe.mockResolvedValueOnce(result(' '))
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const errors: number[] = []
|
||||||
|
svc.on('error', ({ error }) => errors.push(error.code))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
await svc.stopSession()
|
||||||
|
await vi.waitFor(() => expect(errors).toEqual([ErrorCode.STTNoAudioData]))
|
||||||
|
expect(mockAudio.start).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('audio.start 대기 중 취소돼도 늦게 잡힌 참조를 정확히 한 번 놓는다', async () => {
|
||||||
|
const start = deferred<void>()
|
||||||
|
mockAudio.start.mockReturnValueOnce(start.promise)
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
|
||||||
|
const starting = svc.startSession('dictation')
|
||||||
|
await vi.waitFor(() => expect(mockAudio.start).toHaveBeenCalledTimes(1))
|
||||||
|
svc.cancelSession()
|
||||||
|
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
start.resolve()
|
||||||
|
await starting
|
||||||
|
await flush()
|
||||||
|
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
|
||||||
|
expect(audioBus.listenerCount('audio-data')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('세션이 끝나면 오디오 핸들러가 모두 해제된다 (다음 세션 프레임 중복 없음)', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
await svc.stopSession()
|
||||||
|
await vi.waitFor(() => expect(svc.isActive).toBe(false))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
expect(audioBus.listenerCount('audio-data')).toBe(1)
|
||||||
|
expect(audioBus.listenerCount('stopped')).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('캡처 손실', () => {
|
||||||
|
it('오디오 없이 캡처가 죽으면 AudioCaptureFailed 에러를 낸다', async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const errors: number[] = []
|
||||||
|
svc.on('error', ({ error }) => errors.push(error.code))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('stopped', { reason: 'device-lost' })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
expect(errors).toEqual([ErrorCode.AudioCaptureFailed])
|
||||||
|
expect(svc.isActive).toBe(false)
|
||||||
|
// 캡처 쪽이 이미 정리했으므로 다른 소비자의 참조를 깎지 않는다
|
||||||
|
expect(mockAudio.stop).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("수동 정지('manual')는 캡처 손실로 보지 않는다", async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const errors: number[] = []
|
||||||
|
svc.on('error', ({ error }) => errors.push(error.code))
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('stopped', { reason: 'manual' })
|
||||||
|
await flush()
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
expect(svc.isActive).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('표시 이벤트', () => {
|
||||||
|
it("모델 확인을 통과한 뒤에만 'recording' phase 를 내보낸다", async () => {
|
||||||
|
mockSTT.getModels.mockReturnValueOnce([
|
||||||
|
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: false, languages: [], accuracy: 2, speed: 4 },
|
||||||
|
])
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const phases: string[] = []
|
||||||
|
const errors: number[] = []
|
||||||
|
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||||
|
svc.on('error', ({ error }) => errors.push(error.code))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
|
||||||
|
expect(phases).toEqual([])
|
||||||
|
expect(errors).toEqual([ErrorCode.STTModelNotFound])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("녹음 → 정지에서 'recording' 다음 'thinking' phase 가 나간다", async () => {
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const phases: string[] = []
|
||||||
|
svc.on('phase', ({ phase }) => phases.push(phase))
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
await svc.stopSession()
|
||||||
|
expect(phases).toEqual(['recording', 'thinking'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('자동 삽입이 실패하면 insert-failed 를 내보낸다', async () => {
|
||||||
|
mockInsert.insertText.mockRejectedValueOnce(new Error('clipboard busy'))
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
const failed: string[] = []
|
||||||
|
svc.on('insert-failed', ({ text }) => failed.push(text))
|
||||||
|
|
||||||
|
await svc.startSession('dictation')
|
||||||
|
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||||
|
advanceClock(1000)
|
||||||
|
await svc.stopSession()
|
||||||
|
await vi.waitFor(() => expect(failed).toEqual(['기본 전사']))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('자막 핫키', () => {
|
||||||
|
it('회의 모드가 소유한 자막 세션은 자막 핫키로 멈추지 않는다', async () => {
|
||||||
|
caption.state = 'active'
|
||||||
|
caption.stop.mockResolvedValue(false)
|
||||||
|
const svc = getVoiceModeService()
|
||||||
|
svc.connectKeyBindings()
|
||||||
|
expect(keyBinding.handler).not.toBeNull()
|
||||||
|
|
||||||
|
keyBinding.handler?.({
|
||||||
|
actionId: 'caption',
|
||||||
|
type: 'pressed',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isDoublePress: false,
|
||||||
|
holdMode: false,
|
||||||
|
} as KeyBindingTriggerPayload)
|
||||||
|
// 소유자('user')를 밝혀 요청하므로 CaptionService 가 거부할 수 있다
|
||||||
|
await vi.waitFor(() => expect(caption.stop).toHaveBeenCalledWith('user'))
|
||||||
|
expect(caption.start).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
72
apps/desktop/tests/main/shutdown-registry.test.ts
Normal file
72
apps/desktop/tests/main/shutdown-registry.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// tests/main/shutdown-registry.test.ts
|
||||||
|
// 종료 등록부 — 역순 정리, 멱등, 멈춘 항목이 종료를 막지 않음.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { ShutdownRegistry } from '../../src/main/shutdown-registry'
|
||||||
|
|
||||||
|
describe('ShutdownRegistry', () => {
|
||||||
|
it('등록의 역순으로 정리한다', async () => {
|
||||||
|
const order: string[] = []
|
||||||
|
const registry = new ShutdownRegistry()
|
||||||
|
registry.register({ name: 'a', dispose: () => { order.push('a') } })
|
||||||
|
registry.register({ name: 'b', dispose: async () => { order.push('b') } })
|
||||||
|
registry.register({ name: 'c', dispose: () => { order.push('c') } })
|
||||||
|
|
||||||
|
const report = await registry.disposeAll(1000)
|
||||||
|
expect(order).toEqual(['c', 'b', 'a'])
|
||||||
|
expect(report.completed).toEqual(['c', 'b', 'a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('여러 번 불러도 한 번만 정리한다', async () => {
|
||||||
|
const dispose = vi.fn()
|
||||||
|
const registry = new ShutdownRegistry()
|
||||||
|
registry.register({ name: 'x', dispose })
|
||||||
|
await Promise.all([registry.disposeAll(1000), registry.disposeAll(1000)])
|
||||||
|
await registry.disposeAll(1000)
|
||||||
|
expect(dispose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('실패한 항목은 기록하고 나머지를 계속 정리한다', async () => {
|
||||||
|
const onError = vi.fn()
|
||||||
|
const later = vi.fn()
|
||||||
|
const registry = new ShutdownRegistry({ onError })
|
||||||
|
registry.register({ name: 'later', dispose: later })
|
||||||
|
registry.register({ name: 'throws', dispose: () => { throw new Error('sync boom') } })
|
||||||
|
registry.register({ name: 'rejects', dispose: async () => { throw new Error('async boom') } })
|
||||||
|
|
||||||
|
const report = await registry.disposeAll(1000)
|
||||||
|
expect(report.failed).toEqual(['rejects', 'throws'])
|
||||||
|
expect(report.completed).toEqual(['later'])
|
||||||
|
expect(later).toHaveBeenCalledTimes(1)
|
||||||
|
expect(onError).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('멈춘 항목이 있어도 상한 시간 안에 끝나고, 남은 항목의 dispose 는 걸어 둔다', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const last = vi.fn()
|
||||||
|
const registry = new ShutdownRegistry()
|
||||||
|
registry.register({ name: 'last', dispose: last })
|
||||||
|
registry.register({ name: 'hangs', dispose: () => new Promise<void>(() => undefined) })
|
||||||
|
|
||||||
|
const running = registry.disposeAll(500)
|
||||||
|
await vi.advanceTimersByTimeAsync(501)
|
||||||
|
const report = await running
|
||||||
|
|
||||||
|
expect(report.timedOut).toEqual(['hangs', 'last'])
|
||||||
|
// 기다리진 않지만 동기 구간(프로세스 kill 등)은 실행된다
|
||||||
|
expect(last).toHaveBeenCalledTimes(1)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('등록 취소한 항목은 정리하지 않는다', async () => {
|
||||||
|
const dispose = vi.fn()
|
||||||
|
const registry = new ShutdownRegistry()
|
||||||
|
const unregister = registry.register({ name: 'x', dispose })
|
||||||
|
unregister()
|
||||||
|
await registry.disposeAll(1000)
|
||||||
|
expect(dispose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
162
apps/desktop/tests/main/windows/popup-navigation.test.ts
Normal file
162
apps/desktop/tests/main/windows/popup-navigation.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// tests/main/windows/popup-navigation.test.ts
|
||||||
|
// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 히스토리 항목을 삽입하던 버그의 회귀 테스트.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
PopupNavigationController,
|
||||||
|
POPUP_NAV_KEYS,
|
||||||
|
HISTORY_INSERT_DELAY_MS,
|
||||||
|
type PopupKind,
|
||||||
|
type PopupWindowPort,
|
||||||
|
} from '../../../src/main/windows/popup-navigation'
|
||||||
|
|
||||||
|
interface FakeWindow extends PopupWindowPort {
|
||||||
|
visible: boolean
|
||||||
|
keys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeWindow(): FakeWindow {
|
||||||
|
const win: FakeWindow = {
|
||||||
|
visible: false,
|
||||||
|
keys: [],
|
||||||
|
show: () => {
|
||||||
|
win.visible = true
|
||||||
|
},
|
||||||
|
hide: () => {
|
||||||
|
win.visible = false
|
||||||
|
},
|
||||||
|
isVisible: () => win.visible,
|
||||||
|
sendKey: (key) => {
|
||||||
|
win.keys.push(key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Electron globalShortcut 처럼 이미 등록된 가속기의 재등록은 false 로 거부한다 */
|
||||||
|
function fakeShortcuts(): {
|
||||||
|
registered: Map<string, () => void>
|
||||||
|
register: (accel: string, cb: () => void) => boolean
|
||||||
|
unregister: (accel: string) => void
|
||||||
|
press: (accel: string) => void
|
||||||
|
} {
|
||||||
|
const registered = new Map<string, () => void>()
|
||||||
|
return {
|
||||||
|
registered,
|
||||||
|
register: (accel, cb) => {
|
||||||
|
if (registered.has(accel)) return false
|
||||||
|
registered.set(accel, cb)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
unregister: (accel) => {
|
||||||
|
registered.delete(accel)
|
||||||
|
},
|
||||||
|
press: (accel) => registered.get(accel)?.(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(): {
|
||||||
|
controller: PopupNavigationController
|
||||||
|
windows: Record<PopupKind, FakeWindow>
|
||||||
|
shortcuts: ReturnType<typeof fakeShortcuts>
|
||||||
|
inserted: string[]
|
||||||
|
scheduled: Array<{ fn: () => void; ms: number }>
|
||||||
|
selections: Array<{ id: string; name: string }>
|
||||||
|
} {
|
||||||
|
const windows = { history: fakeWindow(), command: fakeWindow() }
|
||||||
|
const shortcuts = fakeShortcuts()
|
||||||
|
const inserted: string[] = []
|
||||||
|
const scheduled: Array<{ fn: () => void; ms: number }> = []
|
||||||
|
const selections: Array<{ id: string; name: string }> = []
|
||||||
|
const controller = new PopupNavigationController({
|
||||||
|
windows,
|
||||||
|
shortcuts,
|
||||||
|
insertText: async (text) => {
|
||||||
|
inserted.push(text)
|
||||||
|
},
|
||||||
|
applyCommandSelection: (sel) => selections.push(sel),
|
||||||
|
schedule: (fn, ms) => scheduled.push({ fn, ms }),
|
||||||
|
})
|
||||||
|
return { controller, windows, shortcuts, inserted, scheduled, selections }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PopupNavigationController', () => {
|
||||||
|
it('히스토리 → 명령어 전환 시 히스토리를 닫고 Enter 는 명령어 팝업으로 간다', () => {
|
||||||
|
const { controller, windows, shortcuts } = setup()
|
||||||
|
controller.toggle('history')
|
||||||
|
controller.toggle('command')
|
||||||
|
|
||||||
|
expect(windows.history.visible).toBe(false)
|
||||||
|
expect(windows.command.visible).toBe(true)
|
||||||
|
expect(controller.owner).toBe('command')
|
||||||
|
|
||||||
|
shortcuts.press('Return')
|
||||||
|
expect(windows.command.keys).toEqual(['Enter'])
|
||||||
|
expect(windows.history.keys).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('소유자가 아닌 팝업을 닫아도 소유자의 키를 뺏지 않는다', () => {
|
||||||
|
const { controller, windows, shortcuts } = setup()
|
||||||
|
controller.show('command')
|
||||||
|
controller.dismiss('history')
|
||||||
|
|
||||||
|
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
|
||||||
|
shortcuts.press('Down')
|
||||||
|
expect(windows.command.keys).toEqual(['ArrowDown'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('소유 팝업을 닫으면 전역 내비게이션 키를 모두 해제한다', () => {
|
||||||
|
const { controller, shortcuts } = setup()
|
||||||
|
controller.toggle('history')
|
||||||
|
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
|
||||||
|
controller.toggle('history')
|
||||||
|
expect(shortcuts.registered.size).toBe(0)
|
||||||
|
expect(controller.owner).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('히스토리 선택은 팝업을 닫고 지연 뒤 삽입한다', async () => {
|
||||||
|
const { controller, windows, inserted, scheduled, shortcuts } = setup()
|
||||||
|
controller.show('history')
|
||||||
|
controller.selectHistoryItem('붙여넣을 텍스트')
|
||||||
|
|
||||||
|
expect(windows.history.visible).toBe(false)
|
||||||
|
expect(shortcuts.registered.size).toBe(0)
|
||||||
|
expect(scheduled).toHaveLength(1)
|
||||||
|
expect(scheduled[0].ms).toBe(HISTORY_INSERT_DELAY_MS)
|
||||||
|
expect(inserted).toEqual([])
|
||||||
|
scheduled[0].fn()
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(inserted).toEqual(['붙여넣을 텍스트'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('명령어 선택은 팝업을 닫고 선택을 반영한다', () => {
|
||||||
|
const { controller, windows, selections } = setup()
|
||||||
|
controller.show('command')
|
||||||
|
controller.selectCommand({ id: 'cmd-1', name: '번역' })
|
||||||
|
expect(windows.command.visible).toBe(false)
|
||||||
|
expect(selections).toEqual([{ id: 'cmd-1', name: '번역' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('팝업이 다른 경로로 사라졌으면 다음 키에서 전역 키를 놓는다', () => {
|
||||||
|
const { controller, windows, shortcuts } = setup()
|
||||||
|
controller.show('history')
|
||||||
|
windows.history.visible = false
|
||||||
|
shortcuts.press('Return')
|
||||||
|
expect(shortcuts.registered.size).toBe(0)
|
||||||
|
expect(windows.history.keys).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('등록 실패(다른 앱이 점유)를 경고한다', () => {
|
||||||
|
const warn = vi.fn()
|
||||||
|
const windows = { history: fakeWindow(), command: fakeWindow() }
|
||||||
|
const controller = new PopupNavigationController({
|
||||||
|
windows,
|
||||||
|
shortcuts: { register: () => false, unregister: () => undefined },
|
||||||
|
insertText: async () => undefined,
|
||||||
|
applyCommandSelection: () => undefined,
|
||||||
|
warn,
|
||||||
|
})
|
||||||
|
controller.show('history')
|
||||||
|
expect(warn).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
// tests/main/windows/recording-tip-presenter.test.ts
|
||||||
|
// RecordingTip 표시 정책 — 에러 숨김 타이머가 다음 세션의 녹음 팁을 숨기던 버그의 회귀 테스트.
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
RecordingTipPresenter,
|
||||||
|
type RecordingTipView,
|
||||||
|
} from '../../../src/main/windows/recording-tip-presenter'
|
||||||
|
|
||||||
|
function fakeView(): RecordingTipView & { calls: string[] } {
|
||||||
|
const calls: string[] = []
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
showRecording: () => calls.push('show'),
|
||||||
|
hide: () => calls.push('hide'),
|
||||||
|
showThinking: () => calls.push('thinking'),
|
||||||
|
showError: (message) => calls.push(`error:${message}`),
|
||||||
|
sendAudioLevel: (level) => calls.push(`level:${level}`),
|
||||||
|
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
|
||||||
|
showResult: (text, ms) => calls.push(`result:${text}:${ms}`),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecordingTipPresenter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('에러 직후 3초 안에 시작한 새 세션의 팁은 숨겨지지 않는다', () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
|
||||||
|
presenter.onError('No speech detected')
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
presenter.onSessionStarted()
|
||||||
|
presenter.onPhase('recording')
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
|
||||||
|
expect(view.calls).toEqual(['error:No speech detected', 'show'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('에러는 3초 뒤 한 번 숨긴다', () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
presenter.onError('boom')
|
||||||
|
vi.advanceTimersByTime(2999)
|
||||||
|
expect(view.calls).toEqual(['error:boom'])
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(view.calls).toEqual(['error:boom', 'hide'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("'warning' 은 팁을 건드리지 않는다 (Ollama 미가용 알림 등)", () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
presenter.onPhase('recording')
|
||||||
|
presenter.onError('Ollama unavailable', 'warning')
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(view.calls).toEqual(['show'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('연속 에러는 숨김 예약을 하나로 유지한다', () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
presenter.onError('a')
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
presenter.onError('b')
|
||||||
|
vi.advanceTimersByTime(2000)
|
||||||
|
expect(view.calls).toEqual(['error:a', 'error:b'])
|
||||||
|
vi.advanceTimersByTime(1000)
|
||||||
|
expect(view.calls).toEqual(['error:a', 'error:b', 'hide'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('완료·취소는 즉시 숨기고, 삽입 실패는 결과 팝업을 띄운다', () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
presenter.onPhase('recording')
|
||||||
|
presenter.onPhase('thinking')
|
||||||
|
presenter.onSessionEnded()
|
||||||
|
presenter.onInsertFailed('text')
|
||||||
|
expect(view.calls).toEqual(['show', 'thinking', 'hide', 'result:text:10000'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispose 는 남은 숨김 예약을 지운다', () => {
|
||||||
|
const view = fakeView()
|
||||||
|
const presenter = new RecordingTipPresenter(view)
|
||||||
|
presenter.onError('x')
|
||||||
|
presenter.dispose()
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(view.calls).toEqual(['error:x'])
|
||||||
|
})
|
||||||
|
})
|
||||||
226
apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts
Normal file
226
apps/desktop/tests/red/meeting-mode-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// tests/red/meeting-mode-redteam-r1-3.test.ts
|
||||||
|
// MeetingModeService 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
|
||||||
|
// - 정지 직전 문장(자막 flush 로 확정)이 회의 전사에 남는다
|
||||||
|
// - 전사 줄은 epoch 이 아니라 회의 시작 기준 [MM:SS] 로 저장된다
|
||||||
|
// - 회의는 자기 소유 자막 세션을 시작한다 (마이크 고정, 오버레이·히스토리 저장 없음, 전역 설정 불변)
|
||||||
|
// - 회의 AI 채팅 히스토리는 회의마다 분리된다
|
||||||
|
// - 긴 전사 다듬기가 잘리면 editedTranscript 를 덮어쓰지 않는다
|
||||||
|
// - 회의 삭제 시 로컬 녹음 WAV 도 지운다
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { ErrorCode } from '@d3ro/core/errors'
|
||||||
|
import type { CaptionSegment } from '@d3ro/core/types'
|
||||||
|
import { useRedHarness } from './harness'
|
||||||
|
|
||||||
|
const caption = vi.hoisted(() => ({
|
||||||
|
bus: null as EventEmitter | null,
|
||||||
|
state: 'inactive' as string,
|
||||||
|
startOptions: [] as unknown[],
|
||||||
|
stopOwners: [] as unknown[],
|
||||||
|
flushText: '마지막 문장',
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/CaptionService', async () => {
|
||||||
|
const { EventEmitter: Emitter } = await import('events')
|
||||||
|
const bus = new Emitter()
|
||||||
|
caption.bus = bus
|
||||||
|
const service = {
|
||||||
|
getState: () => caption.state,
|
||||||
|
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
|
||||||
|
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
|
||||||
|
start: async (opts: unknown) => {
|
||||||
|
caption.startOptions.push(opts)
|
||||||
|
caption.state = 'active'
|
||||||
|
},
|
||||||
|
stop: async (owner?: unknown) => {
|
||||||
|
caption.stopOwners.push(owner)
|
||||||
|
if (caption.state !== 'active') return null
|
||||||
|
// CaptionService.stop 은 말하던 문장을 flush 해 'segment' 로 확정한 뒤 끝난다
|
||||||
|
const segment: CaptionSegment = {
|
||||||
|
id: 'seg-last',
|
||||||
|
text: caption.flushText,
|
||||||
|
timestamp: Date.now() + 10_000,
|
||||||
|
isFinal: true,
|
||||||
|
}
|
||||||
|
bus.emit('segment', segment)
|
||||||
|
caption.state = 'inactive'
|
||||||
|
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getCaptionService: () => service,
|
||||||
|
resetCaptionServiceForTests: () => undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/AudioCaptureService', () => ({
|
||||||
|
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
|
||||||
|
calculateRMS: () => 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const llm = vi.hoisted(() => ({
|
||||||
|
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
|
||||||
|
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getLocalLLMService: () => ({
|
||||||
|
isAvailable: () => true,
|
||||||
|
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
|
||||||
|
llm.chatCalls.push(messages)
|
||||||
|
yield '답변'
|
||||||
|
return '답변'
|
||||||
|
},
|
||||||
|
generate: (text: string, options?: { maxTokens?: number }) =>
|
||||||
|
llm.generate ? llm.generate(text, options) : Promise.resolve({ text }),
|
||||||
|
cancelGeneration: vi.fn(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useRedHarness()
|
||||||
|
|
||||||
|
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
|
||||||
|
import { configGet, configSet } from '../../src/main/services/ConfigService'
|
||||||
|
import { getDatabase } from '../../src/main/db'
|
||||||
|
import { meetingSessions } from '../../src/main/db/schema'
|
||||||
|
|
||||||
|
function insertSession(id: string, rawTranscript: string): void {
|
||||||
|
const now = Date.now()
|
||||||
|
getDatabase()
|
||||||
|
.insert(meetingSessions)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
status: 'completed',
|
||||||
|
startedAt: now,
|
||||||
|
endedAt: now + 1000,
|
||||||
|
durationMs: 1000,
|
||||||
|
rawTranscript,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
caption.state = 'inactive'
|
||||||
|
caption.startOptions = []
|
||||||
|
caption.stopOwners = []
|
||||||
|
llm.chatCalls = []
|
||||||
|
llm.generate = null
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('회의 녹음 정지', () => {
|
||||||
|
it('정지 직전 문장을 잃지 않고, 전사는 회의 시작 기준 시각으로 저장된다', async () => {
|
||||||
|
configSet('captionAudioSource', 'system')
|
||||||
|
const meeting = getMeetingModeService()
|
||||||
|
const { sessionId } = await meeting.startRecording()
|
||||||
|
|
||||||
|
expect(caption.startOptions).toEqual([
|
||||||
|
{ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false },
|
||||||
|
])
|
||||||
|
// 전역 설정은 건드리지 않는다
|
||||||
|
expect(configGet('captionAudioSource')).toBe('system')
|
||||||
|
|
||||||
|
const startedAt = Date.now()
|
||||||
|
caption.bus?.emit('segment', {
|
||||||
|
id: 'seg-1',
|
||||||
|
text: '첫 문장',
|
||||||
|
timestamp: startedAt + 5_000,
|
||||||
|
isFinal: true,
|
||||||
|
} satisfies CaptionSegment)
|
||||||
|
|
||||||
|
await meeting.stopRecording()
|
||||||
|
expect(caption.stopOwners).toEqual(['meeting'])
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||||
|
expect(row?.rawTranscript).toBeTruthy()
|
||||||
|
})
|
||||||
|
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||||
|
const lines = (row?.rawTranscript ?? '').split('\n')
|
||||||
|
expect(lines).toHaveLength(2)
|
||||||
|
expect(lines[0]).toMatch(/^\[00:0[45]\] 첫 문장$/)
|
||||||
|
expect(lines[1]).toMatch(/^\[\d{2}:\d{2}\] 마지막 문장$/)
|
||||||
|
// epoch 이 새지 않는다 (예: [29841999:45])
|
||||||
|
expect(row?.rawTranscript).not.toMatch(/\[\d{4,}:/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('회의 AI 채팅', () => {
|
||||||
|
it('회의 A 의 질의응답이 회의 B 의 대화로 새지 않는다', async () => {
|
||||||
|
insertSession('meeting-a', '[00:01] A 회의 내용')
|
||||||
|
insertSession('meeting-b', '[00:01] B 회의 내용')
|
||||||
|
const meeting = getMeetingModeService()
|
||||||
|
|
||||||
|
await meeting.chatWithMeeting('meeting-a', 'A 의 결정은?')
|
||||||
|
await meeting.chatWithMeeting('meeting-b', 'B 의 결정은?')
|
||||||
|
|
||||||
|
const bMessages = llm.chatCalls[1]
|
||||||
|
expect(bMessages.some((m) => m.content.includes('A 의 결정은?'))).toBe(false)
|
||||||
|
expect(bMessages.filter((m) => m.role !== 'system')).toEqual([{ role: 'user', content: 'B 의 결정은?' }])
|
||||||
|
expect(meeting.getMeetingChatHistory('meeting-a')).toHaveLength(2)
|
||||||
|
expect(meeting.getMeetingChatHistory('meeting-b')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Auto Polish', () => {
|
||||||
|
it('LLM 결과가 잘리면 editedTranscript 를 덮어쓰지 않고 실패한다', async () => {
|
||||||
|
const lines = Array.from({ length: 200 }, (_, i) => {
|
||||||
|
const mm = String(Math.floor(i / 60)).padStart(2, '0')
|
||||||
|
const ss = String(i % 60).padStart(2, '0')
|
||||||
|
return `[${mm}:${ss}] 이것은 회의 발언 ${i}번째 문장입니다`
|
||||||
|
})
|
||||||
|
insertSession('long-meeting', lines.join('\n'))
|
||||||
|
// 출력 한도에 걸린 것처럼 입력의 앞 절반만 돌려준다
|
||||||
|
llm.generate = async (text) => {
|
||||||
|
const kept = text.split('\n')
|
||||||
|
return { text: kept.slice(0, Math.ceil(kept.length / 2)).join('\n') }
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(getMeetingModeService().polishTranscript('long-meeting')).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.MeetingPolishFailed,
|
||||||
|
})
|
||||||
|
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, 'long-meeting')).get()
|
||||||
|
expect(row?.editedTranscript ?? null).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('긴 전사는 줄 경계 조각으로 나눠 다듬고 이어 붙인다', async () => {
|
||||||
|
const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i}`)
|
||||||
|
insertSession('chunked', lines.join('\n'))
|
||||||
|
const maxTokens: Array<number | undefined> = []
|
||||||
|
llm.generate = async (text, options) => {
|
||||||
|
maxTokens.push(options?.maxTokens)
|
||||||
|
return { text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const polished = await getMeetingModeService().polishTranscript('chunked')
|
||||||
|
|
||||||
|
expect(maxTokens.length).toBeGreaterThan(1)
|
||||||
|
expect(maxTokens.every((n) => n !== undefined && n <= 4096)).toBe(true)
|
||||||
|
expect(polished.split('\n')).toEqual(lines)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('회의 삭제', () => {
|
||||||
|
it('로컬 녹음 WAV 도 지운다', async () => {
|
||||||
|
insertSession('with-audio', '[00:01] x')
|
||||||
|
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
|
||||||
|
fs.mkdirSync(audioDir, { recursive: true })
|
||||||
|
const wav = path.join(audioDir, 'with-audio.wav')
|
||||||
|
fs.writeFileSync(wav, Buffer.alloc(64))
|
||||||
|
|
||||||
|
getMeetingModeService().deleteSession('with-audio')
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fs.existsSync(wav)).toBe(false))
|
||||||
|
})
|
||||||
|
})
|
||||||
32
apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts
Normal file
32
apps/desktop/tests/red/meeting-summary-redteam-r1-3.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// tests/red/meeting-summary-redteam-r1-3.test.ts
|
||||||
|
// 자막 세션 자동 요약이 자막 세션 id 로 히스토리를 찾아 항상 실패하던 버그의 회귀 테스트.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { useRedHarness, historyInput } from './harness'
|
||||||
|
|
||||||
|
useRedHarness()
|
||||||
|
|
||||||
|
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
|
||||||
|
import { getHistoryService } from '../../src/main/services/HistoryService'
|
||||||
|
|
||||||
|
describe('MeetingSummaryService.onCaptionSessionSaved', () => {
|
||||||
|
it('자막 세션 요약에 실린 historyId 로 히스토리 행을 찾아 요약한다', async () => {
|
||||||
|
const text = '오늘 회의에서는 다음 분기 출시 일정과 담당자를 정했습니다. '.repeat(3)
|
||||||
|
const entry = getHistoryService().create(historyInput({ originalText: text, mode: 'caption' }))
|
||||||
|
const service = getMeetingSummaryService()
|
||||||
|
const summarize = vi.spyOn(service, 'summarize').mockResolvedValue(
|
||||||
|
undefined as unknown as Awaited<ReturnType<typeof service.summarize>>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: entry.id })
|
||||||
|
|
||||||
|
expect(summarize).toHaveBeenCalledWith(entry.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('저장되지 않은 세션(historyId 없음)은 조용히 건너뛴다', async () => {
|
||||||
|
const service = getMeetingSummaryService()
|
||||||
|
const summarize = vi.spyOn(service, 'summarize')
|
||||||
|
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: null })
|
||||||
|
expect(summarize).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
99
packages/core/__tests__/meeting-markdown.test.ts
Normal file
99
packages/core/__tests__/meeting-markdown.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// 회의 내보내기 마크다운 렌더러(core) 회귀 테스트.
|
||||||
|
// 예전 정규식 변환은 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라졌고,
|
||||||
|
// 한 줄짜리 줄들이 한 문단으로 붙었으며, 표가 파이프 그대로 찍혔다.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { Paragraph, Table } from 'docx'
|
||||||
|
import {
|
||||||
|
escapeHtml,
|
||||||
|
markdownToSimpleHtml,
|
||||||
|
parseMarkdownBlocks,
|
||||||
|
renderMeetingPdfHtml,
|
||||||
|
} from '../src/utils/meeting-markdown'
|
||||||
|
import { blocksToDocxElements, markdownToDocx } from '../src/utils/markdown-to-docx'
|
||||||
|
|
||||||
|
describe('parseMarkdownBlocks', () => {
|
||||||
|
it('제목·불릿·체크박스(두 상태)·표·문단·구분선을 나눈다', () => {
|
||||||
|
const md = [
|
||||||
|
'## 결정',
|
||||||
|
'- 타입을 List<String> 으로 변경',
|
||||||
|
'- [ ] 문서화',
|
||||||
|
'- [x] 배포',
|
||||||
|
'',
|
||||||
|
'담당: <TBD>',
|
||||||
|
'줄1',
|
||||||
|
'---',
|
||||||
|
'| 시간 | 내용 |',
|
||||||
|
'|---|---|',
|
||||||
|
'| 00:01 | 시작 |',
|
||||||
|
].join('\n')
|
||||||
|
expect(parseMarkdownBlocks(md)).toEqual([
|
||||||
|
{ type: 'heading', level: 2, text: '결정' },
|
||||||
|
{ type: 'bullet', text: '타입을 List<String> 으로 변경' },
|
||||||
|
{ type: 'checkbox', checked: false, text: '문서화' },
|
||||||
|
{ type: 'checkbox', checked: true, text: '배포' },
|
||||||
|
{ type: 'paragraph', lines: ['담당: <TBD>', '줄1'] },
|
||||||
|
{ type: 'rule' },
|
||||||
|
{ type: 'table', header: ['시간', '내용'], rows: [['00:01', '시작']] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 입력은 빈 목록', () => {
|
||||||
|
expect(parseMarkdownBlocks('')).toEqual([])
|
||||||
|
expect(parseMarkdownBlocks(null)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markdownToSimpleHtml', () => {
|
||||||
|
it('꺾쇠·앰퍼샌드·따옴표를 이스케이프해 PDF 에서 사라지지 않게 한다', () => {
|
||||||
|
const html = markdownToSimpleHtml('- List<String> & "q" <script>alert(1)</script>')
|
||||||
|
expect(html).toContain('List<String> & "q" <script>')
|
||||||
|
expect(html).not.toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('연속된 한 줄짜리 줄은 <br> 로 보존하고, 불릿은 <ul> 로 감싼다', () => {
|
||||||
|
expect(markdownToSimpleHtml('줄1\n줄2')).toBe('<p>줄1<br>줄2</p>')
|
||||||
|
expect(markdownToSimpleHtml('- a\n- [x] b')).toBe('<ul>\n<li>a</li>\n<li>☑ b</li>\n</ul>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파이프 표를 <table> 로 그린다', () => {
|
||||||
|
const html = markdownToSimpleHtml('| 시간 | 내용 |\n|------|------|\n| 00:01 | <시작> |')
|
||||||
|
expect(html).toBe(
|
||||||
|
'<table><thead><tr><th>시간</th><th>내용</th></tr></thead><tbody><tr><td>00:01</td><td><시작></td></tr></tbody></table>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('**굵게** 는 이스케이프 뒤 <strong> 으로 바꾼다', () => {
|
||||||
|
expect(markdownToSimpleHtml('**중요** <b>')).toBe('<p><strong>중요</strong> <b></p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('renderMeetingPdfHtml', () => {
|
||||||
|
it('제목과 메타를 이스케이프하고 본문이 없으면 안내 문구를 쓴다', () => {
|
||||||
|
const html = renderMeetingPdfHtml({
|
||||||
|
title: '<img src=x onerror=alert(1)>',
|
||||||
|
meta: '일시: A & B',
|
||||||
|
markdown: null,
|
||||||
|
emptyMessage: '회의록이 생성되지 않았습니다.',
|
||||||
|
})
|
||||||
|
expect(html).toContain(`<h1>${escapeHtml('<img src=x onerror=alert(1)>')}</h1>`)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).toContain('<p class="meta">일시: A & B</p>')
|
||||||
|
expect(html).toContain('<p>회의록이 생성되지 않았습니다.</p>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markdown-to-docx', () => {
|
||||||
|
it('블록을 문단·표로 매핑한다 (문단 줄마다 하나)', () => {
|
||||||
|
const elements = blocksToDocxElements(
|
||||||
|
parseMarkdownBlocks('# 제목\n- 항목\n- [x] 완료\n줄1\n줄2\n\n| a | b |\n|---|---|\n| 1 | 2 |'),
|
||||||
|
)
|
||||||
|
expect(elements.map((e) => (e instanceof Table ? 'table' : e instanceof Paragraph ? 'p' : '?'))).toEqual([
|
||||||
|
'p', 'p', 'p', 'p', 'p', 'table',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DOCX 버퍼를 만든다', async () => {
|
||||||
|
const buffer = await markdownToDocx('## 결정\n- A', '회의록')
|
||||||
|
expect(buffer.subarray(0, 2).toString('latin1')).toBe('PK')
|
||||||
|
})
|
||||||
|
})
|
||||||
99
packages/core/__tests__/meeting-transcript-segments.test.ts
Normal file
99
packages/core/__tests__/meeting-transcript-segments.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// 회의 전사 조립·화자 라벨·조각 나누기 (core meeting-transcript) 회귀 테스트.
|
||||||
|
// 예전엔 세그먼트의 epoch 시각을 그대로 써 `[29841999:45]` 가 저장됐고,
|
||||||
|
// 화자 구분을 다시 돌리면 라벨이 쌓였다.
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
applySpeakerLabels,
|
||||||
|
buildTranscriptFromSegments,
|
||||||
|
chunkTranscriptByLines,
|
||||||
|
formatTranscriptLines,
|
||||||
|
looksTruncatedRewrite,
|
||||||
|
parseTranscriptLines,
|
||||||
|
} from '../src/meeting-transcript'
|
||||||
|
|
||||||
|
const START = 1_790_000_000_000 // epoch ms
|
||||||
|
|
||||||
|
describe('buildTranscriptFromSegments', () => {
|
||||||
|
it('epoch 시각을 회의 시작 기준 상대 시각으로 바꾼다', () => {
|
||||||
|
const text = buildTranscriptFromSegments(
|
||||||
|
[
|
||||||
|
{ text: '시작합니다', timestamp: START + 5_000 },
|
||||||
|
{ text: ' 다음 안건 ', timestamp: START + 65_000 },
|
||||||
|
],
|
||||||
|
START,
|
||||||
|
)
|
||||||
|
expect(text).toBe('[00:05] 시작합니다\n[01:05] 다음 안건')
|
||||||
|
// core 파서가 다시 읽을 수 있어야 한다 (동기화·상세 화면·화자 구분이 이 형식을 쓴다)
|
||||||
|
expect(parseTranscriptLines(text).map((l) => l.timestampMs)).toEqual([5_000, 65_000])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('60분을 넘는 회의도 파싱 가능한 형식으로 왕복한다', () => {
|
||||||
|
const text = buildTranscriptFromSegments([{ text: '마무리', timestamp: START + 125 * 60_000 + 7_000 }], START)
|
||||||
|
expect(text).toBe('[125:07] 마무리')
|
||||||
|
const lines = parseTranscriptLines(text)
|
||||||
|
expect(lines).toEqual([{ timestampMs: (125 * 60 + 7) * 1000, timed: true, speaker: null, text: '마무리' }])
|
||||||
|
expect(formatTranscriptLines(lines)).toBe(text)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('시작 전 시각은 0 으로 자르고 빈 세그먼트는 버린다', () => {
|
||||||
|
const text = buildTranscriptFromSegments(
|
||||||
|
[
|
||||||
|
{ text: '이전', timestamp: START - 3_000 },
|
||||||
|
{ text: ' ', timestamp: START + 1_000 },
|
||||||
|
],
|
||||||
|
START,
|
||||||
|
)
|
||||||
|
expect(text).toBe('[00:00] 이전')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applySpeakerLabels', () => {
|
||||||
|
const diar = [
|
||||||
|
{ speaker: 'SPEAKER_00', start: 0, end: 10 },
|
||||||
|
{ speaker: 'SPEAKER_01', start: 10.5, end: 30 },
|
||||||
|
]
|
||||||
|
const label = (raw: string): string => raw.replace('SPEAKER_', '화자 ')
|
||||||
|
|
||||||
|
it('시각이 속한 구간의 화자로 라벨을 붙인다 (초 단위 구간)', () => {
|
||||||
|
const lines = parseTranscriptLines('[00:05] 안녕하세요\n[00:20] 반갑습니다\n[00:45] 혼잣말')
|
||||||
|
const labeled = applySpeakerLabels(lines, diar, label)
|
||||||
|
expect(formatTranscriptLines(labeled)).toBe('[00:05] [화자 00] 안녕하세요\n[00:20] [화자 01] 반갑습니다\n[00:45] 혼잣말')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('이미 라벨이 있는 줄은 덧붙이지 않고 교체한다 (다시 돌려도 쌓이지 않음)', () => {
|
||||||
|
const lines = parseTranscriptLines('[00:05] [화자 1] 안녕하세요')
|
||||||
|
const once = applySpeakerLabels(lines, diar, label)
|
||||||
|
const twice = applySpeakerLabels(parseTranscriptLines(formatTranscriptLines(once)), diar, label)
|
||||||
|
expect(formatTranscriptLines(twice)).toBe('[00:05] [화자 00] 안녕하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('시각 없는 줄은 그대로 둔다', () => {
|
||||||
|
const lines = parseTranscriptLines('메모 없는 줄')
|
||||||
|
expect(applySpeakerLabels(lines, diar, label)).toEqual(lines)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parse→format 은 빈 줄을 버리고 공백을 정리한다', () => {
|
||||||
|
expect(formatTranscriptLines(parseTranscriptLines('[00:01] 가\n\n\n[00:02] 나 '))).toBe('[00:01] 가\n[00:02] 나')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
|
||||||
|
it('줄 경계에서만 나누고 빈 줄은 버린다', () => {
|
||||||
|
const text = ['[00:01] aaaa', '', '[00:02] bbbb', '[00:03] cccc'].join('\n')
|
||||||
|
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
|
||||||
|
const long = `[00:01] ${'x'.repeat(50)}`
|
||||||
|
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
|
||||||
|
const input = Array.from({ length: 10 }, (_, i) => `[00:0${i}] 문장 ${i}`).join('\n')
|
||||||
|
expect(looksTruncatedRewrite(input, input)).toBe(false)
|
||||||
|
expect(looksTruncatedRewrite(input, input.split('\n').slice(0, 5).join('\n'))).toBe(true)
|
||||||
|
expect(looksTruncatedRewrite(input, ' ')).toBe(true)
|
||||||
|
expect(looksTruncatedRewrite('시각 없는 긴 문장입니다', '시각 없는 긴 문장')).toBe(false)
|
||||||
|
expect(looksTruncatedRewrite('시각 없는 아주 긴 문장입니다 정말로', '시각')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -52,3 +52,110 @@ export function formatTranscriptLines(lines: readonly TranscriptLine[]): string
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 캡처 세그먼트(절대 epoch 타임스탬프)를 회의 시작 기준 전사 줄로 조립한다. */
|
||||||
|
export interface TimedSegmentInput {
|
||||||
|
text: string
|
||||||
|
/** 절대 시각(epoch ms). CaptionService 가 Date.now() 로 찍는다. */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의 세그먼트로 전사 텍스트를 만든다.
|
||||||
|
*
|
||||||
|
* 세그먼트 시각은 epoch 이므로 반드시 회의 시작 시각을 빼서 상대 시각으로 바꾼다
|
||||||
|
* (예전엔 epoch 을 그대로 써 `[29841999:45]` 가 저장되어 파싱·화자 구분·동기화가 모두 깨졌다).
|
||||||
|
* 시작 전 시각은 0 으로 자르고, 빈 텍스트 세그먼트는 버린다.
|
||||||
|
*/
|
||||||
|
export function buildTranscriptFromSegments(
|
||||||
|
segments: readonly TimedSegmentInput[],
|
||||||
|
sessionStartedAt: number,
|
||||||
|
): string {
|
||||||
|
const lines: TranscriptLine[] = []
|
||||||
|
for (const segment of segments) {
|
||||||
|
const text = segment.text.trim()
|
||||||
|
if (!text) continue
|
||||||
|
lines.push({
|
||||||
|
timestampMs: Math.max(0, segment.timestamp - sessionStartedAt),
|
||||||
|
timed: true,
|
||||||
|
speaker: null,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return formatTranscriptLines(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 화자 구분 결과 구간. start/end 는 초 단위(pyannote 응답 형식). */
|
||||||
|
export interface SpeakerSegment {
|
||||||
|
speaker: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전사 줄에 화자 구분 결과를 적용한다. 시각이 있는 줄의 시각이 속한 구간의 화자로
|
||||||
|
* speaker 필드를 **교체**한다(앞에 덧붙이지 않으므로 다시 돌려도 라벨이 쌓이지 않는다).
|
||||||
|
* 구간에 속하지 않거나 시각이 없는 줄은 그대로 둔다.
|
||||||
|
*
|
||||||
|
* @param labelFor 원시 화자 id(예: SPEAKER_01)를 표시 라벨로 바꾸는 함수 — 표시 문구는 호출자 소관
|
||||||
|
*/
|
||||||
|
export function applySpeakerLabels(
|
||||||
|
lines: readonly TranscriptLine[],
|
||||||
|
segments: readonly SpeakerSegment[],
|
||||||
|
labelFor: (rawSpeaker: string) => string = (raw) => raw,
|
||||||
|
): TranscriptLine[] {
|
||||||
|
return lines.map((line) => {
|
||||||
|
if (!line.timed) return line
|
||||||
|
const seconds = line.timestampMs / 1000
|
||||||
|
const match = segments.find((s) => seconds >= s.start && seconds <= s.end)
|
||||||
|
if (!match) return line
|
||||||
|
return { ...line, speaker: labelFor(match.speaker) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기의 조각들로 나눈다.
|
||||||
|
* 한 줄이 maxChars 보다 길면 그 줄 하나로 한 조각을 만든다(줄 중간은 자르지 않는다).
|
||||||
|
* 빈 줄은 버린다.
|
||||||
|
*/
|
||||||
|
export function chunkTranscriptByLines(text: string | null | undefined, maxChars: number): string[] {
|
||||||
|
if (!text) return []
|
||||||
|
const chunks: string[] = []
|
||||||
|
let current: string[] = []
|
||||||
|
let size = 0
|
||||||
|
for (const raw of text.split('\n')) {
|
||||||
|
const line = raw.trimEnd()
|
||||||
|
if (!line.trim()) continue
|
||||||
|
if (current.length > 0 && size + line.length + 1 > maxChars) {
|
||||||
|
chunks.push(current.join('\n'))
|
||||||
|
current = []
|
||||||
|
size = 0
|
||||||
|
}
|
||||||
|
current.push(line)
|
||||||
|
size += line.length + 1
|
||||||
|
}
|
||||||
|
if (current.length > 0) chunks.push(current.join('\n'))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMED_LINE = /^\s*\[\d{1,3}:\d{2}\]/
|
||||||
|
|
||||||
|
function countTimedLines(text: string): number {
|
||||||
|
return text.split('\n').filter((l) => TIMED_LINE.test(l)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 이 전사 조각을 다시 쓴 결과가 잘렸는지(출력 토큰 한도 등) 추정한다.
|
||||||
|
* - 입력에 시각 줄이 있으면: 출력의 시각 줄이 입력의 80% 미만이면 잘린 것으로 본다.
|
||||||
|
* - 없으면: 출력 길이가 입력의 50% 미만이면 잘린 것으로 본다.
|
||||||
|
* 잘린 결과로 전사를 덮어쓰면 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라진다.
|
||||||
|
*/
|
||||||
|
export function looksTruncatedRewrite(input: string, output: string): boolean {
|
||||||
|
const trimmed = output.trim()
|
||||||
|
if (!trimmed) return true
|
||||||
|
const inputTimed = countTimedLines(input)
|
||||||
|
if (inputTimed > 0) {
|
||||||
|
return countTimedLines(trimmed) < Math.ceil(inputTimed * 0.8)
|
||||||
|
}
|
||||||
|
return trimmed.length < input.trim().length * 0.5
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,90 @@
|
||||||
// src/main/utils/markdown-to-docx.ts
|
// packages/core/src/utils/markdown-to-docx.ts
|
||||||
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티
|
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티.
|
||||||
|
// 마크다운 해석은 meeting-markdown 의 parseMarkdownBlocks 가 단일 정본이다 —
|
||||||
|
// 여기서는 블록을 docx 요소로 렌더링만 한다(docx 의존은 이 서브패스에만 격리).
|
||||||
|
|
||||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
|
import {
|
||||||
|
Document,
|
||||||
|
Packer,
|
||||||
|
Paragraph,
|
||||||
|
Table,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
TextRun,
|
||||||
|
HeadingLevel,
|
||||||
|
WidthType,
|
||||||
|
} from 'docx'
|
||||||
|
import {
|
||||||
|
CHECKBOX_CHECKED,
|
||||||
|
CHECKBOX_UNCHECKED,
|
||||||
|
parseMarkdownBlocks,
|
||||||
|
type MarkdownBlock,
|
||||||
|
} from './meeting-markdown'
|
||||||
|
|
||||||
|
const HEADING_LEVELS = {
|
||||||
|
1: HeadingLevel.HEADING_1,
|
||||||
|
2: HeadingLevel.HEADING_2,
|
||||||
|
3: HeadingLevel.HEADING_3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function tableCell(text: string, bold: boolean): TableCell {
|
||||||
|
return new TableCell({
|
||||||
|
children: [new Paragraph({ children: [new TextRun({ text, bold })] })],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 블록을 docx 문서 요소(문단/표)로 바꾼다. 순수 매핑이라 개수·종류로 검증할 수 있다. */
|
||||||
|
export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array<Paragraph | Table> {
|
||||||
|
const elements: Array<Paragraph | Table> = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'heading':
|
||||||
|
elements.push(new Paragraph({ text: block.text, heading: HEADING_LEVELS[block.level] }))
|
||||||
|
break
|
||||||
|
case 'bullet':
|
||||||
|
elements.push(new Paragraph({ text: block.text, bullet: { level: 0 } }))
|
||||||
|
break
|
||||||
|
case 'checkbox':
|
||||||
|
elements.push(
|
||||||
|
new Paragraph({
|
||||||
|
text: `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} ${block.text}`,
|
||||||
|
bullet: { level: 0 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'paragraph':
|
||||||
|
// 줄마다 문단 하나 — 한 줄짜리 항목이 한 문단으로 합쳐지지 않게 한다
|
||||||
|
for (const line of block.lines) {
|
||||||
|
elements.push(new Paragraph({ children: [new TextRun(line)] }))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'table': {
|
||||||
|
const rows: TableRow[] = []
|
||||||
|
if (block.header.length > 0) {
|
||||||
|
rows.push(new TableRow({ children: block.header.map((c) => tableCell(c, true)), tableHeader: true }))
|
||||||
|
}
|
||||||
|
for (const row of block.rows) {
|
||||||
|
rows.push(new TableRow({ children: row.map((c) => tableCell(c, false)) }))
|
||||||
|
}
|
||||||
|
if (rows.length > 0) {
|
||||||
|
elements.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'rule':
|
||||||
|
// 구분선은 문서 흐름상 빈 문단으로 둔다
|
||||||
|
elements.push(new Paragraph({ text: '' }))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
|
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
|
||||||
const paragraphs: Paragraph[] = []
|
const children: Array<Paragraph | Table> = [
|
||||||
|
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
|
||||||
// 타이틀 추가
|
...blocksToDocxElements(parseMarkdownBlocks(markdown)),
|
||||||
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE }))
|
]
|
||||||
|
const doc = new Document({ sections: [{ children }] })
|
||||||
// 줄 단위 파싱
|
|
||||||
for (const line of markdown.split('\n')) {
|
|
||||||
if (line.startsWith('## ')) {
|
|
||||||
paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
|
|
||||||
} else if (line.startsWith('### ')) {
|
|
||||||
paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
|
|
||||||
} else if (line.startsWith('# ')) {
|
|
||||||
paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
|
|
||||||
} else if (line.startsWith('- [ ] ')) {
|
|
||||||
paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } }))
|
|
||||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
||||||
paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
|
|
||||||
} else if (line.trim()) {
|
|
||||||
paragraphs.push(new Paragraph({ children: [new TextRun(line)] }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = new Document({ sections: [{ children: paragraphs }] })
|
|
||||||
return Buffer.from(await Packer.toBuffer(doc))
|
return Buffer.from(await Packer.toBuffer(doc))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
// packages/core/src/utils/meeting-markdown.ts
|
// packages/core/src/utils/meeting-markdown.ts
|
||||||
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
|
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티.
|
||||||
|
//
|
||||||
|
// 회의 내보내기(PDF/DOCX)가 쓰는 마크다운 해석의 단일 정본이다. 예전엔 정규식 체인
|
||||||
|
// (markdownToSimpleHtml), DOCX 줄 스위치, 쓰이지 않던 parseMinutes 가 제각각 해석했고,
|
||||||
|
// HTML 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라지고 줄바꿈·표가 깨졌다.
|
||||||
|
// 이제 parseMarkdownBlocks 한 곳에서 블록으로 나누고, HTML/DOCX 는 블록을 렌더링만 한다.
|
||||||
|
|
||||||
import type { MeetingMinutes, MeetingSessionDetail } from '../types'
|
import type { MeetingSessionDetail } from '../types'
|
||||||
|
|
||||||
export function formatTime(ms: number): string {
|
export function formatTime(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000)
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
|
@ -24,55 +29,6 @@ export function formatDateRange(startedAt: number, endedAt: number | null): stri
|
||||||
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
|
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseMinutes(markdown: string): MeetingMinutes {
|
|
||||||
const minutes: MeetingMinutes = {
|
|
||||||
summary: '',
|
|
||||||
decisions: [],
|
|
||||||
actionItems: [],
|
|
||||||
timeline: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = markdown.split(/^## /m)
|
|
||||||
for (const section of sections) {
|
|
||||||
const lines = section.trim().split('\n')
|
|
||||||
const heading = lines[0]?.toLowerCase() ?? ''
|
|
||||||
const body = lines.slice(1).join('\n').trim()
|
|
||||||
|
|
||||||
if (heading.includes('요약') || heading.includes('summary')) {
|
|
||||||
minutes.summary = body
|
|
||||||
} else if (heading.includes('결정') || heading.includes('decision')) {
|
|
||||||
minutes.decisions = body
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
|
||||||
.map((l) => l.replace(/^[-*]\s*/, '').trim())
|
|
||||||
} else if (heading.includes('할 일') || heading.includes('action')) {
|
|
||||||
minutes.actionItems = body
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
|
||||||
.map((l) => {
|
|
||||||
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
|
|
||||||
return { task: text }
|
|
||||||
})
|
|
||||||
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
|
|
||||||
const tableRows = body
|
|
||||||
.split('\n')
|
|
||||||
.filter((l) => l.includes('|') && !l.includes('---'))
|
|
||||||
for (const row of tableRows) {
|
|
||||||
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
|
|
||||||
if (cells.length >= 2) {
|
|
||||||
minutes.timeline.push({
|
|
||||||
time: cells[0],
|
|
||||||
content: cells[1],
|
|
||||||
type: cells[1].includes('📝') ? 'memo' : 'transcript',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildExportMarkdown(session: MeetingSessionDetail): string {
|
export function buildExportMarkdown(session: MeetingSessionDetail): string {
|
||||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||||
const memoTable = session.memos.length > 0
|
const memoTable = session.memos.length > 0
|
||||||
|
|
@ -102,15 +58,228 @@ ${session.rawTranscript ?? '(없음)'}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
|
// ============================================================
|
||||||
export function markdownToSimpleHtml(md: string): string {
|
// 블록 파서 (순수)
|
||||||
return md
|
// ============================================================
|
||||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
||||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
export type MarkdownBlock =
|
||||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
| { type: 'heading'; level: 1 | 2 | 3; text: string }
|
||||||
.replace(/^- \[.\] (.+)$/gm, '<li>$1</li>')
|
| { type: 'bullet'; text: string }
|
||||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
| { type: 'checkbox'; checked: boolean; text: string }
|
||||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
| { type: 'table'; header: string[]; rows: string[][] }
|
||||||
.replace(/\n{2,}/g, '</p><p>')
|
| { type: 'paragraph'; lines: string[] }
|
||||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
| { type: 'rule' }
|
||||||
|
|
||||||
|
const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/
|
||||||
|
const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/
|
||||||
|
const BULLET = /^\s*[-*+]\s+(.*)$/
|
||||||
|
const RULE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/
|
||||||
|
const TABLE_ROW = /^\s*\|.*\|?\s*$/
|
||||||
|
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/
|
||||||
|
|
||||||
|
function splitTableRow(line: string): string[] {
|
||||||
|
let body = line.trim()
|
||||||
|
if (body.startsWith('|')) body = body.slice(1)
|
||||||
|
if (body.endsWith('|')) body = body.slice(0, -1)
|
||||||
|
return body.split('|').map((cell) => cell.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마크다운을 블록 목록으로 나눈다 (제목, 불릿, 체크박스, 표, 문단, 구분선). */
|
||||||
|
export function parseMarkdownBlocks(markdown: string | null | undefined): MarkdownBlock[] {
|
||||||
|
if (!markdown) return []
|
||||||
|
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
|
||||||
|
const blocks: MarkdownBlock[] = []
|
||||||
|
let paragraph: string[] = []
|
||||||
|
|
||||||
|
const flushParagraph = (): void => {
|
||||||
|
if (paragraph.length > 0) {
|
||||||
|
blocks.push({ type: 'paragraph', lines: paragraph })
|
||||||
|
paragraph = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (!line.trim()) {
|
||||||
|
flushParagraph()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TABLE_ROW.test(line) && line.trim().startsWith('|')) {
|
||||||
|
flushParagraph()
|
||||||
|
const tableLines: string[] = []
|
||||||
|
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||||
|
tableLines.push(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
const hasHeader = tableLines.length >= 2 && TABLE_SEPARATOR.test(tableLines[1])
|
||||||
|
const rows = tableLines.filter((l) => !TABLE_SEPARATOR.test(l)).map(splitTableRow)
|
||||||
|
if (hasHeader) {
|
||||||
|
const [header, ...body] = rows
|
||||||
|
blocks.push({ type: 'table', header, rows: body })
|
||||||
|
} else {
|
||||||
|
blocks.push({ type: 'table', header: [], rows })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RULE.test(line)) {
|
||||||
|
flushParagraph()
|
||||||
|
blocks.push({ type: 'rule' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = HEADING.exec(line)
|
||||||
|
if (heading) {
|
||||||
|
flushParagraph()
|
||||||
|
const level = Math.min(3, heading[1].length) as 1 | 2 | 3
|
||||||
|
blocks.push({ type: 'heading', level, text: heading[2] })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkbox = CHECKBOX.exec(line)
|
||||||
|
if (checkbox) {
|
||||||
|
flushParagraph()
|
||||||
|
blocks.push({ type: 'checkbox', checked: checkbox[1] !== ' ', text: checkbox[2].trim() })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const bullet = BULLET.exec(line)
|
||||||
|
if (bullet) {
|
||||||
|
flushParagraph()
|
||||||
|
blocks.push({ type: 'bullet', text: bullet[1].trim() })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
paragraph.push(line.trim())
|
||||||
|
}
|
||||||
|
flushParagraph()
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HTML 렌더링 (PDF 생성용)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const HTML_ESCAPES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이스케이프 후 인라인 강조(**굵게**)만 태그로 바꾼다. */
|
||||||
|
function renderInline(text: string): string {
|
||||||
|
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHECKBOX_UNCHECKED = '☐'
|
||||||
|
export const CHECKBOX_CHECKED = '☑'
|
||||||
|
|
||||||
|
export function renderBlocksToHtml(blocks: readonly MarkdownBlock[]): string {
|
||||||
|
const out: string[] = []
|
||||||
|
let listOpen = false
|
||||||
|
const closeList = (): void => {
|
||||||
|
if (listOpen) {
|
||||||
|
out.push('</ul>')
|
||||||
|
listOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'bullet' || block.type === 'checkbox') {
|
||||||
|
if (!listOpen) {
|
||||||
|
out.push('<ul>')
|
||||||
|
listOpen = true
|
||||||
|
}
|
||||||
|
const marker = block.type === 'checkbox'
|
||||||
|
? `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} `
|
||||||
|
: ''
|
||||||
|
out.push(`<li>${marker}${renderInline(block.text)}</li>`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
switch (block.type) {
|
||||||
|
case 'heading':
|
||||||
|
out.push(`<h${block.level}>${renderInline(block.text)}</h${block.level}>`)
|
||||||
|
break
|
||||||
|
case 'paragraph':
|
||||||
|
out.push(`<p>${block.lines.map(renderInline).join('<br>')}</p>`)
|
||||||
|
break
|
||||||
|
case 'rule':
|
||||||
|
out.push('<hr>')
|
||||||
|
break
|
||||||
|
case 'table': {
|
||||||
|
const head = block.header.length > 0
|
||||||
|
? `<thead><tr>${block.header.map((c) => `<th>${renderInline(c)}</th>`).join('')}</tr></thead>`
|
||||||
|
: ''
|
||||||
|
const body = block.rows
|
||||||
|
.map((row) => `<tr>${row.map((c) => `<td>${renderInline(c)}</td>`).join('')}</tr>`)
|
||||||
|
.join('')
|
||||||
|
out.push(`<table>${head}<tbody>${body}</tbody></table>`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
closeList()
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마크다운→HTML 변환 (PDF 생성용). 모든 텍스트를 이스케이프한다. */
|
||||||
|
export function markdownToSimpleHtml(md: string): string {
|
||||||
|
return renderBlocksToHtml(parseMarkdownBlocks(md))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeetingPdfHtmlInput {
|
||||||
|
title: string
|
||||||
|
/** 제목 아래 한 줄(일시·소요 등). 호출자가 번역된 라벨로 만든다. */
|
||||||
|
meta?: string | null
|
||||||
|
markdown: string | null | undefined
|
||||||
|
/** 본문이 비었을 때 보여 줄 문구 (호출자가 번역해 넘긴다) */
|
||||||
|
emptyMessage?: string
|
||||||
|
/** html lang 속성 */
|
||||||
|
lang?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의 PDF 문서 템플릿(단일 정본). Electron 은 이 HTML 을 불러와 인쇄만 한다.
|
||||||
|
* 제목·메타·본문 모두 이스케이프된다.
|
||||||
|
*/
|
||||||
|
export function renderMeetingPdfHtml(input: MeetingPdfHtmlInput): string {
|
||||||
|
const bodyHtml = input.markdown && input.markdown.trim()
|
||||||
|
? markdownToSimpleHtml(input.markdown)
|
||||||
|
: input.emptyMessage
|
||||||
|
? `<p>${escapeHtml(input.emptyMessage)}</p>`
|
||||||
|
: ''
|
||||||
|
const meta = input.meta ? `\n <p class="meta">${escapeHtml(input.meta)}</p>` : ''
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="${escapeHtml(input.lang ?? 'ko')}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Malgun Gothic', -apple-system, BlinkMacSystemFont, "Segoe UI", 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; }
|
||||||
|
h3 { font-size: 14px; color: #444; margin-top: 16px; }
|
||||||
|
p { line-height: 1.6; }
|
||||||
|
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; }
|
||||||
|
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
|
||||||
|
ul { padding-left: 20px; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${escapeHtml(input.title)}</h1>${meta}
|
||||||
|
${bodyHtml}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue