refactor(desktop): session-scoped voice runtime, STT/LLM ports, caption ownership and meeting export fixes
This commit is contained in:
parent
0adedf5e7b
commit
83cf9133ac
39 changed files with 4787 additions and 1061 deletions
|
|
@ -9,7 +9,12 @@ import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingServ
|
|||
import { acquireGlobalInputHook } from './services/global-input-hook'
|
||||
import { trackModifierKeys } from './services/modifier-state'
|
||||
import { getVoiceModeService } from './services/VoiceModeService'
|
||||
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService'
|
||||
import {
|
||||
startLocalLLMAvailability,
|
||||
getLocalLLMService,
|
||||
disposeLocalLLMService,
|
||||
} from './services/LocalLLMService'
|
||||
import { disposeLocalSTTServiceIfStarted } from './services/LocalSTTService'
|
||||
import { getHistoryService } from './services/HistoryService'
|
||||
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
||||
import { getTextInsertService } from './services/TextInsertService'
|
||||
|
|
@ -34,14 +39,16 @@ import {
|
|||
hideCommandPopup,
|
||||
sendKeyToCommandPopup,
|
||||
isCommandPopupVisible,
|
||||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
showSuggestionOverlay,
|
||||
updateSuggestionOverlay,
|
||||
hideSuggestionOverlay,
|
||||
isSuggestionOverlayVisible,
|
||||
} from './windows/WindowManager'
|
||||
import { createTray } from './windows/TrayManager'
|
||||
import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter'
|
||||
import { PopupNavigationController } from './windows/popup-navigation'
|
||||
import { setupLifecycle } from './lifecycle'
|
||||
import { getShutdownRegistry } from './shutdown-registry'
|
||||
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
|
||||
import { registerAllIpcHandlers } from './ipc'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
|
@ -56,6 +63,11 @@ interface BootstrapStep {
|
|||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
// 종료 훅을 가장 먼저 건다 — 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우고
|
||||
// 등록된 자원을 정리해야 한다. (index.ts 의 뒤늦은 호출은 멱등이라 아무것도 하지 않는다)
|
||||
setupLifecycle()
|
||||
registerCoreShutdownSteps()
|
||||
|
||||
const steps: BootstrapStep[] = [
|
||||
{ name: 'logger', critical: false, fn: initLogger },
|
||||
{ name: 'config', critical: false, fn: initConfig },
|
||||
|
|
@ -102,6 +114,16 @@ export async function bootstrap(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다.
|
||||
* (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다)
|
||||
*/
|
||||
function registerCoreShutdownSteps(): void {
|
||||
const registry = getShutdownRegistry()
|
||||
registry.register({ name: 'local-llm', dispose: () => disposeLocalLLMService() })
|
||||
registry.register({ name: 'local-stt', dispose: () => disposeLocalSTTServiceIfStarted() })
|
||||
}
|
||||
|
||||
async function initLogger(): Promise<void> {
|
||||
initLoggerService()
|
||||
}
|
||||
|
|
@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise<void> {
|
|||
})
|
||||
|
||||
telemetry.start()
|
||||
|
||||
// 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다
|
||||
const registry = getShutdownRegistry()
|
||||
registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() })
|
||||
registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() })
|
||||
}
|
||||
|
||||
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
|
||||
|
|
@ -285,107 +312,111 @@ async function initAutoLaunch(): Promise<void> {
|
|||
|
||||
async function initAutoUpdate(): Promise<void> {
|
||||
const { getUpdateService } = await import('./services/UpdateService')
|
||||
getUpdateService().initialize()
|
||||
const updates = getUpdateService()
|
||||
updates.initialize()
|
||||
getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() })
|
||||
}
|
||||
|
||||
async function initPopupWindows(): Promise<void> {
|
||||
preloadPopupWindows()
|
||||
setupHistoryPopupIPC()
|
||||
|
||||
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
|
||||
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
|
||||
|
||||
const popups = createPopupNavigationController()
|
||||
getShutdownRegistry().register({ name: 'popup-navigation', dispose: () => popups.dispose() })
|
||||
|
||||
// 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다.
|
||||
getKeyBindingService().on('triggered', (payload) => {
|
||||
if (payload.type !== 'pressed') return
|
||||
if (payload.actionId === 'history-popup') {
|
||||
toggleHistoryPopup()
|
||||
popups.toggle('history')
|
||||
} else if (payload.actionId === 'command-popup') {
|
||||
toggleCommandPopup()
|
||||
popups.toggle('command')
|
||||
}
|
||||
})
|
||||
|
||||
setupCommandPopupIPC()
|
||||
setupPopupIPC(popups)
|
||||
}
|
||||
|
||||
function toggleHistoryPopup(): void {
|
||||
if (isHistoryPopupVisible()) {
|
||||
hideHistoryPopup()
|
||||
unregisterPopupNavKeys()
|
||||
return
|
||||
}
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
registerPopupNavKeys()
|
||||
/** 팝업 컨트롤러를 Electron 창·전역 단축키·서비스 어댑터로 조립한다. */
|
||||
function createPopupNavigationController(): PopupNavigationController {
|
||||
return new PopupNavigationController({
|
||||
windows: {
|
||||
history: {
|
||||
show: () => {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
},
|
||||
hide: () => hideHistoryPopup(),
|
||||
isVisible: () => isHistoryPopupVisible(),
|
||||
sendKey: (key) => sendKeyToHistoryPopup(key),
|
||||
},
|
||||
command: {
|
||||
show: () => {
|
||||
const instructions = getCustomInstructionService().getAll()
|
||||
const activeId = configGet('activeInstructionId') as string | null
|
||||
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
|
||||
},
|
||||
hide: () => hideCommandPopup(),
|
||||
isVisible: () => isCommandPopupVisible(),
|
||||
sendKey: (key) => sendKeyToCommandPopup(key),
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
register: (accelerator, callback) => globalShortcut.register(accelerator, callback),
|
||||
unregister: (accelerator) => globalShortcut.unregister(accelerator),
|
||||
},
|
||||
insertText: async (text) => {
|
||||
await getTextInsertService().insertText(text)
|
||||
},
|
||||
applyCommandSelection: ({ id, name }) => {
|
||||
if (id) {
|
||||
// 명령어 선택 → 활성 명령어로 설정
|
||||
configSet('activeInstructionId', id)
|
||||
configSet('defaultLLMAction', 'custom')
|
||||
logger.info(`Active command set: ${name} (${id})`)
|
||||
} else {
|
||||
// 선택 해제 → 명령어 없음 (원본 삽입)
|
||||
configSet('activeInstructionId', '')
|
||||
configSet('defaultLLMAction', 'none')
|
||||
logger.info('Active command cleared (none)')
|
||||
}
|
||||
// CMD 페이지 UI 갱신 알림
|
||||
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: id })
|
||||
},
|
||||
warn: (message) => logger.warn(message),
|
||||
})
|
||||
}
|
||||
|
||||
function toggleCommandPopup(): void {
|
||||
if (isCommandPopupVisible()) {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
return
|
||||
}
|
||||
const instructions = getCustomInstructionService().getAll()
|
||||
const activeId = configGet('activeInstructionId') as string | null
|
||||
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
|
||||
registerPopupNavKeys('command')
|
||||
}
|
||||
|
||||
// 히스토리 팝업 키 네비게이션 등록/해제
|
||||
const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [
|
||||
{ accel: 'Up', key: 'ArrowUp' },
|
||||
{ accel: 'Down', key: 'ArrowDown' },
|
||||
{ accel: 'Return', key: 'Enter' },
|
||||
{ accel: 'Escape', key: 'Escape' },
|
||||
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
|
||||
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
|
||||
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
|
||||
]
|
||||
|
||||
function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void {
|
||||
for (const { accel, key } of POPUP_NAV_KEYS) {
|
||||
try {
|
||||
globalShortcut.register(accel, () => {
|
||||
if (target === 'history' && isHistoryPopupVisible()) {
|
||||
sendKeyToHistoryPopup(key)
|
||||
} else if (target === 'command' && isCommandPopupVisible()) {
|
||||
sendKeyToCommandPopup(key)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 일부 키는 globalShortcut으로 등록 불가할 수 있음
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterPopupNavKeys(): void {
|
||||
for (const { accel } of POPUP_NAV_KEYS) {
|
||||
try {
|
||||
globalShortcut.unregister(accel)
|
||||
} catch {
|
||||
// 이미 해제된 경우 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function initVoiceMode(): Promise<void> {
|
||||
const voiceMode = getVoiceModeService()
|
||||
voiceMode.connectKeyBindings()
|
||||
|
||||
const soundEffect = getSoundEffectService()
|
||||
|
||||
// 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리)
|
||||
// RecordingTip·결과 팝업 표시는 프레젠터 한 곳이 담당한다 (취소 가능한 에러 숨김 타이머 하나)
|
||||
const tip = new RecordingTipPresenter(createWindowRecordingTipView())
|
||||
getShutdownRegistry().register({ name: 'recording-tip', dispose: () => tip.dispose() })
|
||||
getShutdownRegistry().register({ name: 'voice-mode', dispose: () => voiceMode.dispose() })
|
||||
voiceMode.on('phase', ({ phase }) => tip.onPhase(phase))
|
||||
voiceMode.on('audio-level', ({ level }) => tip.onAudioLevel(level))
|
||||
voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text))
|
||||
voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text))
|
||||
|
||||
// 효과음 연동
|
||||
voiceMode.on('session-started', () => {
|
||||
tip.onSessionStarted()
|
||||
soundEffect.play('recording-start')
|
||||
})
|
||||
|
||||
voiceMode.on('session-completed', ({ session, finalText }) => {
|
||||
tip.onSessionEnded()
|
||||
soundEffect.play('recording-stop')
|
||||
|
||||
persistCompletedVoiceSessionSafe(
|
||||
(error, failedSession) => {
|
||||
voiceMode.emit('error', { error, session: failedSession })
|
||||
(error) => {
|
||||
voiceMode.emit('error', { error, session })
|
||||
},
|
||||
session,
|
||||
finalText,
|
||||
|
|
@ -398,13 +429,13 @@ async function initVoiceMode(): Promise<void> {
|
|||
if (reason !== 'too-short') {
|
||||
soundEffect.play('cancel')
|
||||
}
|
||||
hideRecordingTip()
|
||||
tip.onSessionEnded()
|
||||
})
|
||||
|
||||
voiceMode.on('error', ({ error }) => {
|
||||
soundEffect.play('error')
|
||||
updateRecordingTipState('error', { errorMessage: error.message })
|
||||
setTimeout(() => hideRecordingTip(), 3000)
|
||||
voiceMode.on('error', ({ error, severity }) => {
|
||||
// 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
|
||||
if (severity !== 'warning') soundEffect.play('error')
|
||||
tip.onError(error.message, severity)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -447,7 +478,7 @@ async function initMeetingSummaryWiring(): Promise<void> {
|
|||
const captionService = getCaptionService()
|
||||
const summaryService = getMeetingSummaryService()
|
||||
|
||||
captionService.on('session-saved', (summary: { sessionId: string }) => {
|
||||
captionService.on('session-saved', (summary) => {
|
||||
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
|
||||
import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
|
||||
if (getMeetingModeService().isMeetingModeActive()) {
|
||||
|
|
@ -478,62 +509,31 @@ async function initMeetingMode(): Promise<void> {
|
|||
getMeetingDocTemplateService()
|
||||
}
|
||||
|
||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
||||
// ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ─────
|
||||
|
||||
function setupHistoryPopupIPC(): void {
|
||||
// 히스토리 팝업 열기 (핫키에서 호출)
|
||||
function setupPopupIPC(popups: PopupNavigationController): void {
|
||||
// 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다)
|
||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
|
||||
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
|
||||
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
|
||||
popups.show('history')
|
||||
})
|
||||
|
||||
// 아이템 선택 → 텍스트 삽입
|
||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => {
|
||||
hideHistoryPopup()
|
||||
unregisterPopupNavKeys()
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await getTextInsertService().insertText(data.text)
|
||||
} catch (error) {
|
||||
logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}, 200)
|
||||
popups.selectHistoryItem(data.text)
|
||||
})
|
||||
|
||||
// 팝업 닫기
|
||||
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
|
||||
hideHistoryPopup()
|
||||
unregisterPopupNavKeys()
|
||||
popups.dismiss('history')
|
||||
})
|
||||
}
|
||||
|
||||
// ── CommandPopup IPC 연동 ────────────────────────────
|
||||
|
||||
function setupCommandPopupIPC(): void {
|
||||
// 명령어 선택 → 활성 명령어로 설정
|
||||
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
|
||||
if (data.id) {
|
||||
// 명령어 선택 → 활성 명령어로 설정
|
||||
configSet('activeInstructionId', data.id)
|
||||
configSet('defaultLLMAction', 'custom')
|
||||
logger.info(`Active command set: ${data.name} (${data.id})`)
|
||||
} else {
|
||||
// 선택 해제 → 명령어 없음 (원본 삽입)
|
||||
configSet('activeInstructionId', '')
|
||||
configSet('defaultLLMAction', 'none')
|
||||
logger.info('Active command cleared (none)')
|
||||
}
|
||||
|
||||
// CMD 페이지 UI 갱신 알림
|
||||
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: data.id })
|
||||
popups.selectCommand({ id: data.id, name: data.name })
|
||||
})
|
||||
|
||||
// 팝업 닫기
|
||||
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
|
||||
hideCommandPopup()
|
||||
unregisterPopupNavKeys()
|
||||
popups.dismiss('command')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
// src/main/lifecycle.ts — 종료 시퀀스
|
||||
//
|
||||
// 종료 시 정리 대상은 ShutdownRegistry 에 서비스가 스스로 등록한다. lifecycle 은 개별 서비스를
|
||||
// import 하지 않고 등록부만 비운다. setupLifecycle 은 멱등이다 — bootstrap 이 가장 먼저 불러
|
||||
// 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우게 한다(그래야 창의
|
||||
// close-to-tray 가 종료를 가로채지 않는다). index.ts 의 뒤늦은 호출은 아무것도 하지 않는다.
|
||||
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './services/LoggerService'
|
||||
import { getConfigService } from './services/ConfigService'
|
||||
import { disposeLocalLLMService } from './services/LocalLLMService'
|
||||
import { getShutdownRegistry } from './shutdown-registry'
|
||||
|
||||
const logger = getLogger('lifecycle')
|
||||
|
||||
/** 종료 정리 전체 상한 — STT 사이드카 /shutdown 요청(최대 3초) + SIGKILL 여유 */
|
||||
export const SHUTDOWN_TIMEOUT_MS = 4000
|
||||
|
||||
let isQuitting = false
|
||||
let lifecycleInstalled = false
|
||||
let shutdownComplete = false
|
||||
|
||||
export function getIsQuitting(): boolean {
|
||||
return isQuitting
|
||||
|
|
@ -18,22 +28,41 @@ export function setIsQuitting(value: boolean): void {
|
|||
}
|
||||
|
||||
export function setupLifecycle(): void {
|
||||
if (lifecycleInstalled) return
|
||||
lifecycleInstalled = true
|
||||
|
||||
app.on('before-quit', () => {
|
||||
logger.info('[lifecycle] before-quit')
|
||||
isQuitting = true
|
||||
})
|
||||
|
||||
app.on('will-quit', () => {
|
||||
logger.info('[lifecycle] will-quit — flushing config')
|
||||
disposeLocalLLMService()
|
||||
try {
|
||||
const configService = getConfigService()
|
||||
if (configService) {
|
||||
// electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요
|
||||
logger.info('[lifecycle] config flush complete')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[lifecycle] config flush failed:', error)
|
||||
}
|
||||
app.on('will-quit', (event) => {
|
||||
if (shutdownComplete) return
|
||||
// 첫 will-quit 에서만 종료를 미루고 등록된 자원을 정리한 뒤 다시 종료한다
|
||||
event.preventDefault()
|
||||
logger.info('[lifecycle] will-quit — disposing registered resources')
|
||||
void getShutdownRegistry()
|
||||
.disposeAll(SHUTDOWN_TIMEOUT_MS)
|
||||
.then((report) => {
|
||||
logger.info(
|
||||
`[lifecycle] shutdown: completed=${report.completed.length}, failed=${report.failed.join(',') || '-'}, timedOut=${report.timedOut.join(',') || '-'}`,
|
||||
)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
logger.warn('[lifecycle] shutdown registry failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
try {
|
||||
const configService = getConfigService()
|
||||
if (configService) {
|
||||
// electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요
|
||||
logger.info('[lifecycle] config flush complete')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[lifecycle] config flush failed:', error)
|
||||
}
|
||||
shutdownComplete = true
|
||||
app.quit()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ class AudioCaptureService extends EventEmitter {
|
|||
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
|
||||
private _levelAccumulator: Buffer[] = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// EventEmitter 는 'error' 리스너가 없으면 emit('error') 에서 ERR_UNHANDLED_ERROR 를 던진다.
|
||||
// 예전엔 SoX 가 장치를 못 열고 끝나면 'close' 콜백 안에서 이 예외가 나 뒤이은 'stopped' 가
|
||||
// 영영 나가지 않았고, 받아쓰기는 녹음 팁만 띄운 채 조용히 아무것도 하지 않았다.
|
||||
// 기본 sink 로 막고, 소비자는 'stopped'(reason) 로 캡처 종료를 안다. 로깅은 호출 지점에서 한다.
|
||||
this.on('error', () => { /* default sink */ })
|
||||
}
|
||||
|
||||
get state(): CaptureState {
|
||||
return this._state
|
||||
}
|
||||
|
|
@ -560,3 +569,10 @@ export function getAudioCaptureService(): AudioCaptureService {
|
|||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetAudioCaptureServiceForTests(): void {
|
||||
if (instance) instance.removeAllListeners()
|
||||
instance = null
|
||||
}
|
||||
|
||||
export { AudioCaptureService }
|
||||
|
|
|
|||
|
|
@ -53,11 +53,44 @@ const DEFAULT_CONFIG: CaptionConfig = {
|
|||
audioSource: 'mic',
|
||||
}
|
||||
|
||||
/**
|
||||
* 자막 세션의 소유자. 회의 모드가 시작한 세션은 사용자 핫키/IPC 가 끌 수 없다
|
||||
* (예전엔 핫키가 회의의 마이크 캡처를 조용히 끊고 회의 UI 는 계속 '녹음 중'이었다).
|
||||
*/
|
||||
export type CaptionOwner = 'user' | 'meeting'
|
||||
|
||||
/** 자막 세션 시작 옵션 — 생략하면 기존(사용자 자막) 동작과 같다 */
|
||||
export interface CaptionStartOptions {
|
||||
/** 기본 'user' */
|
||||
owner?: CaptionOwner
|
||||
/** 이번 세션에만 쓸 오디오 소스. 생략 시 설정값(captionAudioSource). 전역 설정은 건드리지 않는다. */
|
||||
audioSource?: CaptionConfig['audioSource']
|
||||
/** 자막 오버레이 표시 여부 (기본 true) */
|
||||
showOverlay?: boolean
|
||||
/** 세션을 'caption' 히스토리로 저장하고 session-saved 를 알릴지 (기본 true) */
|
||||
persistHistory?: boolean
|
||||
}
|
||||
|
||||
/** 종료된 세션 요약 + 저장된 히스토리 행 id (저장하지 않았으면 null) */
|
||||
export type CaptionSessionResult = CaptionSessionSummary & { historyId: string | null }
|
||||
|
||||
interface ResolvedStartOptions {
|
||||
owner: CaptionOwner
|
||||
showOverlay: boolean
|
||||
persistHistory: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_START_OPTIONS: ResolvedStartOptions = {
|
||||
owner: 'user',
|
||||
showOverlay: true,
|
||||
persistHistory: true,
|
||||
}
|
||||
|
||||
interface CaptionServiceEvents {
|
||||
'state-changed': (state: CaptionState) => void
|
||||
'segment': (segment: CaptionSegment) => void
|
||||
'delta': (data: { text: string; isFinal: boolean }) => void
|
||||
'session-saved': (summary: CaptionSessionSummary) => void
|
||||
'session-saved': (summary: CaptionSessionResult) => void
|
||||
'error': (error: D3ROError) => void
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +114,8 @@ class CaptionService extends EventEmitter {
|
|||
private _refining = false
|
||||
|
||||
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
|
||||
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
||||
|
|
@ -88,6 +123,11 @@ class CaptionService extends EventEmitter {
|
|||
return this._state
|
||||
}
|
||||
|
||||
/** 현재 세션 소유자 (세션이 없으면 null) */
|
||||
getOwner(): CaptionOwner | null {
|
||||
return this._state === 'inactive' ? null : this._session.owner
|
||||
}
|
||||
|
||||
getConfig(): CaptionConfig {
|
||||
return { ...this._config }
|
||||
}
|
||||
|
|
@ -102,7 +142,7 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// ── 시작 ──
|
||||
|
||||
async start(): Promise<void> {
|
||||
async start(options: CaptionStartOptions = {}): Promise<void> {
|
||||
if (this._disposed) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.CaptionStartFailed,
|
||||
|
|
@ -117,12 +157,19 @@ class CaptionService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
this._session = {
|
||||
owner: options.owner ?? DEFAULT_START_OPTIONS.owner,
|
||||
showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay,
|
||||
persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory,
|
||||
}
|
||||
this._setState('starting')
|
||||
|
||||
// 오버레이를 즉시 표시 (로딩 상태)
|
||||
showCaptionOverlay()
|
||||
sendToCaptionOverlay('caption:config', this._config)
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
|
||||
// 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다
|
||||
if (this._session.showOverlay) {
|
||||
showCaptionOverlay()
|
||||
sendToCaptionOverlay('caption:config', this._config)
|
||||
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
|
||||
}
|
||||
|
||||
try {
|
||||
// STT 초기화 (모델 로딩 — 시간 소요)
|
||||
|
|
@ -142,13 +189,19 @@ class CaptionService extends EventEmitter {
|
|||
const lang = configGet('sttLanguage') as string | undefined
|
||||
const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
|
||||
|
||||
// ConfigService에서 저장된 오디오 소스 읽기
|
||||
const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string
|
||||
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
|
||||
this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
|
||||
// 오디오 소스: 호출자 지정값(이번 세션 한정) → 저장된 설정 순.
|
||||
// 호출자 지정값은 this._config 에 쓰지 않는다 — 다음 사용자 세션으로 새지 않게.
|
||||
let audioSource: CaptionConfig['audioSource']
|
||||
if (options.audioSource) {
|
||||
audioSource = options.audioSource
|
||||
} else {
|
||||
const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string
|
||||
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
|
||||
this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
|
||||
}
|
||||
audioSource = this._config.audioSource
|
||||
}
|
||||
const { audioSource } = this._config
|
||||
logger.info(`Caption audioSource: ${audioSource}`)
|
||||
logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`)
|
||||
|
||||
// 마이크 캡처 (mic 또는 both)
|
||||
if (audioSource === 'mic' || audioSource === 'both') {
|
||||
|
|
@ -190,6 +243,7 @@ class CaptionService extends EventEmitter {
|
|||
await audioCaptureService.stop().catch(() => {})
|
||||
}
|
||||
hideCaptionOverlay()
|
||||
this._session = { ...DEFAULT_START_OPTIONS }
|
||||
this._setState('inactive')
|
||||
const d3roErr =
|
||||
err instanceof D3ROError
|
||||
|
|
@ -205,10 +259,21 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// ── 중지 ──
|
||||
|
||||
async stop(): Promise<void> {
|
||||
/**
|
||||
* 자막 세션을 멈추고 요약을 돌려준다(말하던 마지막 문장 flush 포함).
|
||||
*
|
||||
* @param owner 생략하면 강제 정지(앱 종료·로그아웃·회의 강제 초기화).
|
||||
* 지정하면 그 소유자의 세션일 때만 멈추고, 아니면 false 를 돌려준다.
|
||||
* @returns 세션 요약(세그먼트가 없으면 null), 다른 소유자라 거부했으면 false
|
||||
*/
|
||||
async stop(owner?: CaptionOwner): Promise<CaptionSessionResult | null | false> {
|
||||
if (this._state !== 'active' && this._state !== 'starting') {
|
||||
logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`)
|
||||
return
|
||||
return null
|
||||
}
|
||||
if (owner && owner !== this._session.owner) {
|
||||
logger.warn(`캡션 중지 거부: 세션 소유자=${this._session.owner}, 요청=${owner}`)
|
||||
return false
|
||||
}
|
||||
|
||||
this._setState('stopping')
|
||||
|
|
@ -238,22 +303,25 @@ class CaptionService extends EventEmitter {
|
|||
// 오버레이 숨김
|
||||
hideCaptionOverlay()
|
||||
|
||||
// 세션을 히스토리에 저장
|
||||
const summary = this._saveSession()
|
||||
// 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다)
|
||||
const persist = this._session.persistHistory
|
||||
const summary = this._saveSession(persist)
|
||||
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
this._refineQueue = []
|
||||
this._sessionLanguage = null
|
||||
this._session = { ...DEFAULT_START_OPTIONS }
|
||||
|
||||
this._setState('inactive')
|
||||
getSoundEffectService().play('recording-stop')
|
||||
logger.info('Live Caption 중지')
|
||||
|
||||
if (summary) {
|
||||
if (summary && persist) {
|
||||
this.emit('session-saved', summary)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// ── dispose ──
|
||||
|
|
@ -411,7 +479,7 @@ class CaptionService extends EventEmitter {
|
|||
|
||||
// ── 내부: 세션 저장 ──
|
||||
|
||||
private _saveSession(): CaptionSessionSummary | null {
|
||||
private _saveSession(persist: boolean): CaptionSessionResult | null {
|
||||
if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -421,10 +489,27 @@ class CaptionService extends EventEmitter {
|
|||
const fullText = this._segments.map((s) => s.text).join(' ')
|
||||
const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length
|
||||
|
||||
// 히스토리에 저장 (mode: 'caption')
|
||||
// 히스토리에 저장 (mode: 'caption'). 만들어진 행 id 를 요약에 싣는다 —
|
||||
// 자동 요약은 이 id 로 히스토리를 찾는다(예전엔 자막 세션 id 로 찾아 항상 실패했다).
|
||||
const historyId = persist ? this._persistHistory(fullText, totalDurationMs, wordCount) : null
|
||||
|
||||
const summary: CaptionSessionResult = {
|
||||
sessionId: this._sessionId,
|
||||
historyId,
|
||||
segments: [...this._segments],
|
||||
startedAt: this._sessionStartedAt,
|
||||
endedAt,
|
||||
totalDurationMs,
|
||||
}
|
||||
|
||||
this._segments = []
|
||||
return summary
|
||||
}
|
||||
|
||||
/** 'caption' 히스토리 행을 만들고 id 를 돌려준다. 실패하면 로그만 남기고 null. */
|
||||
private _persistHistory(fullText: string, totalDurationMs: number, wordCount: number): string | null {
|
||||
try {
|
||||
const historyService = getHistoryService()
|
||||
historyService.create({
|
||||
const entry = getHistoryService().create({
|
||||
originalText: fullText,
|
||||
polishedText: null,
|
||||
focusedApp: null,
|
||||
|
|
@ -447,22 +532,13 @@ class CaptionService extends EventEmitter {
|
|||
logger.info(
|
||||
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
|
||||
)
|
||||
return entry.id
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const summary: CaptionSessionSummary = {
|
||||
sessionId: this._sessionId,
|
||||
segments: [...this._segments],
|
||||
startedAt: this._sessionStartedAt,
|
||||
endedAt,
|
||||
totalDurationMs,
|
||||
}
|
||||
|
||||
this._segments = []
|
||||
return summary
|
||||
}
|
||||
|
||||
// ── 내부: 상태 전이 ──
|
||||
|
|
|
|||
|
|
@ -214,11 +214,14 @@ class LocalSTTService extends EventEmitter {
|
|||
private _disposed: boolean = false
|
||||
private _gpuAccelerated: boolean = false
|
||||
|
||||
// ── 이중 조건 플러시 (Speakly 패턴) ──
|
||||
// ── 준비 상태 ──
|
||||
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
|
||||
// initialize()가 풀어 주기를 기다렸다. 아무도 initialize()를 부르지 않으면(클라우드 STT 실패 →
|
||||
// 로컬 폴백) 영원히 끝나지 않았고, 두 번째 호출은 첫 호출을 덮어썼으며, 옵션도 버려졌다.
|
||||
// 이제 transcribe()는 진행 중인 초기화를 기다리거나 직접 초기화한 뒤 전사한다.
|
||||
private _modelReady: boolean = false
|
||||
private _audioBuffer: Buffer[] = []
|
||||
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
|
||||
private _pendingReject: ((error: D3ROError) => void) | null = null
|
||||
/** 진행 중인 초기화(크래시 재시작 포함) — transcribe()가 이것을 기다린다 */
|
||||
private _initializing: { modelId: string; promise: Promise<void> } | null = null
|
||||
// errorEmitted 플래그로 이벤트 중복 방지
|
||||
private _errorEmitted: boolean = false
|
||||
|
||||
|
|
@ -253,15 +256,36 @@ class LocalSTTService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
// 이미 같은 모델이 로딩된 상태면 무시
|
||||
// 이미 같은 모델이 로딩된 상태면 무시 (전사 중이어도 모델은 올라가 있다)
|
||||
if (
|
||||
this._state === STTState.Ready &&
|
||||
this._modelReady &&
|
||||
(this._state === STTState.Ready || this._state === STTState.Transcribing) &&
|
||||
this._currentModelId === targetModel
|
||||
) {
|
||||
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
|
||||
return
|
||||
}
|
||||
|
||||
// 같은 모델을 올리는 중이면 그 작업에 합류한다 (워밍업 + 첫 받아쓰기 동시 호출 등)
|
||||
if (this._initializing && this._initializing.modelId === targetModel) {
|
||||
return this._initializing.promise
|
||||
}
|
||||
|
||||
return this._trackInitialization(targetModel, this._loadInitial(targetModel))
|
||||
}
|
||||
|
||||
/** 진행 중인 초기화를 기록한다 — 끝나면(성공/실패) 스스로 지운다. */
|
||||
private _trackInitialization(modelId: string, promise: Promise<void>): Promise<void> {
|
||||
const entry = { modelId, promise }
|
||||
this._initializing = entry
|
||||
const clear = (): void => {
|
||||
if (this._initializing === entry) this._initializing = null
|
||||
}
|
||||
promise.then(clear, clear)
|
||||
return promise
|
||||
}
|
||||
|
||||
private async _loadInitial(targetModel: string): Promise<void> {
|
||||
this._setState(STTState.Loading)
|
||||
this._errorEmitted = false
|
||||
|
||||
|
|
@ -275,9 +299,6 @@ class LocalSTTService extends EventEmitter {
|
|||
this._modelReady = true
|
||||
this._setState(STTState.Ready)
|
||||
|
||||
// 이중 조건 플러시 시도
|
||||
this._tryFlushAll()
|
||||
|
||||
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
||||
} catch (err) {
|
||||
this._setState(STTState.Error)
|
||||
|
|
@ -313,23 +334,37 @@ class LocalSTTService extends EventEmitter {
|
|||
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
|
||||
}
|
||||
|
||||
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
|
||||
// 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
|
||||
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
|
||||
if (!this._modelReady) {
|
||||
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
|
||||
this._audioBuffer.push(audioBuffer)
|
||||
|
||||
return new Promise<TranscriptionResult>((resolve, reject) => {
|
||||
this._pendingResolve = resolve
|
||||
this._pendingReject = reject
|
||||
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
|
||||
this._tryFlushAll()
|
||||
})
|
||||
await this._ensureModelReady()
|
||||
}
|
||||
|
||||
// 모델 준비 완료 상태: 직접 전사
|
||||
// 호출자 옵션(language/initialPrompt/modelId)을 그대로 전달한다
|
||||
return this._sendToSidecar(audioBuffer, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 준비될 때까지 기다린다.
|
||||
* - 초기화(또는 크래시 재시작)가 진행 중이면 그것을 기다린다.
|
||||
* - 아무도 초기화하지 않았다면 설정된 모델로 직접 초기화한다
|
||||
* (클라우드 STT 실패 → 로컬 폴백, 파일 전사 등 초기화 없이 들어오는 호출).
|
||||
*/
|
||||
private async _ensureModelReady(): Promise<void> {
|
||||
if (this._initializing) {
|
||||
await this._initializing.promise
|
||||
}
|
||||
if (!this._modelReady) {
|
||||
await this.initialize()
|
||||
}
|
||||
if (!this._modelReady) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.STTSidecarCommunicationFailed,
|
||||
'STT 모델이 준비되지 않았습니다',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 시작 시 sidecar와 모델을 미리 데운다.
|
||||
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
|
||||
|
|
@ -542,16 +577,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
logger.info('LocalSTTService dispose 시작')
|
||||
|
||||
// pending promise를 reject
|
||||
if (this._pendingReject) {
|
||||
this._pendingReject(
|
||||
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
|
||||
)
|
||||
this._pendingResolve = null
|
||||
this._pendingReject = null
|
||||
}
|
||||
|
||||
this._audioBuffer = []
|
||||
this._initializing = null
|
||||
this._modelReady = false
|
||||
this._auxModelId = null
|
||||
|
||||
|
|
@ -561,41 +587,6 @@ class LocalSTTService extends EventEmitter {
|
|||
logger.info('LocalSTTService dispose 완료')
|
||||
}
|
||||
|
||||
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
|
||||
|
||||
/**
|
||||
* 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
|
||||
* 설정 메시지(모델) 먼저, 오디오 데이터 후.
|
||||
*/
|
||||
private _tryFlushAll(): void {
|
||||
if (!this._modelReady) return
|
||||
if (this._audioBuffer.length === 0) return
|
||||
if (!this._pendingResolve) return
|
||||
|
||||
const merged = Buffer.concat(this._audioBuffer)
|
||||
this._audioBuffer = []
|
||||
|
||||
const resolve = this._pendingResolve
|
||||
const reject = this._pendingReject
|
||||
this._pendingResolve = null
|
||||
this._pendingReject = null
|
||||
|
||||
this._sendToSidecar(merged)
|
||||
.then(resolve)
|
||||
.catch((err: unknown) => {
|
||||
if (reject) {
|
||||
reject(
|
||||
err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(
|
||||
ErrorCode.STTTranscriptionFailed,
|
||||
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Sidecar 관리 ──
|
||||
|
||||
/**
|
||||
|
|
@ -803,7 +794,7 @@ class LocalSTTService extends EventEmitter {
|
|||
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
||||
private _spawnFailureError(
|
||||
err: Error,
|
||||
launch: { command: string; source: 'bundled' | 'venv' | 'python' },
|
||||
launch: { command: string; source: 'bundled' | 'provisioned' | 'venv' | 'python' },
|
||||
): D3ROError {
|
||||
const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
if (!enoent) {
|
||||
|
|
@ -814,7 +805,7 @@ class LocalSTTService extends EventEmitter {
|
|||
}
|
||||
|
||||
const hint =
|
||||
launch.source === 'bundled'
|
||||
launch.source === 'bundled' || launch.source === 'provisioned'
|
||||
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
|
||||
: launch.source === 'venv'
|
||||
? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.'
|
||||
|
|
@ -1058,31 +1049,30 @@ class LocalSTTService extends EventEmitter {
|
|||
)
|
||||
this._setState(STTState.Error)
|
||||
this._emitError(err)
|
||||
|
||||
// pending promise reject
|
||||
if (this._pendingReject) {
|
||||
this._pendingReject(err)
|
||||
this._pendingResolve = null
|
||||
this._pendingReject = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 비동기 재시작
|
||||
const modelToReload = this._currentModelId
|
||||
const modelToReload = this._currentModelId ?? configGet('sttModelId')
|
||||
this._currentModelId = null
|
||||
this._modelReady = false
|
||||
this._auxModelId = null
|
||||
|
||||
// setTimeout으로 이벤트 루프에 양보
|
||||
setTimeout(() => {
|
||||
if (this._disposed) return
|
||||
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
|
||||
logger.error(
|
||||
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
})
|
||||
}, 1000 * this._restartCount) // 점진적 백오프
|
||||
// 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가
|
||||
// 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
|
||||
const delayMs = 1000 * this._restartCount // 점진적 백오프
|
||||
const restart = new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(async () => {
|
||||
if (this._disposed) {
|
||||
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
|
||||
}
|
||||
await this._loadInitial(modelToReload)
|
||||
})
|
||||
restart.catch((restartErr: unknown) => {
|
||||
logger.error(
|
||||
`Sidecar 재시작 실패: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`,
|
||||
)
|
||||
})
|
||||
this._trackInitialization(modelToReload, restart)
|
||||
}
|
||||
|
||||
private async _shutdownSidecar(): Promise<void> {
|
||||
|
|
@ -1172,4 +1162,13 @@ export function resetLocalSTTServiceForTests(): void {
|
|||
instance = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 종료용: 이미 만들어진 인스턴스만 정리한다(사이드카 /shutdown → kill).
|
||||
* 한 번도 쓰지 않았으면 종료 때문에 인스턴스를 새로 만들지 않는다.
|
||||
*/
|
||||
export async function disposeLocalSTTServiceIfStarted(): Promise<void> {
|
||||
if (!instance) return
|
||||
await instance.dispose()
|
||||
}
|
||||
|
||||
export { LocalSTTService, STTState }
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import { BrowserWindow, Notification, dialog } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { dirname as pathDirname } from 'path'
|
||||
import { eq, desc, sql } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { getDatabase } from '../db'
|
||||
|
|
@ -16,11 +17,19 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
buildExportMarkdown,
|
||||
markdownToSimpleHtml,
|
||||
renderMeetingPdfHtml,
|
||||
formatTime,
|
||||
formatDateFile,
|
||||
formatDateRange,
|
||||
} from '@d3ro/core/utils/meeting-markdown'
|
||||
import {
|
||||
applySpeakerLabels,
|
||||
buildTranscriptFromSegments,
|
||||
chunkTranscriptByLines,
|
||||
formatTranscriptLines,
|
||||
looksTruncatedRewrite,
|
||||
parseTranscriptLines,
|
||||
} from '@d3ro/core/meeting-transcript'
|
||||
import { markdownToDocx } from '@d3ro/core/utils/markdown-to-docx'
|
||||
import type {
|
||||
MeetingModeState,
|
||||
|
|
@ -42,6 +51,31 @@ import type {
|
|||
|
||||
const logger = getLogger('MeetingModeService')
|
||||
|
||||
/** 회의 전사를 LLM 으로 다시 쓸 때 한 번에 보낼 최대 글자 수 (출력 토큰 한도 안에 들도록) */
|
||||
const REWRITE_CHUNK_CHARS = 1500
|
||||
/** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */
|
||||
const REWRITE_CHUNK_MAX_TOKENS = 2048
|
||||
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
|
||||
const MAX_CHAT_HISTORY_MESSAGES = 20
|
||||
|
||||
type MeetingLlm = {
|
||||
generate: (
|
||||
text: string,
|
||||
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
|
||||
) => Promise<{ text: string; model?: string }>
|
||||
chatStream: (
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: { temperature?: number },
|
||||
) => AsyncGenerator<string, string>
|
||||
}
|
||||
|
||||
/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */
|
||||
async function meetingAudioFilePath(sessionId: string): Promise<string> {
|
||||
const { app } = await import('electron')
|
||||
const path = await import('path')
|
||||
return path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
||||
}
|
||||
|
||||
let isShowingMeetingSaveDialog = false
|
||||
|
||||
class MeetingModeService extends EventEmitter {
|
||||
|
|
@ -60,8 +94,8 @@ class MeetingModeService extends EventEmitter {
|
|||
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
||||
private _audioBuffersForFile: Buffer[] = []
|
||||
|
||||
// Phase 15: Meeting AI Chat
|
||||
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
||||
// Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게)
|
||||
private _chatHistories = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
|
||||
private _chatAbortController: AbortController | null = null
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
|
@ -101,11 +135,12 @@ class MeetingModeService extends EventEmitter {
|
|||
try {
|
||||
const { getCaptionService } = await import('./CaptionService')
|
||||
const captionService = getCaptionService()
|
||||
// 강제 정지(소유자 무시) 후 구독을 푼다
|
||||
await captionService.stop().catch(() => null)
|
||||
if (this._segmentHandler) {
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
}
|
||||
await captionService.stop().catch(() => {})
|
||||
} catch { /* noop */ }
|
||||
|
||||
try {
|
||||
|
|
@ -180,25 +215,16 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
captionService.on('segment', this._segmentHandler)
|
||||
|
||||
// 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경
|
||||
// (CaptionService.start()가 ConfigService에서 다시 읽기 때문)
|
||||
const prevAudioSource = configGet('captionAudioSource')
|
||||
configSet('captionAudioSource', 'mic')
|
||||
|
||||
try {
|
||||
await captionService.start()
|
||||
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김
|
||||
const { hideCaptionOverlay } = await import('../windows/WindowManager')
|
||||
hideCaptionOverlay()
|
||||
// ConfigService 원래 값 복원
|
||||
if (prevAudioSource) {
|
||||
configSet('captionAudioSource', prevAudioSource)
|
||||
}
|
||||
// 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음
|
||||
// (회의 UI 가 자체 패널에 표시), 'caption' 히스토리 중복 저장 없음, 핫키로 끌 수 없음.
|
||||
await captionService.start({
|
||||
owner: 'meeting',
|
||||
audioSource: 'mic',
|
||||
showOverlay: false,
|
||||
persistHistory: false,
|
||||
})
|
||||
} catch (err) {
|
||||
// ConfigService 원래 값 복원
|
||||
if (prevAudioSource) {
|
||||
configSet('captionAudioSource', prevAudioSource)
|
||||
}
|
||||
// 시작 실패 시 복원
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
|
|
@ -300,16 +326,26 @@ class MeetingModeService extends EventEmitter {
|
|||
this._audioDataHandler = null
|
||||
}
|
||||
|
||||
// CaptionService 종료
|
||||
// CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다)
|
||||
// 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다.
|
||||
const { getCaptionService } = await import('./CaptionService')
|
||||
const captionService = getCaptionService()
|
||||
|
||||
const captionSummary = await captionService.stop('meeting')
|
||||
|
||||
if (this._segmentHandler) {
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
}
|
||||
|
||||
await captionService.stop()
|
||||
// 혹시 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다
|
||||
if (captionSummary) {
|
||||
const known = new Set(this._segments.map((seg) => seg.id))
|
||||
for (const segment of captionSummary.segments) {
|
||||
if (!known.has(segment.id)) this._segments.push(segment)
|
||||
}
|
||||
this._segments.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
// 세션 종료 시간 기록
|
||||
const db = getDatabase()
|
||||
|
|
@ -345,9 +381,9 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
// Step 1: 전사 텍스트 합산
|
||||
this._sendProgress(sessionId, 'merging', 40)
|
||||
const rawTranscript = this._segments
|
||||
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`)
|
||||
.join('\n')
|
||||
// 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다
|
||||
const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0
|
||||
const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt)
|
||||
|
||||
// Step 2: DB 저장 (rawTranscript + completed)
|
||||
this._sendProgress(sessionId, 'saving', 80)
|
||||
|
|
@ -363,11 +399,8 @@ class MeetingModeService extends EventEmitter {
|
|||
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
||||
if (this._audioBuffersForFile.length > 0) {
|
||||
try {
|
||||
const { app } = await import('electron')
|
||||
const path = await import('path')
|
||||
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
|
||||
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
|
||||
const wavPath = path.join(audioDir, `${sessionId}.wav`)
|
||||
const wavPath = await meetingAudioFilePath(sessionId)
|
||||
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
|
||||
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
||||
getCloudSyncService().pushOne('meeting_audio', sessionId)
|
||||
logger.info(`회의 오디오 저장: ${wavPath}`)
|
||||
|
|
@ -485,9 +518,21 @@ class MeetingModeService extends EventEmitter {
|
|||
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
||||
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
|
||||
getCloudSyncService().pushDelete('meetings', sessionId)
|
||||
this._chatHistories.delete(sessionId)
|
||||
// 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort)
|
||||
void this._deleteMeetingAudio(sessionId)
|
||||
logger.info(`회의 세션 삭제: ${sessionId}`)
|
||||
}
|
||||
|
||||
private async _deleteMeetingAudio(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const audioPath = await meetingAudioFilePath(sessionId)
|
||||
await fs.promises.rm(audioPath, { force: true })
|
||||
} catch (err) {
|
||||
logger.warn(`회의 오디오 삭제 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
updateTitle(sessionId: string, title: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
|
|
@ -615,16 +660,7 @@ class MeetingModeService extends EventEmitter {
|
|||
* 회의 문서/폴리시/채팅/화자추정 LLM.
|
||||
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
|
||||
*/
|
||||
private async _resolveMeetingLlm(): Promise<{
|
||||
generate: (
|
||||
text: string,
|
||||
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
|
||||
) => Promise<{ text: string; model?: string }>
|
||||
chatStream: (
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: { temperature?: number },
|
||||
) => AsyncGenerator<string, string>
|
||||
}> {
|
||||
private async _resolveMeetingLlm(): Promise<MeetingLlm> {
|
||||
const backend = configGet('llmBackend')
|
||||
if (backend === 'online') {
|
||||
try {
|
||||
|
|
@ -871,24 +907,7 @@ class MeetingModeService extends EventEmitter {
|
|||
break
|
||||
}
|
||||
case 'pdf': {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 40px; color: #222; }
|
||||
h1 { font-size: 20px; border-bottom: 1px solid #ddd; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; margin-top: 20px; }
|
||||
p { line-height: 1.6; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${row.title}</h1>
|
||||
${markdownToSimpleHtml(row.content)}
|
||||
</body>
|
||||
</html>`
|
||||
const html = renderMeetingPdfHtml({ title: row.title, markdown: row.content })
|
||||
const win = new BrowserWindow({ show: false, width: 800, height: 600 })
|
||||
try {
|
||||
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
||||
|
|
@ -1013,19 +1032,56 @@ class MeetingModeService extends EventEmitter {
|
|||
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
const result = await llm.generate(transcript, {
|
||||
systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
||||
temperature: 0.3,
|
||||
})
|
||||
const polished = await this._rewriteTranscriptInChunks(
|
||||
llm,
|
||||
transcript,
|
||||
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
||||
ErrorCode.MeetingPolishFailed,
|
||||
)
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: result.text,
|
||||
editedTranscript: polished,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
|
||||
return result.text
|
||||
return polished
|
||||
}
|
||||
|
||||
/**
|
||||
* 긴 전사를 줄 경계 조각으로 나눠 LLM 으로 다시 쓰고 이어 붙인다.
|
||||
* 한 번에 통째로 보내면 출력 토큰 한도(2048/4096)에서 잘린 결과가 editedTranscript 를
|
||||
* 덮어써 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라졌다. 조각 하나라도 잘린 것으로
|
||||
* 보이면 저장하지 않고 실패시킨다.
|
||||
*/
|
||||
private async _rewriteTranscriptInChunks(
|
||||
llm: MeetingLlm,
|
||||
transcript: string,
|
||||
systemPrompt: string,
|
||||
failureCode: ErrorCode,
|
||||
): Promise<string> {
|
||||
const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS)
|
||||
if (chunks.length === 0) {
|
||||
throw new D3ROError(failureCode, '전사 텍스트가 없습니다')
|
||||
}
|
||||
const outputs: string[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]
|
||||
const result = await llm.generate(chunk, {
|
||||
systemPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: REWRITE_CHUNK_MAX_TOKENS,
|
||||
})
|
||||
if (looksTruncatedRewrite(chunk, result.text)) {
|
||||
throw new D3ROError(
|
||||
failureCode,
|
||||
`LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`,
|
||||
)
|
||||
}
|
||||
outputs.push(result.text.trim())
|
||||
}
|
||||
return outputs.join('\n')
|
||||
}
|
||||
|
||||
// ── Phase 15: Meeting AI Chat ──
|
||||
|
|
@ -1044,42 +1100,55 @@ class MeetingModeService extends EventEmitter {
|
|||
## 회의 전사
|
||||
${transcript}`
|
||||
|
||||
this._chatHistory.push({ role: 'user', content: userMessage })
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
|
||||
// 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다
|
||||
// (실패·취소 시 짝 없는 사용자 턴이 남지 않게).
|
||||
const history = this._chatHistories.get(sessionId) ?? []
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: systemPrompt },
|
||||
...this._chatHistory,
|
||||
...history,
|
||||
{ role: 'user' as const, content: userMessage },
|
||||
]
|
||||
|
||||
let fullResponse = ''
|
||||
this._chatAbortController = new AbortController()
|
||||
const signal = this._chatAbortController.signal
|
||||
// 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다
|
||||
this._chatAbortController?.abort()
|
||||
const controller = new AbortController()
|
||||
this._chatAbortController = controller
|
||||
const signal = controller.signal
|
||||
|
||||
let fullResponse = ''
|
||||
try {
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
const generator = llm.chatStream(messages, { temperature: 0.5 })
|
||||
|
||||
for await (const token of generator) {
|
||||
if (signal.aborted) break
|
||||
fullResponse += token
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token })
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { sessionId, token })
|
||||
}
|
||||
|
||||
this._chatHistory.push({ role: 'assistant', content: fullResponse })
|
||||
if (!signal.aborted) {
|
||||
const updated = [
|
||||
...history,
|
||||
{ role: 'user' as const, content: userMessage },
|
||||
{ role: 'assistant' as const, content: fullResponse },
|
||||
]
|
||||
this._chatHistories.set(sessionId, updated.slice(-MAX_CHAT_HISTORY_MESSAGES))
|
||||
}
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, {
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: fullResponse,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
if (!signal.aborted && (err as Error).name !== 'AbortError') {
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, {
|
||||
sessionId,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this._chatAbortController = null
|
||||
if (this._chatAbortController === controller) this._chatAbortController = null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1090,8 +1159,18 @@ ${transcript}`
|
|||
}
|
||||
}
|
||||
|
||||
clearMeetingChatHistory(): void {
|
||||
this._chatHistory = []
|
||||
/** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */
|
||||
clearMeetingChatHistory(sessionId?: string): void {
|
||||
if (sessionId) {
|
||||
this._chatHistories.delete(sessionId)
|
||||
} else {
|
||||
this._chatHistories.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** 테스트/진단용: 특정 회의의 채팅 히스토리 사본 */
|
||||
getMeetingChatHistory(sessionId: string): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return [...(this._chatHistories.get(sessionId) ?? [])]
|
||||
}
|
||||
|
||||
// ── Phase 15.5: 화자 구분 ──
|
||||
|
|
@ -1105,9 +1184,7 @@ ${transcript}`
|
|||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
||||
|
||||
// 오디오 파일 존재 확인
|
||||
const { app } = await import('electron')
|
||||
const path = await import('path')
|
||||
const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
||||
const audioPath = await meetingAudioFilePath(sessionId)
|
||||
const hasAudioFile = fs.existsSync(audioPath)
|
||||
|
||||
const hfToken = configGet('hfToken') as string
|
||||
|
|
@ -1147,31 +1224,19 @@ ${transcript}`
|
|||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 })
|
||||
|
||||
// 전사 세그먼트와 화자 세그먼트 매칭
|
||||
// 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다.
|
||||
// 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다.
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const lines = transcript.split('\n').filter((l) => l.trim())
|
||||
const diarSegments = diarResult.segments
|
||||
|
||||
const labeledLines = lines.map((line) => {
|
||||
const timeMatch = /^\[(\d{2}):(\d{2})\]/.exec(line)
|
||||
if (!timeMatch) return line
|
||||
|
||||
const timeSec = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)
|
||||
// 해당 시간에 가장 가까운 화자 세그먼트 찾기
|
||||
const matchedSeg = diarSegments.find(
|
||||
(s) => timeSec >= s.start && timeSec <= s.end,
|
||||
)
|
||||
if (matchedSeg) {
|
||||
const speakerLabel = matchedSeg.speaker.replace('SPEAKER_', '화자 ')
|
||||
return line.replace(/^\[(\d{2}:\d{2})\]/, `[$1] [${speakerLabel}]`)
|
||||
}
|
||||
return line
|
||||
})
|
||||
const labeledLines = applySpeakerLabels(
|
||||
parseTranscriptLines(transcript),
|
||||
diarResult.segments,
|
||||
(raw) => raw.replace('SPEAKER_', '화자 '),
|
||||
)
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: labeledLines.join('\n'),
|
||||
editedTranscript: formatTranscriptLines(labeledLines),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
|
@ -1205,22 +1270,23 @@ ${transcript}`
|
|||
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
||||
: '화자 수는 문맥에서 추정하세요.'
|
||||
|
||||
const result = await llm.generate(transcript, {
|
||||
systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
|
||||
const labeled = await this._rewriteTranscriptInChunks(
|
||||
llm,
|
||||
transcript,
|
||||
`다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
|
||||
${speakerHint}
|
||||
각 줄을 [시간] [화자] 내용 형식으로 변환하세요.
|
||||
화자는 "화자 1", "화자 2" 등으로 표시하세요.
|
||||
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
||||
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 8192,
|
||||
})
|
||||
ErrorCode.DiarizationFailed,
|
||||
)
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
||||
|
||||
const db = getDatabase()
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: result.text,
|
||||
editedTranscript: labeled,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
|
@ -1228,33 +1294,12 @@ ${speakerHint}
|
|||
|
||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||
const minutesHtml = session.minutesMarkdown
|
||||
? markdownToSimpleHtml(session.minutesMarkdown)
|
||||
: '<p>회의록이 생성되지 않았습니다.</p>'
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||
th { background: #f5f5f5; font-weight: 600; }
|
||||
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
|
||||
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${session.title ?? '무제 회의'}</h1>
|
||||
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
||||
${minutesHtml}
|
||||
</body>
|
||||
</html>`
|
||||
return renderMeetingPdfHtml({
|
||||
title: session.title ?? '무제 회의',
|
||||
meta: `일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분`,
|
||||
markdown: session.minutesMarkdown,
|
||||
emptyMessage: '회의록이 생성되지 않았습니다.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,17 +187,24 @@ class MeetingSummaryService extends EventEmitter {
|
|||
/**
|
||||
* CaptionService 세션 종료 시 자동 요약 (옵션)
|
||||
*/
|
||||
async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise<void> {
|
||||
// caption 모드 히스토리에서 해당 세션 찾기
|
||||
async onCaptionSessionSaved(sessionSummary: { sessionId: string; historyId?: string | null }): Promise<void> {
|
||||
// caption 모드 히스토리에서 해당 세션 찾기.
|
||||
// 히스토리 행 id 는 HistoryService 가 따로 만든다 — 자막 세션 id 와 절대 같지 않으므로
|
||||
// CaptionService 가 실어 준 historyId 로 찾는다(없으면 저장되지 않은 세션).
|
||||
const historyId = sessionSummary.historyId
|
||||
if (!historyId) {
|
||||
logger.info(`Caption session was not saved to history, skipping summary: ${sessionSummary.sessionId}`)
|
||||
return
|
||||
}
|
||||
const db = getDatabase()
|
||||
const rows = db
|
||||
.select()
|
||||
.from(history)
|
||||
.where(eq(history.id, sessionSummary.sessionId))
|
||||
.where(eq(history.id, historyId))
|
||||
.all()
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.warn(`Caption session not found in history: ${sessionSummary.sessionId}`)
|
||||
logger.warn(`Caption session not found in history: ${historyId}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import path from 'path'
|
|||
import fs from 'fs'
|
||||
import { asc, eq } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { extractDocxText } from './rag/docx-text'
|
||||
import { getOllamaServerUrl } from './LocalLLMService'
|
||||
import { getDatabase } from '../db'
|
||||
import { ragDocuments, ragChunks } from '../db/schema'
|
||||
|
|
@ -291,8 +292,8 @@ class RAGService extends EventEmitter {
|
|||
Documents:
|
||||
${context}`
|
||||
|
||||
const llmService = getPremiumLLMService()
|
||||
const result = await llmService.generate(queryText, { systemPrompt })
|
||||
// 답변 LLM 은 사용자가 고른 백엔드를 따른다(로컬 선택 시 문서 조각을 클라우드로 보내지 않는다)
|
||||
const result = await getLlmGateway().generate(queryText, { systemPrompt })
|
||||
|
||||
return {
|
||||
query: queryText,
|
||||
|
|
@ -487,18 +488,11 @@ ${context}`
|
|||
if (fileType === 'docx') {
|
||||
try {
|
||||
const buffer = await fsp.readFile(filePath)
|
||||
// DOCX는 ZIP 내 XML — w:t 태그에서 텍스트 추출
|
||||
const raw = buffer.toString('utf-8')
|
||||
const matches = raw.match(/<w:t[^>]*>([^<]*)<\/w:t>/g)
|
||||
if (matches) {
|
||||
const text = matches
|
||||
.map((m) => m.replace(/<[^>]+>/g, ''))
|
||||
.join(' ')
|
||||
return text.trim()
|
||||
}
|
||||
// XML 태그 제거 폴백
|
||||
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500000)
|
||||
} catch {
|
||||
// DOCX는 ZIP 안의 (deflate 압축된) word/document.xml — 압축을 풀어 문단 텍스트를 뽑는다.
|
||||
// 텍스트를 찾지 못하면 바이너리 잡음을 색인하지 않고 실패시킨다.
|
||||
return extractDocxText(buffer).slice(0, 500000)
|
||||
} catch (err) {
|
||||
logger.warn(`DOCX parsing failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,17 @@ import { getLogger } from './LoggerService'
|
|||
import { configGet, configSet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types'
|
||||
import { waitForModifiersReleased } from './modifier-state'
|
||||
|
||||
const logger = getLogger('screen-context')
|
||||
|
||||
/**
|
||||
* 선택 텍스트 캡처 전에 사용자가 수정자 키(Ctrl/Alt/Shift/Win)를 뗄 때까지 기다리는 최대 시간.
|
||||
* 손을 떼지 않으면 캡처를 건너뛴다 — 수정자를 쥔 채 Ctrl+C 를 보내면 대상 앱엔 Ctrl+Alt+C 등이
|
||||
* 들어가 복사가 되지 않고(AltGr 배열에선 문자가 입력되어 선택 영역을 덮어쓴다) 클립보드만 비워진다.
|
||||
*/
|
||||
const MODIFIER_RELEASE_TIMEOUT_MS = 800
|
||||
|
||||
// ============================================================
|
||||
// nut-js 타입 (lazy import)
|
||||
// ============================================================
|
||||
|
|
@ -107,7 +115,7 @@ class ScreenContextService {
|
|||
if (captureSelectedText) {
|
||||
selectedTextAttempted = true
|
||||
try {
|
||||
selectedText = await this._captureSelectedText()
|
||||
selectedText = await this.captureSelectedText()
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -130,6 +138,20 @@ class ScreenContextService {
|
|||
return { context, selectedTextAttempted }
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 텍스트만 캡처한다 (클립보드 방식).
|
||||
* 수정자 키가 눌려 있으면 뗄 때까지 잠깐 기다리고, 그래도 쥐고 있으면 캡처하지 않고 null 을
|
||||
* 돌려준다(클립보드도 건드리지 않는다).
|
||||
*/
|
||||
async captureSelectedText(): Promise<string | null> {
|
||||
const released = await waitForModifiersReleased(MODIFIER_RELEASE_TIMEOUT_MS)
|
||||
if (!released) {
|
||||
logger.info('Selected text capture skipped: modifier keys still held')
|
||||
return null
|
||||
}
|
||||
return this._captureSelectedText()
|
||||
}
|
||||
|
||||
/**
|
||||
* ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다.
|
||||
* 빈 컨텍스트면 빈 문자열을 반환.
|
||||
|
|
@ -371,7 +393,9 @@ end try
|
|||
}
|
||||
|
||||
/**
|
||||
* 클립보드 복원 (TextInsertService와 동일 패턴).
|
||||
* 클립보드 복원 — 저장해 둔 모든 형식(text/html/rtf/image)을 한 번에 되돌린다.
|
||||
* 예전엔 첫 번째로 있는 형식 하나만 썼기 때문에 서식 있는 복사본이나 텍스트+이미지가
|
||||
* 캡처 한 번에 평문으로 바뀌었다.
|
||||
*/
|
||||
private _restoreClipboard(snapshot: ClipboardSnapshot): void {
|
||||
if (!snapshot.hasContent) {
|
||||
|
|
@ -379,15 +403,12 @@ end try
|
|||
return
|
||||
}
|
||||
|
||||
if (snapshot.text) {
|
||||
clipboard.writeText(snapshot.text)
|
||||
} else if (snapshot.html) {
|
||||
clipboard.writeHTML(snapshot.html)
|
||||
} else if (snapshot.rtf) {
|
||||
clipboard.writeRTF(snapshot.rtf)
|
||||
} else if (snapshot.image) {
|
||||
clipboard.writeImage(snapshot.image)
|
||||
}
|
||||
const data: Electron.Data = {}
|
||||
if (snapshot.text) data.text = snapshot.text
|
||||
if (snapshot.html) data.html = snapshot.html
|
||||
if (snapshot.rtf) data.rtf = snapshot.rtf
|
||||
if (snapshot.image) data.image = snapshot.image
|
||||
clipboard.write(data)
|
||||
}
|
||||
|
||||
private _sleep(ms: number): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@
|
|||
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
||||
// 5. staged rollout + 원격 킬 스위치
|
||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
//
|
||||
// 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가
|
||||
// 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다.
|
||||
// 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
|
@ -25,13 +29,12 @@ import {
|
|||
} from '../update-feed'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
decideUpdate,
|
||||
isChannelAcceptable,
|
||||
isWithinRollout,
|
||||
majorOf,
|
||||
evaluateUpdateOffer,
|
||||
parseUpdatePolicy,
|
||||
shouldPromptForUpdate,
|
||||
type UpdateDecision,
|
||||
type UpdatePolicy,
|
||||
type UpdatePromptRecord,
|
||||
} from '../update-policy'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
|
@ -76,124 +79,162 @@ export interface UpdateServiceEvents {
|
|||
'update-error': { message: string }
|
||||
}
|
||||
|
||||
// ── 포트 ─────────────────────────────────────────────────────
|
||||
|
||||
export type ReleaseNotes = string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined
|
||||
|
||||
export interface UpdateInfoLike {
|
||||
version: string
|
||||
releaseNotes?: ReleaseNotes
|
||||
}
|
||||
|
||||
/** 업데이터(electron-updater) 이벤트를 서비스로 넘기는 콜백 */
|
||||
export interface UpdaterEventHandlers {
|
||||
onChecking(): void
|
||||
onAvailable(info: UpdateInfoLike): void
|
||||
onNotAvailable(version: string): void
|
||||
onProgress(progress: UpdateProgressPayload): void
|
||||
onDownloaded(version: string): void
|
||||
onError(message: string): void
|
||||
}
|
||||
|
||||
export interface UpdaterPort {
|
||||
checkForUpdates(): Promise<unknown>
|
||||
downloadUpdate(): Promise<unknown>
|
||||
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void
|
||||
setChannel(channel: UpdateChannel, allowPrerelease: boolean): void
|
||||
setDifferentialDisabled(disabled: boolean): void
|
||||
}
|
||||
|
||||
/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */
|
||||
export interface PolicySource {
|
||||
load(): Promise<UpdatePolicy>
|
||||
}
|
||||
|
||||
export type ConsentAnswer = 'download' | 'later' | 'skip'
|
||||
export type RestartAnswer = 'restart' | 'later'
|
||||
|
||||
export interface UpdatePrompter {
|
||||
askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise<ConsentAnswer>
|
||||
askRestart(version: string): Promise<RestartAnswer>
|
||||
}
|
||||
|
||||
export interface UpdateServiceDeps {
|
||||
/** 지원 환경(패키지 빌드·플랫폼·feed)일 때 업데이터를 만든다. 아니면 null(자동 업데이트 비활성). */
|
||||
createUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null
|
||||
policySource: PolicySource
|
||||
prompter: UpdatePrompter
|
||||
currentVersion(): string
|
||||
now(): number
|
||||
}
|
||||
|
||||
// ── 서비스 ───────────────────────────────────────────────────
|
||||
|
||||
class UpdateService extends EventEmitter {
|
||||
private _initialized = false
|
||||
private _initialTimer: NodeJS.Timeout | null = null
|
||||
private _intervalTimer: NodeJS.Timeout | null = null
|
||||
private _promptShown = false
|
||||
private _autoUpdater: import('electron-updater').AppUpdater | null = null
|
||||
/** 마지막으로 사용자에게 물어본 제안 — 새 버전/필수 격상/간격 경과 시 다시 묻는다 */
|
||||
private _lastPrompt: UpdatePromptRecord | null = null
|
||||
private _prompting = false
|
||||
private _updater: UpdaterPort | null = null
|
||||
private _downloading = false
|
||||
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
|
||||
private _channel: UpdateChannel = 'latest'
|
||||
private _forceFullDownload = false
|
||||
private readonly _deps: UpdateServiceDeps
|
||||
|
||||
constructor(deps: Partial<UpdateServiceDeps> = {}) {
|
||||
super()
|
||||
this._deps = {
|
||||
createUpdater: deps.createUpdater ?? createElectronUpdater,
|
||||
policySource: deps.policySource ?? createRemotePolicySource(),
|
||||
prompter: deps.prompter ?? createDialogPrompter(),
|
||||
currentVersion: deps.currentVersion ?? (() => app.getVersion()),
|
||||
now: deps.now ?? (() => Date.now()),
|
||||
}
|
||||
}
|
||||
|
||||
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||
initialize(): void {
|
||||
if (this._initialized) return
|
||||
this._initialized = true
|
||||
|
||||
if (!app.isPackaged) {
|
||||
logger.info('dev 실행 — 자동 업데이트 비활성')
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
|
||||
return
|
||||
}
|
||||
if (!UPDATE_FEED_URL) {
|
||||
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
|
||||
return
|
||||
}
|
||||
this._updater = this._deps.createUpdater({
|
||||
onChecking: () => {
|
||||
logger.info('업데이트 확인 중...')
|
||||
this.emit('checking-for-update', undefined as unknown as void)
|
||||
},
|
||||
onAvailable: (info) => {
|
||||
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
||||
void this.handleUpdateAvailable(info)
|
||||
},
|
||||
onNotAvailable: (version) => {
|
||||
logger.info(`최신 버전 사용 중: v${version}`)
|
||||
this.emit('update-not-available', { version })
|
||||
},
|
||||
onProgress: (progress) => this.emit('download-progress', progress),
|
||||
onDownloaded: (version) => {
|
||||
this._downloading = false
|
||||
logger.info(`업데이트 다운로드 완료: v${version}`)
|
||||
this.emit('update-downloaded', { version })
|
||||
void this._promptRestart(version)
|
||||
},
|
||||
onError: (message) => {
|
||||
// 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다
|
||||
if (this._downloading) this._lastPrompt = null
|
||||
this._downloading = false
|
||||
logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`)
|
||||
this.emit('update-error', { message })
|
||||
},
|
||||
})
|
||||
if (!this._updater) return
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
this._autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const updater = this._autoUpdater
|
||||
updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
|
||||
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
|
||||
updater.autoDownload = false
|
||||
updater.autoInstallOnAppQuit = true
|
||||
this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
|
||||
updater.logger = {
|
||||
info: (msg: unknown) => logger.info(String(msg)),
|
||||
warn: (msg: unknown) => logger.warn(String(msg)),
|
||||
error: (msg: unknown) => logger.error(String(msg)),
|
||||
debug: (msg: unknown) => logger.debug(String(msg)),
|
||||
}
|
||||
|
||||
updater.on('checking-for-update', () => {
|
||||
logger.info('업데이트 확인 중...')
|
||||
this.emit('checking-for-update', undefined as unknown as void)
|
||||
})
|
||||
|
||||
updater.on('update-available', (info) => {
|
||||
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
||||
void this._handleUpdateAvailable(info)
|
||||
})
|
||||
|
||||
updater.on('update-not-available', (info) => {
|
||||
logger.info(`최신 버전 사용 중: v${info.version}`)
|
||||
this.emit('update-not-available', { version: info.version })
|
||||
})
|
||||
|
||||
updater.on('download-progress', (progress) => {
|
||||
this.emit('download-progress', {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total
|
||||
})
|
||||
})
|
||||
|
||||
updater.on('update-downloaded', (info) => {
|
||||
this._downloading = false
|
||||
logger.info(`업데이트 다운로드 완료: v${info.version}`)
|
||||
this.emit('update-downloaded', { version: info.version })
|
||||
void this._promptRestart(info.version)
|
||||
})
|
||||
|
||||
updater.on('error', (err) => {
|
||||
this._downloading = false
|
||||
logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`)
|
||||
this.emit('update-error', { message: err.message })
|
||||
})
|
||||
|
||||
// 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다.
|
||||
void this._loadPolicy()
|
||||
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||
this._initialTimer = setTimeout(() => void this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||
this._intervalTimer = setInterval(() => void this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
||||
}
|
||||
|
||||
/** 수동 또는 주기적 업데이트 확인 */
|
||||
checkForUpdates(): void {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
/**
|
||||
* 수동 또는 주기적 업데이트 확인.
|
||||
* 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는
|
||||
* 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다.
|
||||
*/
|
||||
async checkForUpdates(): Promise<void> {
|
||||
if (!this._updater || this._downloading) return
|
||||
await this._refreshPolicy()
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||
return
|
||||
}
|
||||
this._autoUpdater.checkForUpdates().catch((err: unknown) => {
|
||||
try {
|
||||
await this._updater.checkForUpdates()
|
||||
} catch (err) {
|
||||
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 사용자 수락 시 업데이트 다운로드 시작 */
|
||||
async startDownload(): Promise<void> {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
if (!this._updater || this._downloading) return
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 다운로드 중단')
|
||||
return
|
||||
}
|
||||
this._downloading = true
|
||||
logger.info(
|
||||
this._forceFullDownload
|
||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
||||
)
|
||||
await this._autoUpdater.downloadUpdate()
|
||||
try {
|
||||
await this._updater.downloadUpdate()
|
||||
} catch (err) {
|
||||
if (this._downloading) this._lastPrompt = null
|
||||
this._downloading = false
|
||||
logger.warn(`업데이트 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 이번 버전 건너뛰기 설정 */
|
||||
|
|
@ -224,8 +265,73 @@ class UpdateService extends EventEmitter {
|
|||
this._intervalTimer = null
|
||||
}
|
||||
|
||||
/** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */
|
||||
async handleUpdateAvailable(info: UpdateInfoLike): Promise<void> {
|
||||
const currentVersion = this._deps.currentVersion()
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy: this._policy,
|
||||
channel: this._channel,
|
||||
currentVersion,
|
||||
targetVersion: info.version,
|
||||
skippedVersion: this._skippedVersion(),
|
||||
deviceId: this._deviceId(),
|
||||
})
|
||||
|
||||
if (offer.action === 'ignore') {
|
||||
switch (offer.reason) {
|
||||
case 'kill-switch':
|
||||
logger.info(`원격 킬 스위치 활성 — v${info.version} 무시`)
|
||||
break
|
||||
case 'channel':
|
||||
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
|
||||
break
|
||||
case 'skipped':
|
||||
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
|
||||
break
|
||||
case 'rollout':
|
||||
logger.info(
|
||||
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
|
||||
)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { decision } = offer
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
|
||||
this._forceFullDownload = decision.forceFull
|
||||
this._updater?.setDifferentialDisabled(decision.forceFull)
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
currentVersion,
|
||||
channel: this._channel,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
isMandatory: decision.mandatory,
|
||||
isMajorUpgrade: offer.isMajorUpgrade,
|
||||
isFullDownload: decision.forceFull,
|
||||
reason: decision.reason,
|
||||
})
|
||||
|
||||
if (this._downloading) return
|
||||
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
if (offer.action === 'force-download') {
|
||||
logger.info(`v${info.version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
return
|
||||
}
|
||||
|
||||
await this._promptUserConsent(info.version, info.releaseNotes, decision)
|
||||
}
|
||||
|
||||
// ── 내부 ──
|
||||
|
||||
private async _refreshPolicy(): Promise<void> {
|
||||
this._policy = await this._deps.policySource.load()
|
||||
this._applyChannel(this._resolveChannel(this._policy))
|
||||
}
|
||||
|
||||
private _resolveChannel(policy: UpdatePolicy): UpdateChannel {
|
||||
const configured = configGet('updateChannel')
|
||||
return isUpdateChannel(configured) ? configured : policy.defaultChannel
|
||||
|
|
@ -233,38 +339,9 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
private _applyChannel(channel: UpdateChannel): void {
|
||||
this._channel = channel
|
||||
if (!this._autoUpdater) return
|
||||
if (!this._updater) return
|
||||
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
|
||||
this._autoUpdater.channel = channel
|
||||
this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease
|
||||
}
|
||||
|
||||
private async _loadPolicy(): Promise<void> {
|
||||
if (!UPDATE_POLICY_URL) return
|
||||
try {
|
||||
const response = await fetch(UPDATE_POLICY_URL, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
this._policy = parseUpdatePolicy(await response.json())
|
||||
this._applyChannel(this._resolveChannel(this._policy))
|
||||
logger.info(
|
||||
`원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`,
|
||||
)
|
||||
} catch (err) {
|
||||
// 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속).
|
||||
logger.debug(
|
||||
`원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */
|
||||
private _setDifferentialDisabled(disabled: boolean): void {
|
||||
if (!this._autoUpdater) return
|
||||
;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload =
|
||||
disabled
|
||||
this._updater.setChannel(channel, channelPolicy.allowPrerelease)
|
||||
}
|
||||
|
||||
private _deviceId(): string {
|
||||
|
|
@ -282,142 +359,44 @@ class UpdateService extends EventEmitter {
|
|||
return typeof legacy === 'string' && legacy ? legacy : null
|
||||
}
|
||||
|
||||
private async _handleUpdateAvailable(
|
||||
info: import('electron-updater').UpdateInfo,
|
||||
): Promise<void> {
|
||||
const currentVersion = app.getVersion()
|
||||
const decision = decideUpdate(this._policy, currentVersion, info.version)
|
||||
const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false }
|
||||
|
||||
if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) {
|
||||
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!decision.mandatory && this._skippedVersion() === info.version) {
|
||||
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) {
|
||||
logger.info(
|
||||
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
|
||||
this._forceFullDownload = decision.forceFull
|
||||
this._setDifferentialDisabled(decision.forceFull)
|
||||
|
||||
const currentMajor = majorOf(currentVersion)
|
||||
const targetMajor = majorOf(info.version)
|
||||
const isMajorUpgrade =
|
||||
currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
currentVersion,
|
||||
channel: this._channel,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
isMandatory: decision.mandatory,
|
||||
isMajorUpgrade,
|
||||
isFullDownload: decision.forceFull,
|
||||
reason: decision.reason,
|
||||
})
|
||||
|
||||
await this._promptUserConsent(
|
||||
info.version,
|
||||
info.releaseNotes,
|
||||
decision,
|
||||
)
|
||||
}
|
||||
|
||||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined,
|
||||
releaseNotes: ReleaseNotes,
|
||||
decision: UpdateDecision,
|
||||
): Promise<void> {
|
||||
if (this._downloading) return
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
if (decision.mandatory && decision.reason === 'below-force-install') {
|
||||
logger.info(`v${version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
if (this._prompting) return
|
||||
const now = this._deps.now()
|
||||
if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) {
|
||||
logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`)
|
||||
return
|
||||
}
|
||||
if (this._promptShown) return
|
||||
this._promptShown = true
|
||||
|
||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
||||
const mandatoryNote = decision.mandatory
|
||||
? isKo
|
||||
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
|
||||
: '\n\nThis update is required (end of support).'
|
||||
: ''
|
||||
|
||||
const options = decision.mandatory
|
||||
? {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '필수 업데이트' : 'Required Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}`
|
||||
: `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`,
|
||||
buttons: isKo ? ['지금 업데이트'] : ['Update Now'],
|
||||
defaultId: 0,
|
||||
cancelId: -1,
|
||||
}
|
||||
: {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '새 버전 업데이트' : 'Software Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}`
|
||||
: `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`,
|
||||
buttons: isKo
|
||||
? ['지금 다운로드', '나중에', '이 버전 건너뛰기']
|
||||
: ['Download Now', 'Later', 'Skip This Version'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
}
|
||||
|
||||
const win = getMainWindow()
|
||||
const { response } =
|
||||
win && !win.isDestroyed()
|
||||
? await dialog.showMessageBox(win, options)
|
||||
: await dialog.showMessageBox(options)
|
||||
|
||||
if (response === 0) {
|
||||
void this.startDownload()
|
||||
} else if (!decision.mandatory && response === 2) {
|
||||
this.skipVersion(version)
|
||||
this._lastPrompt = { version, mandatory: decision.mandatory, at: now }
|
||||
this._prompting = true
|
||||
try {
|
||||
const answer = await this._deps.prompter.askConsent({
|
||||
version,
|
||||
releaseNotes,
|
||||
mandatory: decision.mandatory,
|
||||
})
|
||||
if (answer === 'download') {
|
||||
void this.startDownload()
|
||||
} else if (!decision.mandatory && answer === 'skip') {
|
||||
this.skipVersion(version)
|
||||
}
|
||||
} finally {
|
||||
this._prompting = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
|
||||
private async _promptRestart(version: string): Promise<void> {
|
||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const options = {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?`
|
||||
: `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`,
|
||||
buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
}
|
||||
|
||||
const win = getMainWindow()
|
||||
const { response } =
|
||||
win && !win.isDestroyed()
|
||||
? await dialog.showMessageBox(win, options)
|
||||
: await dialog.showMessageBox(options)
|
||||
|
||||
if (response === 0 && this._autoUpdater) {
|
||||
const answer = await this._deps.prompter.askRestart(version)
|
||||
if (answer === 'restart' && this._updater) {
|
||||
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행
|
||||
this._autoUpdater.quitAndInstall(false, true)
|
||||
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
|
||||
// quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다.
|
||||
this._updater.quitAndInstall(false, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -438,6 +417,177 @@ class UpdateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 기본 어댑터 (electron-updater / fetch / electron dialog) ─────
|
||||
|
||||
function createElectronUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null {
|
||||
if (!app.isPackaged) {
|
||||
logger.info('dev 실행 — 자동 업데이트 비활성')
|
||||
return null
|
||||
}
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
|
||||
return null
|
||||
}
|
||||
if (!UPDATE_FEED_URL) {
|
||||
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
|
||||
return null
|
||||
}
|
||||
|
||||
let updater: import('electron-updater').AppUpdater
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
updater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
|
||||
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
|
||||
updater.autoDownload = false
|
||||
updater.autoInstallOnAppQuit = true
|
||||
updater.logger = {
|
||||
info: (msg: unknown) => logger.info(String(msg)),
|
||||
warn: (msg: unknown) => logger.warn(String(msg)),
|
||||
error: (msg: unknown) => logger.error(String(msg)),
|
||||
debug: (msg: unknown) => logger.debug(String(msg)),
|
||||
}
|
||||
|
||||
updater.on('checking-for-update', () => handlers.onChecking())
|
||||
updater.on('update-available', (info) => handlers.onAvailable(info))
|
||||
updater.on('update-not-available', (info) => handlers.onNotAvailable(info.version))
|
||||
updater.on('download-progress', (progress) =>
|
||||
handlers.onProgress({
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
}),
|
||||
)
|
||||
updater.on('update-downloaded', (info) => handlers.onDownloaded(info.version))
|
||||
updater.on('error', (err) => handlers.onError(err.message))
|
||||
|
||||
return {
|
||||
checkForUpdates: () => updater.checkForUpdates(),
|
||||
downloadUpdate: () => updater.downloadUpdate(),
|
||||
quitAndInstall: (isSilent, isForceRunAfter) => updater.quitAndInstall(isSilent, isForceRunAfter),
|
||||
setChannel: (channel, allowPrerelease) => {
|
||||
updater.channel = channel
|
||||
updater.allowPrerelease = allowPrerelease
|
||||
},
|
||||
setDifferentialDisabled: (disabled) => {
|
||||
// NSIS 이외 업데이터는 이 속성을 무시한다
|
||||
;(updater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = disabled
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 원격 정책 공급원 — 실패하면 마지막으로 성공한 정책, 그것도 없으면 내장 기본값 */
|
||||
export function createRemotePolicySource(
|
||||
url: string | null | undefined = UPDATE_POLICY_URL,
|
||||
fetchImpl: typeof fetch = (input, init) => fetch(input, init),
|
||||
): PolicySource {
|
||||
let lastGood: UpdatePolicy | null = null
|
||||
return {
|
||||
async load(): Promise<UpdatePolicy> {
|
||||
if (!url) return lastGood ?? DEFAULT_UPDATE_POLICY
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
lastGood = parseUpdatePolicy(await response.json())
|
||||
logger.info(
|
||||
`원격 업데이트 정책 적용 — min=${lastGood.minimumSupportedVersion}, killSwitch=${lastGood.killSwitch}, staging=${lastGood.stagingPercentage}%`,
|
||||
)
|
||||
return lastGood
|
||||
} catch (err) {
|
||||
// 네트워크 실패 시 마지막 정책(없으면 내장 기본값)으로 fail-open (업데이트 자체는 계속).
|
||||
logger.debug(
|
||||
`원격 업데이트 정책 로드 실패 — ${lastGood ? '마지막 정책 유지' : '내장 기본값 사용'}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
return lastGood ?? DEFAULT_UPDATE_POLICY
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* electron dialog 기반 프롬프터.
|
||||
* TODO(i18n): 문구가 아직 ko/en 인라인이다 — update.* 키를 locale 파일에 추가한 뒤 t() 로 옮긴다.
|
||||
*/
|
||||
function createDialogPrompter(): UpdatePrompter {
|
||||
const isKorean = (): boolean => (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const show = async (options: Electron.MessageBoxOptions): Promise<number> => {
|
||||
const win = getMainWindow()
|
||||
const { response } =
|
||||
win && !win.isDestroyed()
|
||||
? await dialog.showMessageBox(win, options)
|
||||
: await dialog.showMessageBox(options)
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
async askConsent({ version, releaseNotes, mandatory }): Promise<ConsentAnswer> {
|
||||
const isKo = isKorean()
|
||||
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
||||
const mandatoryNote = mandatory
|
||||
? isKo
|
||||
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
|
||||
: '\n\nThis update is required (end of support).'
|
||||
: ''
|
||||
|
||||
if (mandatory) {
|
||||
const response = await show({
|
||||
type: 'info',
|
||||
title: isKo ? '필수 업데이트' : 'Required Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}`
|
||||
: `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`,
|
||||
buttons: isKo ? ['지금 업데이트'] : ['Update Now'],
|
||||
defaultId: 0,
|
||||
cancelId: -1,
|
||||
})
|
||||
return response === 0 ? 'download' : 'later'
|
||||
}
|
||||
|
||||
const response = await show({
|
||||
type: 'info',
|
||||
title: isKo ? '새 버전 업데이트' : 'Software Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}`
|
||||
: `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`,
|
||||
buttons: isKo
|
||||
? ['지금 다운로드', '나중에', '이 버전 건너뛰기']
|
||||
: ['Download Now', 'Later', 'Skip This Version'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
if (response === 0) return 'download'
|
||||
if (response === 2) return 'skip'
|
||||
return 'later'
|
||||
},
|
||||
|
||||
async askRestart(version): Promise<RestartAnswer> {
|
||||
const isKo = isKorean()
|
||||
const response = await show({
|
||||
type: 'info',
|
||||
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?`
|
||||
: `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`,
|
||||
buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
return response === 0 ? 'restart' : 'later'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
||||
let instance: UpdateService | null = null
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@
|
|||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { getLocalLLMService } from './LocalLLMService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getAudioCaptureService } from './AudioCaptureService'
|
||||
import { getTTSPlaybackService } from './TTSPlaybackService'
|
||||
import { getSoundEffectService } from './SoundEffectService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
|
|
@ -386,45 +386,22 @@ class VoiceConversationService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* LLM 백엔드 선택 + chatStream 생성.
|
||||
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback.
|
||||
* LLM 백엔드 선택 + chatStream 생성 — 선택 정책(online+가용 → Premium, 아니면 Local)은
|
||||
* LlmGateway 가 소유한다. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다.
|
||||
*/
|
||||
private async _createChatStream(signal: AbortSignal): Promise<{
|
||||
generator: AsyncGenerator<string, string>
|
||||
backend: 'local' | 'premium'
|
||||
}> {
|
||||
const chatMessages = this._buildChatMessages()
|
||||
const backend = configGet('llmBackend')
|
||||
|
||||
if (backend === 'online') {
|
||||
try {
|
||||
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
||||
const premium = getPremiumLLMService()
|
||||
if (premium.isAvailable()) {
|
||||
return { generator: premium.chatStream(chatMessages), backend: 'premium' }
|
||||
}
|
||||
logger.warn('Premium LLM not available for conversation, falling back to local')
|
||||
} catch (err) {
|
||||
logger.warn('Premium LLM init failed for conversation:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const local = getLocalLLMService()
|
||||
if (!local.isAvailable()) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMServerUnreachable,
|
||||
'Local LLM (Ollama) is not available',
|
||||
)
|
||||
}
|
||||
return {
|
||||
generator: local.chatStream(chatMessages, {
|
||||
signal,
|
||||
return getLlmGateway().openChatStream(chatMessages, {
|
||||
signal,
|
||||
local: {
|
||||
maxTokens: 512,
|
||||
timeoutMs: 60_000,
|
||||
keepAlive: '2m',
|
||||
}),
|
||||
backend: 'local',
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private _isCurrentResponse(responseAbortController: AbortController): boolean {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
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,
|
||||
STTStatus,
|
||||
} from '@d3ro/core/types'
|
||||
import type { ISTTDriver } from './types'
|
||||
import type { ISTTDriver, STTEngine } from './types'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { OpenAIDriver } from './drivers/OpenAIDriver'
|
||||
import { GroqDriver } from './drivers/GroqDriver'
|
||||
import { DeepgramDriver } from './drivers/DeepgramDriver'
|
||||
|
|
@ -26,6 +27,23 @@ import { normalizeLoopbackUrl } from '../../utils/loopback'
|
|||
|
||||
const logger = getLogger('STTManager')
|
||||
|
||||
/**
|
||||
* 클라우드 실패 → 로컬 폴백의 전체 상한(모델 로딩 포함).
|
||||
* 로컬 엔진의 각 단계에도 타임아웃이 있지만, 폴백 경로가 어떤 이유로든 멈추면
|
||||
* 받아쓰기 액션 큐 전체가 굳으므로 여기서 한 번 더 끊는다.
|
||||
*/
|
||||
export const LOCAL_FALLBACK_TIMEOUT_MS = 180_000
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new D3ROError(ErrorCode.STTTranscriptionTimeout, message)), timeoutMs)
|
||||
})
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
}
|
||||
|
||||
export const STT_PROVIDERS_META: STTProviderInfo[] = [
|
||||
{
|
||||
id: 'local',
|
||||
|
|
@ -116,7 +134,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [
|
|||
},
|
||||
]
|
||||
|
||||
export class STTManager extends EventEmitter {
|
||||
export class STTManager extends EventEmitter implements STTEngine {
|
||||
private _drivers = new Map<STTProviderType, ISTTDriver>()
|
||||
|
||||
constructor() {
|
||||
|
|
@ -210,7 +228,7 @@ export class STTManager extends EventEmitter {
|
|||
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
|
||||
const provider = this.getActiveProvider()
|
||||
|
||||
// 1. Local STT
|
||||
// 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다
|
||||
if (provider === 'local') {
|
||||
return getLocalSTTService().transcribe(audioBuffer, options)
|
||||
}
|
||||
|
|
@ -219,7 +237,7 @@ export class STTManager extends EventEmitter {
|
|||
const driver = this._drivers.get(provider)
|
||||
if (!driver) {
|
||||
logger.warn(`Driver not found for provider ${provider}, falling back to local`)
|
||||
return getLocalSTTService().transcribe(audioBuffer, options)
|
||||
return this._transcribeLocalFallback(audioBuffer, options)
|
||||
}
|
||||
|
||||
const config = this.getProviderConfig(provider)
|
||||
|
|
@ -236,7 +254,7 @@ export class STTManager extends EventEmitter {
|
|||
logger.info(`Auto-fallback: executing Local Whisper transcription...`)
|
||||
this.emit('fallback-to-local', { provider, reason: errorMsg })
|
||||
try {
|
||||
return await getLocalSTTService().transcribe(audioBuffer, options)
|
||||
return await this._transcribeLocalFallback(audioBuffer, options)
|
||||
} catch (localError) {
|
||||
logger.error(`Local STT fallback also failed: ${localError}`)
|
||||
throw error // 원래 에러 유지
|
||||
|
|
@ -247,6 +265,45 @@ export class STTManager extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라우드 공급자에서 로컬 엔진으로 넘어가는 경로.
|
||||
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가
|
||||
* 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어
|
||||
* 받아쓰기 액션 큐가 굳지 않게 한다.
|
||||
*/
|
||||
private _transcribeLocalFallback(
|
||||
audioBuffer: Buffer,
|
||||
options?: TranscribeOptions,
|
||||
): Promise<TranscriptionResult> {
|
||||
return withTimeout(
|
||||
getLocalSTTService().transcribe(audioBuffer, options),
|
||||
LOCAL_FALLBACK_TIMEOUT_MS,
|
||||
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* STTEngine 포트: 현재 공급자로 전사할 준비를 한다.
|
||||
* 로컬이면 설정된 모델을 올리고(이미 올라가 있으면 즉시 반환), 클라우드면 할 일이 없다.
|
||||
*/
|
||||
async ensureReady(): Promise<void> {
|
||||
if (this.getActiveProvider() !== 'local') return
|
||||
await getLocalSTTService().initialize(configGet('sttModelId'))
|
||||
}
|
||||
|
||||
/** STTEngine 포트: 로컬 공급자에서 선택한 모델이 설치되지 않았으면 그 id를 돌려준다. */
|
||||
getMissingModel(): string | null {
|
||||
if (this.getActiveProvider() !== 'local') return null
|
||||
const modelId = configGet('sttModelId')
|
||||
const model = getLocalSTTService().getModels().find((m) => m.id === modelId)
|
||||
return model && !model.downloaded ? modelId : null
|
||||
}
|
||||
|
||||
/** STTEngine 포트: 실시간 미리보기는 로컬 Whisper에서만 지원한다. */
|
||||
supportsPartial(): boolean {
|
||||
return this.getActiveProvider() === 'local'
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 중 실시간 미리보기 전사(최종 삽입과 무관).
|
||||
* 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService'
|
||||
import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types'
|
||||
|
||||
export type { TranscriptionResult, TranscribeOptions }
|
||||
|
||||
export interface ISTTDriver {
|
||||
readonly id: STTProviderType
|
||||
readonly name: string
|
||||
|
|
@ -18,3 +20,26 @@ export interface ISTTDriver {
|
|||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* 받아쓰기 파이프라인이 의존하는 STT 엔진 포트.
|
||||
*
|
||||
* 공급자(로컬/클라우드) 선택과 로컬 폴백 정책은 구현(STTManager) 한 곳에만 둔다.
|
||||
* 오케스트레이터(VoiceModeService)는 `provider === 'local'` 같은 분기를 알 필요가 없다.
|
||||
*/
|
||||
export interface STTEngine {
|
||||
/**
|
||||
* 현재 공급자로 전사할 준비를 한다. 로컬 공급자면 설정된 모델을 올리고,
|
||||
* 클라우드 공급자면 즉시 끝난다. 실패하면 예외를 던진다(무한 대기 없음).
|
||||
*/
|
||||
ensureReady(): Promise<void>
|
||||
/**
|
||||
* 녹음 시작 전에 확인할 수 있는 "필수 모델 미설치" 상태.
|
||||
* 설치되지 않은 모델 id를 돌려주고, 문제가 없으면 null.
|
||||
*/
|
||||
getMissingModel(): string | null
|
||||
/** 실시간 미리보기(부분 전사)를 지원하는지 — 클라우드 공급자는 요청 비용 때문에 지원하지 않는다. */
|
||||
supportsPartial(): boolean
|
||||
transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult>
|
||||
transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise<string>
|
||||
}
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 업데이트 제안 평가 (게이팅 순서의 단일 정본)
|
||||
// ============================================================
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* main 이 실어 보낸 sessionId 가 이 패널의 회의인지 확인한다.
|
||||
* sessionId 가 없는(구버전) 이벤트는 그대로 받아들인다.
|
||||
*/
|
||||
function isForSession(payload: object, sessionId: string): boolean {
|
||||
if ('sessionId' in payload && typeof payload.sessionId === 'string') {
|
||||
return payload.sessionId === sessionId
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 타이핑 인디케이터 점 3개 애니메이션
|
||||
function TypingIndicator(): React.ReactElement {
|
||||
return (
|
||||
|
|
@ -74,21 +85,38 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
scrollToBottom()
|
||||
}, [messages, streamingContent, scrollToBottom])
|
||||
|
||||
// 이벤트 구독
|
||||
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
|
||||
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
|
||||
useEffect(() => {
|
||||
setMessages([])
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
setError(null)
|
||||
isSendingChatRef.current = false
|
||||
return () => {
|
||||
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||
void window.electronAPI.meetingChat.cancel()
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// 이벤트 구독 — 다른 회의로 향한 이벤트는 무시한다
|
||||
useEffect(() => {
|
||||
const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => {
|
||||
if (!isForSession(data, sessionId)) return
|
||||
setStreaming(true)
|
||||
setStreamingContent((prev) => prev + data.token)
|
||||
})
|
||||
|
||||
const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => {
|
||||
setMessages((prev) => [...prev, msg])
|
||||
if (!isForSession(msg, sessionId)) return
|
||||
setMessages((prev) => [...prev, { role: msg.role, content: msg.content, timestamp: msg.timestamp }])
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
isSendingChatRef.current = false
|
||||
})
|
||||
|
||||
const unsubError = window.electronAPI.meetingChat.onError((err) => {
|
||||
if (!isForSession(err, sessionId)) return
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
setError(err.message)
|
||||
|
|
@ -139,7 +167,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
setMessages([])
|
||||
setStreamingContent('')
|
||||
setStreaming(false)
|
||||
window.electronAPI.meetingChat.clear({ sessionId })
|
||||
window.electronAPI.meetingChat.clear()
|
||||
}, [sessionId])
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue