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

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

View file

@ -9,7 +9,12 @@ import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingServ
import { acquireGlobalInputHook } from './services/global-input-hook' import { acquireGlobalInputHook } from './services/global-input-hook'
import { trackModifierKeys } from './services/modifier-state' import { trackModifierKeys } from './services/modifier-state'
import { getVoiceModeService } from './services/VoiceModeService' import { getVoiceModeService } from './services/VoiceModeService'
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService' import {
startLocalLLMAvailability,
getLocalLLMService,
disposeLocalLLMService,
} from './services/LocalLLMService'
import { disposeLocalSTTServiceIfStarted } from './services/LocalSTTService'
import { getHistoryService } from './services/HistoryService' import { getHistoryService } from './services/HistoryService'
import { persistCompletedVoiceSessionSafe } from './voice-session-persist' import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
import { getTextInsertService } from './services/TextInsertService' import { getTextInsertService } from './services/TextInsertService'
@ -34,14 +39,16 @@ import {
hideCommandPopup, hideCommandPopup,
sendKeyToCommandPopup, sendKeyToCommandPopup,
isCommandPopupVisible, isCommandPopupVisible,
hideRecordingTip,
updateRecordingTipState,
showSuggestionOverlay, showSuggestionOverlay,
updateSuggestionOverlay, updateSuggestionOverlay,
hideSuggestionOverlay, hideSuggestionOverlay,
isSuggestionOverlayVisible, isSuggestionOverlayVisible,
} from './windows/WindowManager' } from './windows/WindowManager'
import { createTray } from './windows/TrayManager' import { createTray } from './windows/TrayManager'
import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter'
import { PopupNavigationController } from './windows/popup-navigation'
import { setupLifecycle } from './lifecycle'
import { getShutdownRegistry } from './shutdown-registry'
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy' import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
import { registerAllIpcHandlers } from './ipc' import { registerAllIpcHandlers } from './ipc'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
@ -56,6 +63,11 @@ interface BootstrapStep {
} }
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
// 종료 훅을 가장 먼저 건다 — 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우고
// 등록된 자원을 정리해야 한다. (index.ts 의 뒤늦은 호출은 멱등이라 아무것도 하지 않는다)
setupLifecycle()
registerCoreShutdownSteps()
const steps: BootstrapStep[] = [ const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger }, { name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig }, { name: 'config', critical: false, fn: initConfig },
@ -102,6 +114,16 @@ export async function bootstrap(): Promise<void> {
} }
} }
/**
* 종료 시 정리할 자원. 등록의 역순으로 정리되며, 쓰지 않은 서비스는 새로 만들지 않는다.
* (프로세스를 소유하는 서비스를 새로 추가하면 여기나 그 서비스의 초기화 단계에서 등록한다)
*/
function registerCoreShutdownSteps(): void {
const registry = getShutdownRegistry()
registry.register({ name: 'local-llm', dispose: () => disposeLocalLLMService() })
registry.register({ name: 'local-stt', dispose: () => disposeLocalSTTServiceIfStarted() })
}
async function initLogger(): Promise<void> { async function initLogger(): Promise<void> {
initLoggerService() initLoggerService()
} }
@ -255,6 +277,11 @@ async function initInputIntelligence(): Promise<void> {
}) })
telemetry.start() telemetry.start()
// 전역 입력 훅·제안 타이머는 종료 시 풀어야 한다
const registry = getShutdownRegistry()
registry.register({ name: 'suggestion', dispose: () => suggestion.dispose() })
registry.register({ name: 'input-telemetry', dispose: () => telemetry.dispose() })
} }
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */ /** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
@ -285,89 +312,82 @@ async function initAutoLaunch(): Promise<void> {
async function initAutoUpdate(): Promise<void> { async function initAutoUpdate(): Promise<void> {
const { getUpdateService } = await import('./services/UpdateService') const { getUpdateService } = await import('./services/UpdateService')
getUpdateService().initialize() const updates = getUpdateService()
updates.initialize()
getShutdownRegistry().register({ name: 'auto-update', dispose: () => updates.dispose() })
} }
async function initPopupWindows(): Promise<void> { async function initPopupWindows(): Promise<void> {
preloadPopupWindows() preloadPopupWindows()
setupHistoryPopupIPC()
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환) // 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ }) getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
const popups = createPopupNavigationController()
getShutdownRegistry().register({ name: 'popup-navigation', dispose: () => popups.dispose() })
// 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다. // 팝업 토글도 재바인딩 가능한 액션이다 — 하드코딩 accelerator 대신 KeyBindingService를 구독한다.
getKeyBindingService().on('triggered', (payload) => { getKeyBindingService().on('triggered', (payload) => {
if (payload.type !== 'pressed') return if (payload.type !== 'pressed') return
if (payload.actionId === 'history-popup') { if (payload.actionId === 'history-popup') {
toggleHistoryPopup() popups.toggle('history')
} else if (payload.actionId === 'command-popup') { } else if (payload.actionId === 'command-popup') {
toggleCommandPopup() popups.toggle('command')
} }
}) })
setupCommandPopupIPC() setupPopupIPC(popups)
} }
function toggleHistoryPopup(): void { /** 팝업 컨트롤러를 Electron 창·전역 단축키·서비스 어댑터로 조립한다. */
if (isHistoryPopupVisible()) { function createPopupNavigationController(): PopupNavigationController {
hideHistoryPopup() return new PopupNavigationController({
unregisterPopupNavKeys() windows: {
return history: {
} show: () => {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>) showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
registerPopupNavKeys() },
} hide: () => hideHistoryPopup(),
isVisible: () => isHistoryPopupVisible(),
function toggleCommandPopup(): void { sendKey: (key) => sendKeyToHistoryPopup(key),
if (isCommandPopupVisible()) { },
hideCommandPopup() command: {
unregisterPopupNavKeys() show: () => {
return
}
const instructions = getCustomInstructionService().getAll() const instructions = getCustomInstructionService().getAll()
const activeId = configGet('activeInstructionId') as string | null const activeId = configGet('activeInstructionId') as string | null
showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null) showCommandPopup(instructions as unknown as Array<Record<string, unknown>>, activeId || null)
registerPopupNavKeys('command') },
} hide: () => hideCommandPopup(),
isVisible: () => isCommandPopupVisible(),
// 히스토리 팝업 키 네비게이션 등록/해제 sendKey: (key) => sendKeyToCommandPopup(key),
const POPUP_NAV_KEYS: Array<{ accel: string; key: string }> = [ },
{ accel: 'Up', key: 'ArrowUp' }, },
{ accel: 'Down', key: 'ArrowDown' }, shortcuts: {
{ accel: 'Return', key: 'Enter' }, register: (accelerator, callback) => globalShortcut.register(accelerator, callback),
{ accel: 'Escape', key: 'Escape' }, unregister: (accelerator) => globalShortcut.unregister(accelerator),
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' }, },
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' }, insertText: async (text) => {
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' }, await getTextInsertService().insertText(text)
] },
applyCommandSelection: ({ id, name }) => {
function registerPopupNavKeys(target: 'history' | 'command' = 'history'): void { if (id) {
for (const { accel, key } of POPUP_NAV_KEYS) { // 명령어 선택 → 활성 명령어로 설정
try { configSet('activeInstructionId', id)
globalShortcut.register(accel, () => { configSet('defaultLLMAction', 'custom')
if (target === 'history' && isHistoryPopupVisible()) { logger.info(`Active command set: ${name} (${id})`)
sendKeyToHistoryPopup(key) } else {
} else if (target === 'command' && isCommandPopupVisible()) { // 선택 해제 → 명령어 없음 (원본 삽입)
sendKeyToCommandPopup(key) configSet('activeInstructionId', '')
configSet('defaultLLMAction', 'none')
logger.info('Active command cleared (none)')
} }
// CMD 페이지 UI 갱신 알림
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: id })
},
warn: (message) => logger.warn(message),
}) })
} catch {
// 일부 키는 globalShortcut으로 등록 불가할 수 있음
} }
}
}
function unregisterPopupNavKeys(): void {
for (const { accel } of POPUP_NAV_KEYS) {
try {
globalShortcut.unregister(accel)
} catch {
// 이미 해제된 경우 무시
}
}
}
async function initVoiceMode(): Promise<void> { async function initVoiceMode(): Promise<void> {
const voiceMode = getVoiceModeService() const voiceMode = getVoiceModeService()
@ -375,17 +395,28 @@ async function initVoiceMode(): Promise<void> {
const soundEffect = getSoundEffectService() const soundEffect = getSoundEffectService()
// 효과음 연동 (팝업 제어는 VoiceModeService 내부에서 처리) // RecordingTip·결과 팝업 표시는 프레젠터 한 곳이 담당한다 (취소 가능한 에러 숨김 타이머 하나)
const tip = new RecordingTipPresenter(createWindowRecordingTipView())
getShutdownRegistry().register({ name: 'recording-tip', dispose: () => tip.dispose() })
getShutdownRegistry().register({ name: 'voice-mode', dispose: () => voiceMode.dispose() })
voiceMode.on('phase', ({ phase }) => tip.onPhase(phase))
voiceMode.on('audio-level', ({ level }) => tip.onAudioLevel(level))
voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text))
voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text))
// 효과음 연동
voiceMode.on('session-started', () => { voiceMode.on('session-started', () => {
tip.onSessionStarted()
soundEffect.play('recording-start') soundEffect.play('recording-start')
}) })
voiceMode.on('session-completed', ({ session, finalText }) => { voiceMode.on('session-completed', ({ session, finalText }) => {
tip.onSessionEnded()
soundEffect.play('recording-stop') soundEffect.play('recording-stop')
persistCompletedVoiceSessionSafe( persistCompletedVoiceSessionSafe(
(error, failedSession) => { (error) => {
voiceMode.emit('error', { error, session: failedSession }) voiceMode.emit('error', { error, session })
}, },
session, session,
finalText, finalText,
@ -398,13 +429,13 @@ async function initVoiceMode(): Promise<void> {
if (reason !== 'too-short') { if (reason !== 'too-short') {
soundEffect.play('cancel') soundEffect.play('cancel')
} }
hideRecordingTip() tip.onSessionEnded()
}) })
voiceMode.on('error', ({ error }) => { voiceMode.on('error', ({ error, severity }) => {
soundEffect.play('error') // 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
updateRecordingTipState('error', { errorMessage: error.message }) if (severity !== 'warning') soundEffect.play('error')
setTimeout(() => hideRecordingTip(), 3000) tip.onError(error.message, severity)
}) })
} }
@ -447,7 +478,7 @@ async function initMeetingSummaryWiring(): Promise<void> {
const captionService = getCaptionService() const captionService = getCaptionService()
const summaryService = getMeetingSummaryService() const summaryService = getMeetingSummaryService()
captionService.on('session-saved', (summary: { sessionId: string }) => { captionService.on('session-saved', (summary) => {
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기 // 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
import('./services/MeetingModeService').then(({ getMeetingModeService }) => { import('./services/MeetingModeService').then(({ getMeetingModeService }) => {
if (getMeetingModeService().isMeetingModeActive()) { if (getMeetingModeService().isMeetingModeActive()) {
@ -478,62 +509,31 @@ async function initMeetingMode(): Promise<void> {
getMeetingDocTemplateService() getMeetingDocTemplateService()
} }
// ── HistoryPopup IPC 연동 ──────────────────────────── // ── 팝업 IPC 연동 (얇은 어댑터 — 정책은 PopupNavigationController) ─────
function setupHistoryPopupIPC(): void { function setupPopupIPC(popups: PopupNavigationController): void {
// 히스토리 팝업 열기 (핫키에서 호출) // 히스토리 팝업 열기 (핫키 경로와 같게 내비게이션 키까지 묶는다)
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => { ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.SHOW_POPUP, () => {
const entries = getHistoryService().list({ page: 0, pageSize: 10 }).entries popups.show('history')
showHistoryPopup(entries as unknown as Array<Record<string, unknown>>)
}) })
// 아이템 선택 → 텍스트 삽입 // 아이템 선택 → 텍스트 삽입
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => { ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.ITEM_SELECTED, (_event: Electron.IpcMainEvent, data: { text: string }) => {
hideHistoryPopup() popups.selectHistoryItem(data.text)
unregisterPopupNavKeys()
setTimeout(async () => {
try {
await getTextInsertService().insertText(data.text)
} catch (error) {
logger.warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
}
}, 200)
}) })
// 팝업 닫기 // 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => { ipcMainRef.on(IPC_CHANNELS.POPUP_HISTORY.POPUP_DISMISSED, () => {
hideHistoryPopup() popups.dismiss('history')
unregisterPopupNavKeys()
}) })
}
// ── CommandPopup IPC 연동 ────────────────────────────
function setupCommandPopupIPC(): void {
// 명령어 선택 → 활성 명령어로 설정 // 명령어 선택 → 활성 명령어로 설정
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => { ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.SELECTED, (_event: Electron.IpcMainEvent, data: { id: string; name: string }) => {
hideCommandPopup() popups.selectCommand({ id: data.id, name: data.name })
unregisterPopupNavKeys()
if (data.id) {
// 명령어 선택 → 활성 명령어로 설정
configSet('activeInstructionId', data.id)
configSet('defaultLLMAction', 'custom')
logger.info(`Active command set: ${data.name} (${data.id})`)
} else {
// 선택 해제 → 명령어 없음 (원본 삽입)
configSet('activeInstructionId', '')
configSet('defaultLLMAction', 'none')
logger.info('Active command cleared (none)')
}
// CMD 페이지 UI 갱신 알림
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'command-changed', activeId: data.id })
}) })
// 팝업 닫기 // 팝업 닫기
ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => { ipcMainRef.on(IPC_CHANNELS.POPUP_COMMAND.DISMISSED, () => {
hideCommandPopup() popups.dismiss('command')
unregisterPopupNavKeys()
}) })
} }

View file

@ -1,13 +1,23 @@
// src/main/lifecycle.ts — 종료 시퀀스 // src/main/lifecycle.ts — 종료 시퀀스
//
// 종료 시 정리 대상은 ShutdownRegistry 에 서비스가 스스로 등록한다. lifecycle 은 개별 서비스를
// import 하지 않고 등록부만 비운다. setupLifecycle 은 멱등이다 — bootstrap 이 가장 먼저 불러
// 초기화 도중의 app.quit()(치명 단계 실패 등)도 isQuitting 을 세우게 한다(그래야 창의
// close-to-tray 가 종료를 가로채지 않는다). index.ts 의 뒤늦은 호출은 아무것도 하지 않는다.
import { app } from 'electron' import { app } from 'electron'
import { getLogger } from './services/LoggerService' import { getLogger } from './services/LoggerService'
import { getConfigService } from './services/ConfigService' import { getConfigService } from './services/ConfigService'
import { disposeLocalLLMService } from './services/LocalLLMService' import { getShutdownRegistry } from './shutdown-registry'
const logger = getLogger('lifecycle') const logger = getLogger('lifecycle')
/** 종료 정리 전체 상한 — STT 사이드카 /shutdown 요청(최대 3초) + SIGKILL 여유 */
export const SHUTDOWN_TIMEOUT_MS = 4000
let isQuitting = false let isQuitting = false
let lifecycleInstalled = false
let shutdownComplete = false
export function getIsQuitting(): boolean { export function getIsQuitting(): boolean {
return isQuitting return isQuitting
@ -18,14 +28,30 @@ export function setIsQuitting(value: boolean): void {
} }
export function setupLifecycle(): void { export function setupLifecycle(): void {
if (lifecycleInstalled) return
lifecycleInstalled = true
app.on('before-quit', () => { app.on('before-quit', () => {
logger.info('[lifecycle] before-quit') logger.info('[lifecycle] before-quit')
isQuitting = true isQuitting = true
}) })
app.on('will-quit', () => { app.on('will-quit', (event) => {
logger.info('[lifecycle] will-quit — flushing config') if (shutdownComplete) return
disposeLocalLLMService() // 첫 will-quit 에서만 종료를 미루고 등록된 자원을 정리한 뒤 다시 종료한다
event.preventDefault()
logger.info('[lifecycle] will-quit — disposing registered resources')
void getShutdownRegistry()
.disposeAll(SHUTDOWN_TIMEOUT_MS)
.then((report) => {
logger.info(
`[lifecycle] shutdown: completed=${report.completed.length}, failed=${report.failed.join(',') || '-'}, timedOut=${report.timedOut.join(',') || '-'}`,
)
})
.catch((error: unknown) => {
logger.warn('[lifecycle] shutdown registry failed:', error)
})
.finally(() => {
try { try {
const configService = getConfigService() const configService = getConfigService()
if (configService) { if (configService) {
@ -35,5 +61,8 @@ export function setupLifecycle(): void {
} catch (error) { } catch (error) {
logger.warn('[lifecycle] config flush failed:', error) logger.warn('[lifecycle] config flush failed:', error)
} }
shutdownComplete = true
app.quit()
})
}) })
} }

View file

@ -66,6 +66,15 @@ class AudioCaptureService extends EventEmitter {
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */ /** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
private _levelAccumulator: Buffer[] = [] private _levelAccumulator: Buffer[] = []
constructor() {
super()
// EventEmitter 는 'error' 리스너가 없으면 emit('error') 에서 ERR_UNHANDLED_ERROR 를 던진다.
// 예전엔 SoX 가 장치를 못 열고 끝나면 'close' 콜백 안에서 이 예외가 나 뒤이은 'stopped' 가
// 영영 나가지 않았고, 받아쓰기는 녹음 팁만 띄운 채 조용히 아무것도 하지 않았다.
// 기본 sink 로 막고, 소비자는 'stopped'(reason) 로 캡처 종료를 안다. 로깅은 호출 지점에서 한다.
this.on('error', () => { /* default sink */ })
}
get state(): CaptureState { get state(): CaptureState {
return this._state return this._state
} }
@ -560,3 +569,10 @@ export function getAudioCaptureService(): AudioCaptureService {
} }
return instance return instance
} }
export function resetAudioCaptureServiceForTests(): void {
if (instance) instance.removeAllListeners()
instance = null
}
export { AudioCaptureService }

View file

@ -53,11 +53,44 @@ const DEFAULT_CONFIG: CaptionConfig = {
audioSource: 'mic', audioSource: 'mic',
} }
/**
* 자막 세션의 소유자. 회의 모드가 시작한 세션은 사용자 핫키/IPC 가 끌 수 없다
* (예전엔 핫키가 회의의 마이크 캡처를 조용히 끊고 회의 UI 는 계속 '녹음 중'이었다).
*/
export type CaptionOwner = 'user' | 'meeting'
/** 자막 세션 시작 옵션 — 생략하면 기존(사용자 자막) 동작과 같다 */
export interface CaptionStartOptions {
/** 기본 'user' */
owner?: CaptionOwner
/** 이번 세션에만 쓸 오디오 소스. 생략 시 설정값(captionAudioSource). 전역 설정은 건드리지 않는다. */
audioSource?: CaptionConfig['audioSource']
/** 자막 오버레이 표시 여부 (기본 true) */
showOverlay?: boolean
/** 세션을 'caption' 히스토리로 저장하고 session-saved 를 알릴지 (기본 true) */
persistHistory?: boolean
}
/** 종료된 세션 요약 + 저장된 히스토리 행 id (저장하지 않았으면 null) */
export type CaptionSessionResult = CaptionSessionSummary & { historyId: string | null }
interface ResolvedStartOptions {
owner: CaptionOwner
showOverlay: boolean
persistHistory: boolean
}
const DEFAULT_START_OPTIONS: ResolvedStartOptions = {
owner: 'user',
showOverlay: true,
persistHistory: true,
}
interface CaptionServiceEvents { interface CaptionServiceEvents {
'state-changed': (state: CaptionState) => void 'state-changed': (state: CaptionState) => void
'segment': (segment: CaptionSegment) => void 'segment': (segment: CaptionSegment) => void
'delta': (data: { text: string; isFinal: boolean }) => void 'delta': (data: { text: string; isFinal: boolean }) => void
'session-saved': (summary: CaptionSessionSummary) => void 'session-saved': (summary: CaptionSessionResult) => void
'error': (error: D3ROError) => void 'error': (error: D3ROError) => void
} }
@ -81,6 +114,8 @@ class CaptionService extends EventEmitter {
private _refining = false private _refining = false
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
// ── 공개 접근자 ── // ── 공개 접근자 ──
@ -88,6 +123,11 @@ class CaptionService extends EventEmitter {
return this._state return this._state
} }
/** 현재 세션 소유자 (세션이 없으면 null) */
getOwner(): CaptionOwner | null {
return this._state === 'inactive' ? null : this._session.owner
}
getConfig(): CaptionConfig { getConfig(): CaptionConfig {
return { ...this._config } return { ...this._config }
} }
@ -102,7 +142,7 @@ class CaptionService extends EventEmitter {
// ── 시작 ── // ── 시작 ──
async start(): Promise<void> { async start(options: CaptionStartOptions = {}): Promise<void> {
if (this._disposed) { if (this._disposed) {
throw new D3ROError( throw new D3ROError(
ErrorCode.CaptionStartFailed, ErrorCode.CaptionStartFailed,
@ -117,12 +157,19 @@ class CaptionService extends EventEmitter {
) )
} }
this._session = {
owner: options.owner ?? DEFAULT_START_OPTIONS.owner,
showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay,
persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory,
}
this._setState('starting') this._setState('starting')
// 오버레이를 즉시 표시 (로딩 상태) // 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다
if (this._session.showOverlay) {
showCaptionOverlay() showCaptionOverlay()
sendToCaptionOverlay('caption:config', this._config) sendToCaptionOverlay('caption:config', this._config)
sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' }) sendToCaptionOverlay(IPC_CHANNELS.CAPTION.STATE_CHANGED, { state: 'starting' })
}
try { try {
// STT 초기화 (모델 로딩 — 시간 소요) // STT 초기화 (모델 로딩 — 시간 소요)
@ -142,13 +189,19 @@ class CaptionService extends EventEmitter {
const lang = configGet('sttLanguage') as string | undefined const lang = configGet('sttLanguage') as string | undefined
const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : '' const initialContext = lang === 'ko' ? '다음은 한국어 대화입니다.' : ''
// ConfigService에서 저장된 오디오 소스 읽기 // 오디오 소스: 호출자 지정값(이번 세션 한정) → 저장된 설정 순.
// 호출자 지정값은 this._config 에 쓰지 않는다 — 다음 사용자 세션으로 새지 않게.
let audioSource: CaptionConfig['audioSource']
if (options.audioSource) {
audioSource = options.audioSource
} else {
const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string const savedSource = configGet('captionAudioSource' as keyof import('@d3ro/core/types').AppConfig) as unknown as string
if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) { if (savedSource && (savedSource === 'mic' || savedSource === 'system' || savedSource === 'both')) {
this._config.audioSource = savedSource as 'mic' | 'system' | 'both' this._config.audioSource = savedSource as 'mic' | 'system' | 'both'
} }
const { audioSource } = this._config audioSource = this._config.audioSource
logger.info(`Caption audioSource: ${audioSource}`) }
logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`)
// 마이크 캡처 (mic 또는 both) // 마이크 캡처 (mic 또는 both)
if (audioSource === 'mic' || audioSource === 'both') { if (audioSource === 'mic' || audioSource === 'both') {
@ -190,6 +243,7 @@ class CaptionService extends EventEmitter {
await audioCaptureService.stop().catch(() => {}) await audioCaptureService.stop().catch(() => {})
} }
hideCaptionOverlay() hideCaptionOverlay()
this._session = { ...DEFAULT_START_OPTIONS }
this._setState('inactive') this._setState('inactive')
const d3roErr = const d3roErr =
err instanceof D3ROError err instanceof D3ROError
@ -205,10 +259,21 @@ class CaptionService extends EventEmitter {
// ── 중지 ── // ── 중지 ──
async stop(): Promise<void> { /**
* 자막 세션을 멈추고 요약을 돌려준다(말하던 마지막 문장 flush 포함).
*
* @param owner 생략하면 강제 정지(앱 종료·로그아웃·회의 강제 초기화).
* 지정하면 그 소유자의 세션일 때만 멈추고, 아니면 false 를 돌려준다.
* @returns 세션 요약(세그먼트가 없으면 null), 다른 소유자라 거부했으면 false
*/
async stop(owner?: CaptionOwner): Promise<CaptionSessionResult | null | false> {
if (this._state !== 'active' && this._state !== 'starting') { if (this._state !== 'active' && this._state !== 'starting') {
logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`) logger.warn(`캡션 중지 불가: 현재 상태=${this._state}`)
return return null
}
if (owner && owner !== this._session.owner) {
logger.warn(`캡션 중지 거부: 세션 소유자=${this._session.owner}, 요청=${owner}`)
return false
} }
this._setState('stopping') this._setState('stopping')
@ -238,22 +303,25 @@ class CaptionService extends EventEmitter {
// 오버레이 숨김 // 오버레이 숨김
hideCaptionOverlay() hideCaptionOverlay()
// 세션을 히스토리에 저장 // 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다)
const summary = this._saveSession() const persist = this._session.persistHistory
const summary = this._saveSession(persist)
this._sessionId = null this._sessionId = null
this._sessionStartedAt = null this._sessionStartedAt = null
this._refineQueue = [] this._refineQueue = []
this._sessionLanguage = null this._sessionLanguage = null
this._session = { ...DEFAULT_START_OPTIONS }
this._setState('inactive') this._setState('inactive')
getSoundEffectService().play('recording-stop') getSoundEffectService().play('recording-stop')
logger.info('Live Caption 중지') logger.info('Live Caption 중지')
if (summary) { if (summary && persist) {
this.emit('session-saved', summary) this.emit('session-saved', summary)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary) this._sendToMainWindow(IPC_CHANNELS.CAPTION.SESSION_SAVED, summary)
} }
return summary
} }
// ── dispose ── // ── dispose ──
@ -411,7 +479,7 @@ class CaptionService extends EventEmitter {
// ── 내부: 세션 저장 ── // ── 내부: 세션 저장 ──
private _saveSession(): CaptionSessionSummary | null { private _saveSession(persist: boolean): CaptionSessionResult | null {
if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) { if (!this._sessionId || !this._sessionStartedAt || this._segments.length === 0) {
return null return null
} }
@ -421,10 +489,27 @@ class CaptionService extends EventEmitter {
const fullText = this._segments.map((s) => s.text).join(' ') const fullText = this._segments.map((s) => s.text).join(' ')
const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length
// 히스토리에 저장 (mode: 'caption') // 히스토리에 저장 (mode: 'caption'). 만들어진 행 id 를 요약에 싣는다 —
// 자동 요약은 이 id 로 히스토리를 찾는다(예전엔 자막 세션 id 로 찾아 항상 실패했다).
const historyId = persist ? this._persistHistory(fullText, totalDurationMs, wordCount) : null
const summary: CaptionSessionResult = {
sessionId: this._sessionId,
historyId,
segments: [...this._segments],
startedAt: this._sessionStartedAt,
endedAt,
totalDurationMs,
}
this._segments = []
return summary
}
/** 'caption' 히스토리 행을 만들고 id 를 돌려준다. 실패하면 로그만 남기고 null. */
private _persistHistory(fullText: string, totalDurationMs: number, wordCount: number): string | null {
try { try {
const historyService = getHistoryService() const entry = getHistoryService().create({
historyService.create({
originalText: fullText, originalText: fullText,
polishedText: null, polishedText: null,
focusedApp: null, focusedApp: null,
@ -447,22 +532,13 @@ class CaptionService extends EventEmitter {
logger.info( logger.info(
`캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`, `캡션 세션 저장: ${this._segments.length}개 세그먼트, ${wordCount}단어, ${Math.round(totalDurationMs / 1000)}초`,
) )
return entry.id
} catch (err) { } catch (err) {
logger.error( logger.error(
`캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`, `캡션 세션 저장 실패: ${err instanceof Error ? err.message : String(err)}`,
) )
return null
} }
const summary: CaptionSessionSummary = {
sessionId: this._sessionId,
segments: [...this._segments],
startedAt: this._sessionStartedAt,
endedAt,
totalDurationMs,
}
this._segments = []
return summary
} }
// ── 내부: 상태 전이 ── // ── 내부: 상태 전이 ──

View file

@ -214,11 +214,14 @@ class LocalSTTService extends EventEmitter {
private _disposed: boolean = false private _disposed: boolean = false
private _gpuAccelerated: boolean = false private _gpuAccelerated: boolean = false
// ── 이중 조건 플러시 (Speakly 패턴) ── // ── 준비 상태 ──
// 예전엔 모델이 준비되지 않았을 때 transcribe()를 단일 슬롯(_pendingResolve)에 걸어 두고
// initialize()가 풀어 주기를 기다렸다. 아무도 initialize()를 부르지 않으면(클라우드 STT 실패 →
// 로컬 폴백) 영원히 끝나지 않았고, 두 번째 호출은 첫 호출을 덮어썼으며, 옵션도 버려졌다.
// 이제 transcribe()는 진행 중인 초기화를 기다리거나 직접 초기화한 뒤 전사한다.
private _modelReady: boolean = false private _modelReady: boolean = false
private _audioBuffer: Buffer[] = [] /** 진행 중인 초기화(크래시 재시작 포함) — transcribe()가 이것을 기다린다 */
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null private _initializing: { modelId: string; promise: Promise<void> } | null = null
private _pendingReject: ((error: D3ROError) => void) | null = null
// errorEmitted 플래그로 이벤트 중복 방지 // errorEmitted 플래그로 이벤트 중복 방지
private _errorEmitted: boolean = false private _errorEmitted: boolean = false
@ -253,15 +256,36 @@ class LocalSTTService extends EventEmitter {
) )
} }
// 이미 같은 모델이 로딩된 상태면 무시 // 이미 같은 모델이 로딩된 상태면 무시 (전사 중이어도 모델은 올라가 있다)
if ( if (
this._state === STTState.Ready && this._modelReady &&
(this._state === STTState.Ready || this._state === STTState.Transcribing) &&
this._currentModelId === targetModel this._currentModelId === targetModel
) { ) {
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`) logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
return return
} }
// 같은 모델을 올리는 중이면 그 작업에 합류한다 (워밍업 + 첫 받아쓰기 동시 호출 등)
if (this._initializing && this._initializing.modelId === targetModel) {
return this._initializing.promise
}
return this._trackInitialization(targetModel, this._loadInitial(targetModel))
}
/** 진행 중인 초기화를 기록한다 — 끝나면(성공/실패) 스스로 지운다. */
private _trackInitialization(modelId: string, promise: Promise<void>): Promise<void> {
const entry = { modelId, promise }
this._initializing = entry
const clear = (): void => {
if (this._initializing === entry) this._initializing = null
}
promise.then(clear, clear)
return promise
}
private async _loadInitial(targetModel: string): Promise<void> {
this._setState(STTState.Loading) this._setState(STTState.Loading)
this._errorEmitted = false this._errorEmitted = false
@ -275,9 +299,6 @@ class LocalSTTService extends EventEmitter {
this._modelReady = true this._modelReady = true
this._setState(STTState.Ready) this._setState(STTState.Ready)
// 이중 조건 플러시 시도
this._tryFlushAll()
logger.info(`STT 초기화 완료: 모델=${targetModel}`) logger.info(`STT 초기화 완료: 모델=${targetModel}`)
} catch (err) { } catch (err) {
this._setState(STTState.Error) this._setState(STTState.Error)
@ -313,23 +334,37 @@ class LocalSTTService extends EventEmitter {
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다') throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
} }
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기 // 모델이 아직 준비되지 않았으면 진행 중인 초기화를 기다리거나 직접 초기화한다.
// 초기화가 실패하면 그 에러로 reject 된다 — 절대 끝나지 않는 promise를 돌려주지 않는다.
if (!this._modelReady) { if (!this._modelReady) {
logger.debug('모델 로딩 중, 오디오 버퍼에 적재') await this._ensureModelReady()
this._audioBuffer.push(audioBuffer)
return new Promise<TranscriptionResult>((resolve, reject) => {
this._pendingResolve = resolve
this._pendingReject = reject
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
this._tryFlushAll()
})
} }
// 모델 준비 완료 상태: 직접 전사 // 호출자 옵션(language/initialPrompt/modelId)을 그대로 전달한다
return this._sendToSidecar(audioBuffer, options) return this._sendToSidecar(audioBuffer, options)
} }
/**
* 모델이 준비될 때까지 기다린다.
* - 초기화(또는 크래시 재시작)가 진행 중이면 그것을 기다린다.
* - 아무도 초기화하지 않았다면 설정된 모델로 직접 초기화한다
* (클라우드 STT 실패 → 로컬 폴백, 파일 전사 등 초기화 없이 들어오는 호출).
*/
private async _ensureModelReady(): Promise<void> {
if (this._initializing) {
await this._initializing.promise
}
if (!this._modelReady) {
await this.initialize()
}
if (!this._modelReady) {
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
'STT 모델이 준비되지 않았습니다',
)
}
}
/** /**
* 앱 시작 시 sidecar와 모델을 미리 데운다. * 앱 시작 시 sidecar와 모델을 미리 데운다.
* 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로 * 첫 받아쓰기에서 모델 로딩(수초)을 기다리지 않게 하는 것이 목적이므로
@ -542,16 +577,7 @@ class LocalSTTService extends EventEmitter {
logger.info('LocalSTTService dispose 시작') logger.info('LocalSTTService dispose 시작')
// pending promise를 reject this._initializing = null
if (this._pendingReject) {
this._pendingReject(
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
)
this._pendingResolve = null
this._pendingReject = null
}
this._audioBuffer = []
this._modelReady = false this._modelReady = false
this._auxModelId = null this._auxModelId = null
@ -561,41 +587,6 @@ class LocalSTTService extends EventEmitter {
logger.info('LocalSTTService dispose 완료') logger.info('LocalSTTService dispose 완료')
} }
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
/**
* 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
* 설정 메시지(모델) 먼저, 오디오 데이터 후.
*/
private _tryFlushAll(): void {
if (!this._modelReady) return
if (this._audioBuffer.length === 0) return
if (!this._pendingResolve) return
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
const resolve = this._pendingResolve
const reject = this._pendingReject
this._pendingResolve = null
this._pendingReject = null
this._sendToSidecar(merged)
.then(resolve)
.catch((err: unknown) => {
if (reject) {
reject(
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
})
}
// ── Sidecar 관리 ── // ── Sidecar 관리 ──
/** /**
@ -803,7 +794,7 @@ class LocalSTTService extends EventEmitter {
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ /** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
private _spawnFailureError( private _spawnFailureError(
err: Error, err: Error,
launch: { command: string; source: 'bundled' | 'venv' | 'python' }, launch: { command: string; source: 'bundled' | 'provisioned' | 'venv' | 'python' },
): D3ROError { ): D3ROError {
const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT' const enoent = (err as NodeJS.ErrnoException).code === 'ENOENT'
if (!enoent) { if (!enoent) {
@ -814,7 +805,7 @@ class LocalSTTService extends EventEmitter {
} }
const hint = const hint =
launch.source === 'bundled' launch.source === 'bundled' || launch.source === 'provisioned'
? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.' ? '번들된 사이드카 실행 파일이 손상되었거나 백신이 차단했습니다. 앱을 다시 설치하세요.'
: launch.source === 'venv' : launch.source === 'venv'
? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.' ? '사이드카 가상환경이 손상되었습니다. `npm --prefix apps/desktop run sidecar:setup`을 실행하세요.'
@ -1058,31 +1049,30 @@ class LocalSTTService extends EventEmitter {
) )
this._setState(STTState.Error) this._setState(STTState.Error)
this._emitError(err) this._emitError(err)
// pending promise reject
if (this._pendingReject) {
this._pendingReject(err)
this._pendingResolve = null
this._pendingReject = null
}
return return
} }
// 비동기 재시작 // 비동기 재시작
const modelToReload = this._currentModelId const modelToReload = this._currentModelId ?? configGet('sttModelId')
this._currentModelId = null this._currentModelId = null
this._modelReady = false this._modelReady = false
this._auxModelId = null this._auxModelId = null
// setTimeout으로 이벤트 루프에 양보 // 재시작을 "진행 중인 초기화"로 즉시 기록한다 — 대기 구간에 들어온 transcribe()가
setTimeout(() => { // 초기화를 따로 경쟁시키지 않고 재시작 결과를 기다린다(실패하면 그 에러로 reject).
if (this._disposed) return const delayMs = 1000 * this._restartCount // 점진적 백오프
this.initialize(modelToReload ?? undefined).catch((err: unknown) => { const restart = new Promise<void>((resolve) => setTimeout(resolve, delayMs)).then(async () => {
if (this._disposed) {
throw new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 재시작 취소')
}
await this._loadInitial(modelToReload)
})
restart.catch((restartErr: unknown) => {
logger.error( logger.error(
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`, `Sidecar 재시작 실패: ${restartErr instanceof Error ? restartErr.message : String(restartErr)}`,
) )
}) })
}, 1000 * this._restartCount) // 점진적 백오프 this._trackInitialization(modelToReload, restart)
} }
private async _shutdownSidecar(): Promise<void> { private async _shutdownSidecar(): Promise<void> {
@ -1172,4 +1162,13 @@ export function resetLocalSTTServiceForTests(): void {
instance = null instance = null
} }
/**
* 앱 종료용: 이미 만들어진 인스턴스만 정리한다(사이드카 /shutdown → kill).
* 한 번도 쓰지 않았으면 종료 때문에 인스턴스를 새로 만들지 않는다.
*/
export async function disposeLocalSTTServiceIfStarted(): Promise<void> {
if (!instance) return
await instance.dispose()
}
export { LocalSTTService, STTState } export { LocalSTTService, STTState }

View file

@ -5,9 +5,10 @@
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { BrowserWindow, Notification, dialog } from 'electron' import { BrowserWindow, Notification, dialog } from 'electron'
import fs from 'fs' import fs from 'fs'
import { dirname as pathDirname } from 'path'
import { eq, desc, sql } from 'drizzle-orm' import { eq, desc, sql } from 'drizzle-orm'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService' import { configGet } from './ConfigService'
import { getCloudSyncService } from './CloudSyncService' import { getCloudSyncService } from './CloudSyncService'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import { getDatabase } from '../db' import { getDatabase } from '../db'
@ -16,11 +17,19 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { import {
buildExportMarkdown, buildExportMarkdown,
markdownToSimpleHtml, renderMeetingPdfHtml,
formatTime, formatTime,
formatDateFile, formatDateFile,
formatDateRange, formatDateRange,
} from '@d3ro/core/utils/meeting-markdown' } from '@d3ro/core/utils/meeting-markdown'
import {
applySpeakerLabels,
buildTranscriptFromSegments,
chunkTranscriptByLines,
formatTranscriptLines,
looksTruncatedRewrite,
parseTranscriptLines,
} from '@d3ro/core/meeting-transcript'
import { markdownToDocx } from '@d3ro/core/utils/markdown-to-docx' import { markdownToDocx } from '@d3ro/core/utils/markdown-to-docx'
import type { import type {
MeetingModeState, MeetingModeState,
@ -42,6 +51,31 @@ import type {
const logger = getLogger('MeetingModeService') const logger = getLogger('MeetingModeService')
/** 회의 전사를 LLM 으로 다시 쓸 때 한 번에 보낼 최대 글자 수 (출력 토큰 한도 안에 들도록) */
const REWRITE_CHUNK_CHARS = 1500
/** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */
const REWRITE_CHUNK_MAX_TOKENS = 2048
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
const MAX_CHAT_HISTORY_MESSAGES = 20
type MeetingLlm = {
generate: (
text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
) => Promise<{ text: string; model?: string }>
chatStream: (
messages: Array<{ role: string; content: string }>,
options?: { temperature?: number },
) => AsyncGenerator<string, string>
}
/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */
async function meetingAudioFilePath(sessionId: string): Promise<string> {
const { app } = await import('electron')
const path = await import('path')
return path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
}
let isShowingMeetingSaveDialog = false let isShowingMeetingSaveDialog = false
class MeetingModeService extends EventEmitter { class MeetingModeService extends EventEmitter {
@ -60,8 +94,8 @@ class MeetingModeService extends EventEmitter {
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */ /** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
private _audioBuffersForFile: Buffer[] = [] private _audioBuffersForFile: Buffer[] = []
// Phase 15: Meeting AI Chat // Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게)
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [] private _chatHistories = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
private _chatAbortController: AbortController | null = null private _chatAbortController: AbortController | null = null
// ── 공개 접근자 ── // ── 공개 접근자 ──
@ -101,11 +135,12 @@ class MeetingModeService extends EventEmitter {
try { try {
const { getCaptionService } = await import('./CaptionService') const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService() const captionService = getCaptionService()
// 강제 정지(소유자 무시) 후 구독을 푼다
await captionService.stop().catch(() => null)
if (this._segmentHandler) { if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler) captionService.off('segment', this._segmentHandler)
this._segmentHandler = null this._segmentHandler = null
} }
await captionService.stop().catch(() => {})
} catch { /* noop */ } } catch { /* noop */ }
try { try {
@ -180,25 +215,16 @@ class MeetingModeService extends EventEmitter {
} }
captionService.on('segment', this._segmentHandler) captionService.on('segment', this._segmentHandler)
// 회의 모드는 마이크 캡처 강제 — ConfigService 값도 임시 변경
// (CaptionService.start()가 ConfigService에서 다시 읽기 때문)
const prevAudioSource = configGet('captionAudioSource')
configSet('captionAudioSource', 'mic')
try { try {
await captionService.start() // 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음
// 회의 모드 UI가 자체 자막 패널에 표시하므로 오버레이 숨김 // (회의 UI 가 자체 패널에 표시), 'caption' 히스토리 중복 저장 없음, 핫키로 끌 수 없음.
const { hideCaptionOverlay } = await import('../windows/WindowManager') await captionService.start({
hideCaptionOverlay() owner: 'meeting',
// ConfigService 원래 값 복원 audioSource: 'mic',
if (prevAudioSource) { showOverlay: false,
configSet('captionAudioSource', prevAudioSource) persistHistory: false,
} })
} catch (err) { } catch (err) {
// ConfigService 원래 값 복원
if (prevAudioSource) {
configSet('captionAudioSource', prevAudioSource)
}
// 시작 실패 시 복원 // 시작 실패 시 복원
captionService.off('segment', this._segmentHandler) captionService.off('segment', this._segmentHandler)
this._segmentHandler = null this._segmentHandler = null
@ -300,16 +326,26 @@ class MeetingModeService extends EventEmitter {
this._audioDataHandler = null this._audioDataHandler = null
} }
// CaptionService 종료 // CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다)
// 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다.
const { getCaptionService } = await import('./CaptionService') const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService() const captionService = getCaptionService()
const captionSummary = await captionService.stop('meeting')
if (this._segmentHandler) { if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler) captionService.off('segment', this._segmentHandler)
this._segmentHandler = null this._segmentHandler = null
} }
await captionService.stop() // 혹시 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다
if (captionSummary) {
const known = new Set(this._segments.map((seg) => seg.id))
for (const segment of captionSummary.segments) {
if (!known.has(segment.id)) this._segments.push(segment)
}
this._segments.sort((a, b) => a.timestamp - b.timestamp)
}
// 세션 종료 시간 기록 // 세션 종료 시간 기록
const db = getDatabase() const db = getDatabase()
@ -345,9 +381,9 @@ class MeetingModeService extends EventEmitter {
// Step 1: 전사 텍스트 합산 // Step 1: 전사 텍스트 합산
this._sendProgress(sessionId, 'merging', 40) this._sendProgress(sessionId, 'merging', 40)
const rawTranscript = this._segments // 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`) const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0
.join('\n') const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt)
// Step 2: DB 저장 (rawTranscript + completed) // Step 2: DB 저장 (rawTranscript + completed)
this._sendProgress(sessionId, 'saving', 80) this._sendProgress(sessionId, 'saving', 80)
@ -363,11 +399,8 @@ class MeetingModeService extends EventEmitter {
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용) // Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
if (this._audioBuffersForFile.length > 0) { if (this._audioBuffersForFile.length > 0) {
try { try {
const { app } = await import('electron') const wavPath = await meetingAudioFilePath(sessionId)
const path = await import('path') fs.mkdirSync(pathDirname(wavPath), { recursive: true })
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
const wavPath = path.join(audioDir, `${sessionId}.wav`)
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile)) this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
getCloudSyncService().pushOne('meeting_audio', sessionId) getCloudSyncService().pushOne('meeting_audio', sessionId)
logger.info(`회의 오디오 저장: ${wavPath}`) logger.info(`회의 오디오 저장: ${wavPath}`)
@ -485,9 +518,21 @@ class MeetingModeService extends EventEmitter {
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run() db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다. // 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
getCloudSyncService().pushDelete('meetings', sessionId) getCloudSyncService().pushDelete('meetings', sessionId)
this._chatHistories.delete(sessionId)
// 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort)
void this._deleteMeetingAudio(sessionId)
logger.info(`회의 세션 삭제: ${sessionId}`) logger.info(`회의 세션 삭제: ${sessionId}`)
} }
private async _deleteMeetingAudio(sessionId: string): Promise<void> {
try {
const audioPath = await meetingAudioFilePath(sessionId)
await fs.promises.rm(audioPath, { force: true })
} catch (err) {
logger.warn(`회의 오디오 삭제 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
updateTitle(sessionId: string, title: string): void { updateTitle(sessionId: string, title: string): void {
const db = getDatabase() const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
@ -615,16 +660,7 @@ class MeetingModeService extends EventEmitter {
* 회의 문서/폴리시/채팅/화자추정 LLM. * 회의 문서/폴리시/채팅/화자추정 LLM.
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백. * llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
*/ */
private async _resolveMeetingLlm(): Promise<{ private async _resolveMeetingLlm(): Promise<MeetingLlm> {
generate: (
text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
) => Promise<{ text: string; model?: string }>
chatStream: (
messages: Array<{ role: string; content: string }>,
options?: { temperature?: number },
) => AsyncGenerator<string, string>
}> {
const backend = configGet('llmBackend') const backend = configGet('llmBackend')
if (backend === 'online') { if (backend === 'online') {
try { try {
@ -871,24 +907,7 @@ class MeetingModeService extends EventEmitter {
break break
} }
case 'pdf': { case 'pdf': {
const html = `<!DOCTYPE html> const html = renderMeetingPdfHtml({ title: row.title, markdown: row.content })
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 40px; color: #222; }
h1 { font-size: 20px; border-bottom: 1px solid #ddd; padding-bottom: 8px; }
h2 { font-size: 16px; margin-top: 20px; }
p { line-height: 1.6; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${row.title}</h1>
${markdownToSimpleHtml(row.content)}
</body>
</html>`
const win = new BrowserWindow({ show: false, width: 800, height: 600 }) const win = new BrowserWindow({ show: false, width: 800, height: 600 })
try { try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
@ -1013,19 +1032,56 @@ class MeetingModeService extends EventEmitter {
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다') if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
const llm = await this._resolveMeetingLlm() const llm = await this._resolveMeetingLlm()
const result = await llm.generate(transcript, { const polished = await this._rewriteTranscriptInChunks(
systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', llm,
temperature: 0.3, transcript,
}) '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
ErrorCode.MeetingPolishFailed,
)
db.update(meetingSessions).set({ db.update(meetingSessions).set({
editedTranscript: result.text, editedTranscript: polished,
updatedAt: Date.now(), updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run() }).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId) getCloudSyncService().pushOne('meetings', sessionId)
logger.info(`Auto Polish 완료: sessionId=${sessionId}`) logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
return result.text return polished
}
/**
* 긴 전사를 줄 경계 조각으로 나눠 LLM 으로 다시 쓰고 이어 붙인다.
* 한 번에 통째로 보내면 출력 토큰 한도(2048/4096)에서 잘린 결과가 editedTranscript 를
* 덮어써 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라졌다. 조각 하나라도 잘린 것으로
* 보이면 저장하지 않고 실패시킨다.
*/
private async _rewriteTranscriptInChunks(
llm: MeetingLlm,
transcript: string,
systemPrompt: string,
failureCode: ErrorCode,
): Promise<string> {
const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS)
if (chunks.length === 0) {
throw new D3ROError(failureCode, '전사 텍스트가 없습니다')
}
const outputs: string[] = []
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]
const result = await llm.generate(chunk, {
systemPrompt,
temperature: 0.3,
maxTokens: REWRITE_CHUNK_MAX_TOKENS,
})
if (looksTruncatedRewrite(chunk, result.text)) {
throw new D3ROError(
failureCode,
`LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`,
)
}
outputs.push(result.text.trim())
}
return outputs.join('\n')
} }
// ── Phase 15: Meeting AI Chat ── // ── Phase 15: Meeting AI Chat ──
@ -1044,42 +1100,55 @@ class MeetingModeService extends EventEmitter {
## 회의 전사 ## 회의 전사
${transcript}` ${transcript}`
this._chatHistory.push({ role: 'user', content: userMessage }) // 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다
// (실패·취소 시 짝 없는 사용자 턴이 남지 않게).
const llm = await this._resolveMeetingLlm() const history = this._chatHistories.get(sessionId) ?? []
const messages = [ const messages = [
{ role: 'system' as const, content: systemPrompt }, { role: 'system' as const, content: systemPrompt },
...this._chatHistory, ...history,
{ role: 'user' as const, content: userMessage },
] ]
let fullResponse = '' // 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다
this._chatAbortController = new AbortController() this._chatAbortController?.abort()
const signal = this._chatAbortController.signal const controller = new AbortController()
this._chatAbortController = controller
const signal = controller.signal
let fullResponse = ''
try { try {
const llm = await this._resolveMeetingLlm()
const generator = llm.chatStream(messages, { temperature: 0.5 }) const generator = llm.chatStream(messages, { temperature: 0.5 })
for await (const token of generator) { for await (const token of generator) {
if (signal.aborted) break if (signal.aborted) break
fullResponse += token fullResponse += token
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token }) this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { sessionId, token })
} }
this._chatHistory.push({ role: 'assistant', content: fullResponse }) if (!signal.aborted) {
const updated = [
...history,
{ role: 'user' as const, content: userMessage },
{ role: 'assistant' as const, content: fullResponse },
]
this._chatHistories.set(sessionId, updated.slice(-MAX_CHAT_HISTORY_MESSAGES))
}
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, { this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, {
sessionId,
role: 'assistant', role: 'assistant',
content: fullResponse, content: fullResponse,
timestamp: Date.now(), timestamp: Date.now(),
}) })
} catch (err) { } catch (err) {
if ((err as Error).name !== 'AbortError') { if (!signal.aborted && (err as Error).name !== 'AbortError') {
this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, { this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, {
sessionId,
message: err instanceof Error ? err.message : String(err), message: err instanceof Error ? err.message : String(err),
}) })
} }
} finally { } finally {
this._chatAbortController = null if (this._chatAbortController === controller) this._chatAbortController = null
} }
} }
@ -1090,8 +1159,18 @@ ${transcript}`
} }
} }
clearMeetingChatHistory(): void { /** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */
this._chatHistory = [] clearMeetingChatHistory(sessionId?: string): void {
if (sessionId) {
this._chatHistories.delete(sessionId)
} else {
this._chatHistories.clear()
}
}
/** 테스트/진단용: 특정 회의의 채팅 히스토리 사본 */
getMeetingChatHistory(sessionId: string): Array<{ role: 'user' | 'assistant'; content: string }> {
return [...(this._chatHistories.get(sessionId) ?? [])]
} }
// ── Phase 15.5: 화자 구분 ── // ── Phase 15.5: 화자 구분 ──
@ -1105,9 +1184,7 @@ ${transcript}`
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 }) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
// 오디오 파일 존재 확인 // 오디오 파일 존재 확인
const { app } = await import('electron') const audioPath = await meetingAudioFilePath(sessionId)
const path = await import('path')
const audioPath = path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
const hasAudioFile = fs.existsSync(audioPath) const hasAudioFile = fs.existsSync(audioPath)
const hfToken = configGet('hfToken') as string const hfToken = configGet('hfToken') as string
@ -1147,31 +1224,19 @@ ${transcript}`
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 }) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 70 })
// 전사 세그먼트와 화자 세그먼트 매칭 // 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다.
// 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다.
const transcript = row.editedTranscript ?? row.rawTranscript ?? '' const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
const lines = transcript.split('\n').filter((l) => l.trim()) const labeledLines = applySpeakerLabels(
const diarSegments = diarResult.segments parseTranscriptLines(transcript),
diarResult.segments,
const labeledLines = lines.map((line) => { (raw) => raw.replace('SPEAKER_', '화자 '),
const timeMatch = /^\[(\d{2}):(\d{2})\]/.exec(line)
if (!timeMatch) return line
const timeSec = parseInt(timeMatch[1], 10) * 60 + parseInt(timeMatch[2], 10)
// 해당 시간에 가장 가까운 화자 세그먼트 찾기
const matchedSeg = diarSegments.find(
(s) => timeSec >= s.start && timeSec <= s.end,
) )
if (matchedSeg) {
const speakerLabel = matchedSeg.speaker.replace('SPEAKER_', '화자 ')
return line.replace(/^\[(\d{2}:\d{2})\]/, `[$1] [${speakerLabel}]`)
}
return line
})
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 }) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
db.update(meetingSessions).set({ db.update(meetingSessions).set({
editedTranscript: labeledLines.join('\n'), editedTranscript: formatTranscriptLines(labeledLines),
updatedAt: Date.now(), updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run() }).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId) getCloudSyncService().pushOne('meetings', sessionId)
@ -1205,22 +1270,23 @@ ${transcript}`
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.` ? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
: '화자 수는 문맥에서 추정하세요.' : '화자 수는 문맥에서 추정하세요.'
const result = await llm.generate(transcript, { const labeled = await this._rewriteTranscriptInChunks(
systemPrompt: `다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요. llm,
transcript,
`다음 회의 전사록을 분석하여 각 발언의 화자를 추정해주세요.
${speakerHint} ${speakerHint}
각 줄을 [시간] [화자] 내용 형식으로 변환하세요. 각 줄을 [시간] [화자] 내용 형식으로 변환하세요.
화자는 "화자 1", "화자 2" 등으로 표시하세요. 화자는 "화자 1", "화자 2" 등으로 표시하세요.
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요. 발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
원문의 타임스탬프와 내용은 변경하지 마세요.`, 원문의 타임스탬프와 내용은 변경하지 마세요.`,
temperature: 0.3, ErrorCode.DiarizationFailed,
maxTokens: 8192, )
})
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 }) this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
const db = getDatabase() const db = getDatabase()
db.update(meetingSessions).set({ db.update(meetingSessions).set({
editedTranscript: result.text, editedTranscript: labeled,
updatedAt: Date.now(), updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run() }).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId) getCloudSyncService().pushOne('meetings', sessionId)
@ -1228,33 +1294,12 @@ ${speakerHint}
private _buildPdfHtml(session: MeetingSessionDetail): string { private _buildPdfHtml(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const minutesHtml = session.minutesMarkdown return renderMeetingPdfHtml({
? markdownToSimpleHtml(session.minutesMarkdown) title: session.title ?? '무제 회의',
: '<p>회의록이 생성되지 않았습니다.</p>' meta: `일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분`,
markdown: session.minutesMarkdown,
return `<!DOCTYPE html> emptyMessage: '회의록이 생성되지 않았습니다.',
<html lang="ko"> })
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
h2 { font-size: 16px; color: #444; margin-top: 24px; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
th { background: #f5f5f5; font-weight: 600; }
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${session.title ?? '무제 회의'}</h1>
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
${minutesHtml}
</body>
</html>`
} }
} }

View file

@ -187,17 +187,24 @@ class MeetingSummaryService extends EventEmitter {
/** /**
* CaptionService 세션 종료 시 자동 요약 (옵션) * CaptionService 세션 종료 시 자동 요약 (옵션)
*/ */
async onCaptionSessionSaved(sessionSummary: { sessionId: string }): Promise<void> { async onCaptionSessionSaved(sessionSummary: { sessionId: string; historyId?: string | null }): Promise<void> {
// caption 모드 히스토리에서 해당 세션 찾기 // caption 모드 히스토리에서 해당 세션 찾기.
// 히스토리 행 id 는 HistoryService 가 따로 만든다 — 자막 세션 id 와 절대 같지 않으므로
// CaptionService 가 실어 준 historyId 로 찾는다(없으면 저장되지 않은 세션).
const historyId = sessionSummary.historyId
if (!historyId) {
logger.info(`Caption session was not saved to history, skipping summary: ${sessionSummary.sessionId}`)
return
}
const db = getDatabase() const db = getDatabase()
const rows = db const rows = db
.select() .select()
.from(history) .from(history)
.where(eq(history.id, sessionSummary.sessionId)) .where(eq(history.id, historyId))
.all() .all()
if (rows.length === 0) { if (rows.length === 0) {
logger.warn(`Caption session not found in history: ${sessionSummary.sessionId}`) logger.warn(`Caption session not found in history: ${historyId}`)
return return
} }

View file

@ -7,7 +7,8 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import { asc, eq } from 'drizzle-orm' import { asc, eq } from 'drizzle-orm'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService' import { getLlmGateway } from './llm/LlmGateway'
import { extractDocxText } from './rag/docx-text'
import { getOllamaServerUrl } from './LocalLLMService' import { getOllamaServerUrl } from './LocalLLMService'
import { getDatabase } from '../db' import { getDatabase } from '../db'
import { ragDocuments, ragChunks } from '../db/schema' import { ragDocuments, ragChunks } from '../db/schema'
@ -291,8 +292,8 @@ class RAGService extends EventEmitter {
Documents: Documents:
${context}` ${context}`
const llmService = getPremiumLLMService() // 답변 LLM 은 사용자가 고른 백엔드를 따른다(로컬 선택 시 문서 조각을 클라우드로 보내지 않는다)
const result = await llmService.generate(queryText, { systemPrompt }) const result = await getLlmGateway().generate(queryText, { systemPrompt })
return { return {
query: queryText, query: queryText,
@ -487,18 +488,11 @@ ${context}`
if (fileType === 'docx') { if (fileType === 'docx') {
try { try {
const buffer = await fsp.readFile(filePath) const buffer = await fsp.readFile(filePath)
// DOCX는 ZIP 내 XML — w:t 태그에서 텍스트 추출 // DOCX는 ZIP 안의 (deflate 압축된) word/document.xml — 압축을 풀어 문단 텍스트를 뽑는다.
const raw = buffer.toString('utf-8') // 텍스트를 찾지 못하면 바이너리 잡음을 색인하지 않고 실패시킨다.
const matches = raw.match(/<w:t[^>]*>([^<]*)<\/w:t>/g) return extractDocxText(buffer).slice(0, 500000)
if (matches) { } catch (err) {
const text = matches logger.warn(`DOCX parsing failed: ${err instanceof Error ? err.message : String(err)}`)
.map((m) => m.replace(/<[^>]+>/g, ''))
.join(' ')
return text.trim()
}
// XML 태그 제거 폴백
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500000)
} catch {
throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed') throw new D3ROError(ErrorCode.RAGUnsupportedFormat, 'DOCX parsing failed')
} }
} }

View file

@ -7,9 +7,17 @@ import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService' import { configGet, configSet } from './ConfigService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types' import type { ScreenContext, CaptureContextResult } from '@d3ro/core/types'
import { waitForModifiersReleased } from './modifier-state'
const logger = getLogger('screen-context') const logger = getLogger('screen-context')
/**
* 선택 텍스트 캡처 전에 사용자가 수정자 키(Ctrl/Alt/Shift/Win)를 뗄 때까지 기다리는 최대 시간.
* 손을 떼지 않으면 캡처를 건너뛴다 — 수정자를 쥔 채 Ctrl+C 를 보내면 대상 앱엔 Ctrl+Alt+C 등이
* 들어가 복사가 되지 않고(AltGr 배열에선 문자가 입력되어 선택 영역을 덮어쓴다) 클립보드만 비워진다.
*/
const MODIFIER_RELEASE_TIMEOUT_MS = 800
// ============================================================ // ============================================================
// nut-js 타입 (lazy import) // nut-js 타입 (lazy import)
// ============================================================ // ============================================================
@ -107,7 +115,7 @@ class ScreenContextService {
if (captureSelectedText) { if (captureSelectedText) {
selectedTextAttempted = true selectedTextAttempted = true
try { try {
selectedText = await this._captureSelectedText() selectedText = await this.captureSelectedText()
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}` `Failed to capture selected text: ${error instanceof Error ? error.message : String(error)}`
@ -130,6 +138,20 @@ class ScreenContextService {
return { context, selectedTextAttempted } return { context, selectedTextAttempted }
} }
/**
* 선택된 텍스트만 캡처한다 (클립보드 방식).
* 수정자 키가 눌려 있으면 뗄 때까지 잠깐 기다리고, 그래도 쥐고 있으면 캡처하지 않고 null 을
* 돌려준다(클립보드도 건드리지 않는다).
*/
async captureSelectedText(): Promise<string | null> {
const released = await waitForModifiersReleased(MODIFIER_RELEASE_TIMEOUT_MS)
if (!released) {
logger.info('Selected text capture skipped: modifier keys still held')
return null
}
return this._captureSelectedText()
}
/** /**
* ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다. * ScreenContext를 LLM 시스템 프롬프트 접두사로 변환한다.
* 빈 컨텍스트면 빈 문자열을 반환. * 빈 컨텍스트면 빈 문자열을 반환.
@ -371,7 +393,9 @@ end try
} }
/** /**
* 클립보드 복원 (TextInsertService와 동일 패턴). * 클립보드 복원 — 저장해 둔 모든 형식(text/html/rtf/image)을 한 번에 되돌린다.
* 예전엔 첫 번째로 있는 형식 하나만 썼기 때문에 서식 있는 복사본이나 텍스트+이미지가
* 캡처 한 번에 평문으로 바뀌었다.
*/ */
private _restoreClipboard(snapshot: ClipboardSnapshot): void { private _restoreClipboard(snapshot: ClipboardSnapshot): void {
if (!snapshot.hasContent) { if (!snapshot.hasContent) {
@ -379,15 +403,12 @@ end try
return return
} }
if (snapshot.text) { const data: Electron.Data = {}
clipboard.writeText(snapshot.text) if (snapshot.text) data.text = snapshot.text
} else if (snapshot.html) { if (snapshot.html) data.html = snapshot.html
clipboard.writeHTML(snapshot.html) if (snapshot.rtf) data.rtf = snapshot.rtf
} else if (snapshot.rtf) { if (snapshot.image) data.image = snapshot.image
clipboard.writeRTF(snapshot.rtf) clipboard.write(data)
} else if (snapshot.image) {
clipboard.writeImage(snapshot.image)
}
} }
private _sleep(ms: number): Promise<void> { private _sleep(ms: number): Promise<void> {

View file

@ -10,6 +10,10 @@
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자 // 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
// 5. staged rollout + 원격 킬 스위치 // 5. staged rollout + 원격 킬 스위치
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall) // 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
//
// 구조: 게이팅 순서는 update-policy.ts 의 순수 함수(evaluateUpdateOffer / shouldPromptForUpdate)가
// 정하고, 이 서비스는 포트(UpdaterPort / PolicySource / UpdatePrompter)를 통해 I/O 만 한다.
// 기본 어댑터는 electron-updater · fetch · electron dialog 이며, 테스트는 가짜 포트를 주입한다.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
@ -25,13 +29,12 @@ import {
} from '../update-feed' } from '../update-feed'
import { import {
DEFAULT_UPDATE_POLICY, DEFAULT_UPDATE_POLICY,
decideUpdate, evaluateUpdateOffer,
isChannelAcceptable,
isWithinRollout,
majorOf,
parseUpdatePolicy, parseUpdatePolicy,
shouldPromptForUpdate,
type UpdateDecision, type UpdateDecision,
type UpdatePolicy, type UpdatePolicy,
type UpdatePromptRecord,
} from '../update-policy' } from '../update-policy'
const logger = getLogger('UpdateService') const logger = getLogger('UpdateService')
@ -76,124 +79,162 @@ export interface UpdateServiceEvents {
'update-error': { message: string } 'update-error': { message: string }
} }
// ── 포트 ─────────────────────────────────────────────────────
export type ReleaseNotes = string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined
export interface UpdateInfoLike {
version: string
releaseNotes?: ReleaseNotes
}
/** 업데이터(electron-updater) 이벤트를 서비스로 넘기는 콜백 */
export interface UpdaterEventHandlers {
onChecking(): void
onAvailable(info: UpdateInfoLike): void
onNotAvailable(version: string): void
onProgress(progress: UpdateProgressPayload): void
onDownloaded(version: string): void
onError(message: string): void
}
export interface UpdaterPort {
checkForUpdates(): Promise<unknown>
downloadUpdate(): Promise<unknown>
quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void
setChannel(channel: UpdateChannel, allowPrerelease: boolean): void
setDifferentialDisabled(disabled: boolean): void
}
/** 원격 정책 공급원. 실패하면 마지막으로 성공한 정책(없으면 기본값)을 돌려준다(fail-open). */
export interface PolicySource {
load(): Promise<UpdatePolicy>
}
export type ConsentAnswer = 'download' | 'later' | 'skip'
export type RestartAnswer = 'restart' | 'later'
export interface UpdatePrompter {
askConsent(input: { version: string; releaseNotes: ReleaseNotes; mandatory: boolean }): Promise<ConsentAnswer>
askRestart(version: string): Promise<RestartAnswer>
}
export interface UpdateServiceDeps {
/** 지원 환경(패키지 빌드·플랫폼·feed)일 때 업데이터를 만든다. 아니면 null(자동 업데이트 비활성). */
createUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null
policySource: PolicySource
prompter: UpdatePrompter
currentVersion(): string
now(): number
}
// ── 서비스 ───────────────────────────────────────────────────
class UpdateService extends EventEmitter { class UpdateService extends EventEmitter {
private _initialized = false private _initialized = false
private _initialTimer: NodeJS.Timeout | null = null private _initialTimer: NodeJS.Timeout | null = null
private _intervalTimer: NodeJS.Timeout | null = null private _intervalTimer: NodeJS.Timeout | null = null
private _promptShown = false /** 마지막으로 사용자에게 물어본 제안 — 새 버전/필수 격상/간격 경과 시 다시 묻는다 */
private _autoUpdater: import('electron-updater').AppUpdater | null = null private _lastPrompt: UpdatePromptRecord | null = null
private _prompting = false
private _updater: UpdaterPort | null = null
private _downloading = false private _downloading = false
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
private _channel: UpdateChannel = 'latest' private _channel: UpdateChannel = 'latest'
private _forceFullDownload = false private _forceFullDownload = false
private readonly _deps: UpdateServiceDeps
constructor(deps: Partial<UpdateServiceDeps> = {}) {
super()
this._deps = {
createUpdater: deps.createUpdater ?? createElectronUpdater,
policySource: deps.policySource ?? createRemotePolicySource(),
prompter: deps.prompter ?? createDialogPrompter(),
currentVersion: deps.currentVersion ?? (() => app.getVersion()),
now: deps.now ?? (() => Date.now()),
}
}
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */ /** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
initialize(): void { initialize(): void {
if (this._initialized) return if (this._initialized) return
this._initialized = true this._initialized = true
if (!app.isPackaged) { this._updater = this._deps.createUpdater({
logger.info('dev 실행 — 자동 업데이트 비활성') onChecking: () => {
return
}
if (process.platform !== 'win32' && process.platform !== 'darwin') {
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
return
}
if (!UPDATE_FEED_URL) {
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
return
}
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
this._autoUpdater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
} catch (err) {
logger.warn(
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
)
return
}
const updater = this._autoUpdater
updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
updater.autoDownload = false
updater.autoInstallOnAppQuit = true
this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
updater.logger = {
info: (msg: unknown) => logger.info(String(msg)),
warn: (msg: unknown) => logger.warn(String(msg)),
error: (msg: unknown) => logger.error(String(msg)),
debug: (msg: unknown) => logger.debug(String(msg)),
}
updater.on('checking-for-update', () => {
logger.info('업데이트 확인 중...') logger.info('업데이트 확인 중...')
this.emit('checking-for-update', undefined as unknown as void) this.emit('checking-for-update', undefined as unknown as void)
}) },
onAvailable: (info) => {
updater.on('update-available', (info) => {
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`) logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
void this._handleUpdateAvailable(info) void this.handleUpdateAvailable(info)
}) },
onNotAvailable: (version) => {
updater.on('update-not-available', (info) => { logger.info(`최신 버전 사용 중: v${version}`)
logger.info(`최신 버전 사용 중: v${info.version}`) this.emit('update-not-available', { version })
this.emit('update-not-available', { version: info.version }) },
}) onProgress: (progress) => this.emit('download-progress', progress),
onDownloaded: (version) => {
updater.on('download-progress', (progress) => {
this.emit('download-progress', {
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total
})
})
updater.on('update-downloaded', (info) => {
this._downloading = false this._downloading = false
logger.info(`업데이트 다운로드 완료: v${info.version}`) logger.info(`업데이트 다운로드 완료: v${version}`)
this.emit('update-downloaded', { version: info.version }) this.emit('update-downloaded', { version })
void this._promptRestart(info.version) void this._promptRestart(version)
}) },
onError: (message) => {
updater.on('error', (err) => { // 수락한 다운로드가 실패했으면 다음 체크에서 다시 물을 수 있게 기록을 지운다
if (this._downloading) this._lastPrompt = null
this._downloading = false this._downloading = false
logger.warn(`업데이트 체크 또는 다운로드 실패: ${err.message}`) logger.warn(`업데이트 체크 또는 다운로드 실패: ${message}`)
this.emit('update-error', { message: err.message }) this.emit('update-error', { message })
},
}) })
if (!this._updater) return
// 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다. this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
void this._loadPolicy() this._initialTimer = setTimeout(() => void this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS) this._intervalTimer = setInterval(() => void this.checkForUpdates(), CHECK_INTERVAL_MS)
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`) logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
} }
/** 수동 또는 주기적 업데이트 확인 */ /**
checkForUpdates(): void { * 수동 또는 주기적 업데이트 확인.
if (!this._autoUpdater || this._downloading) return * 체크할 때마다 원격 정책을 다시 받는다 — 예전엔 시작 시 한 번만 받아, 오래 켜 둔 클라이언트는
* 킬 스위치·rollout·최소 지원 버전 변경을 재시작 전까지 전혀 반영하지 못했다.
*/
async checkForUpdates(): Promise<void> {
if (!this._updater || this._downloading) return
await this._refreshPolicy()
if (this._policy.killSwitch) { if (this._policy.killSwitch) {
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단') logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
return return
} }
this._autoUpdater.checkForUpdates().catch((err: unknown) => { try {
await this._updater.checkForUpdates()
} catch (err) {
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`) logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
}) }
} }
/** 사용자 수락 시 업데이트 다운로드 시작 */ /** 사용자 수락 시 업데이트 다운로드 시작 */
async startDownload(): Promise<void> { async startDownload(): Promise<void> {
if (!this._autoUpdater || this._downloading) return if (!this._updater || this._downloading) return
if (this._policy.killSwitch) {
logger.info('원격 킬 스위치 활성 — 다운로드 중단')
return
}
this._downloading = true this._downloading = true
logger.info( logger.info(
this._forceFullDownload this._forceFullDownload
? '전체 설치자 다운로드 시작 (major/버전갭)' ? '전체 설치자 다운로드 시작 (major/버전갭)'
: '차분 업데이트 다운로드 시작 (.blockmap)', : '차분 업데이트 다운로드 시작 (.blockmap)',
) )
await this._autoUpdater.downloadUpdate() try {
await this._updater.downloadUpdate()
} catch (err) {
if (this._downloading) this._lastPrompt = null
this._downloading = false
logger.warn(`업데이트 다운로드 실패: ${err instanceof Error ? err.message : String(err)}`)
}
} }
/** 이번 버전 건너뛰기 설정 */ /** 이번 버전 건너뛰기 설정 */
@ -224,8 +265,73 @@ class UpdateService extends EventEmitter {
this._intervalTimer = null this._intervalTimer = null
} }
/** 업데이터가 새 버전을 알렸을 때 — 게이팅은 evaluateUpdateOffer 가 정한다. */
async handleUpdateAvailable(info: UpdateInfoLike): Promise<void> {
const currentVersion = this._deps.currentVersion()
const offer = evaluateUpdateOffer({
policy: this._policy,
channel: this._channel,
currentVersion,
targetVersion: info.version,
skippedVersion: this._skippedVersion(),
deviceId: this._deviceId(),
})
if (offer.action === 'ignore') {
switch (offer.reason) {
case 'kill-switch':
logger.info(`원격 킬 스위치 활성 — v${info.version} 무시`)
break
case 'channel':
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
break
case 'skipped':
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
break
case 'rollout':
logger.info(
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
)
break
}
return
}
const { decision } = offer
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
this._forceFullDownload = decision.forceFull
this._updater?.setDifferentialDisabled(decision.forceFull)
this.emit('update-available', {
version: info.version,
currentVersion,
channel: this._channel,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
isMandatory: decision.mandatory,
isMajorUpgrade: offer.isMajorUpgrade,
isFullDownload: decision.forceFull,
reason: decision.reason,
})
if (this._downloading) return
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
if (offer.action === 'force-download') {
logger.info(`v${info.version} 강제 설치 (${decision.reason})`)
void this.startDownload()
return
}
await this._promptUserConsent(info.version, info.releaseNotes, decision)
}
// ── 내부 ── // ── 내부 ──
private async _refreshPolicy(): Promise<void> {
this._policy = await this._deps.policySource.load()
this._applyChannel(this._resolveChannel(this._policy))
}
private _resolveChannel(policy: UpdatePolicy): UpdateChannel { private _resolveChannel(policy: UpdatePolicy): UpdateChannel {
const configured = configGet('updateChannel') const configured = configGet('updateChannel')
return isUpdateChannel(configured) ? configured : policy.defaultChannel return isUpdateChannel(configured) ? configured : policy.defaultChannel
@ -233,38 +339,9 @@ class UpdateService extends EventEmitter {
private _applyChannel(channel: UpdateChannel): void { private _applyChannel(channel: UpdateChannel): void {
this._channel = channel this._channel = channel
if (!this._autoUpdater) return if (!this._updater) return
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false } const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
this._autoUpdater.channel = channel this._updater.setChannel(channel, channelPolicy.allowPrerelease)
this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease
}
private async _loadPolicy(): Promise<void> {
if (!UPDATE_POLICY_URL) return
try {
const response = await fetch(UPDATE_POLICY_URL, {
cache: 'no-store',
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
this._policy = parseUpdatePolicy(await response.json())
this._applyChannel(this._resolveChannel(this._policy))
logger.info(
`원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`,
)
} catch (err) {
// 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속).
logger.debug(
`원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
/** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */
private _setDifferentialDisabled(disabled: boolean): void {
if (!this._autoUpdater) return
;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload =
disabled
} }
private _deviceId(): string { private _deviceId(): string {
@ -282,142 +359,44 @@ class UpdateService extends EventEmitter {
return typeof legacy === 'string' && legacy ? legacy : null return typeof legacy === 'string' && legacy ? legacy : null
} }
private async _handleUpdateAvailable(
info: import('electron-updater').UpdateInfo,
): Promise<void> {
const currentVersion = app.getVersion()
const decision = decideUpdate(this._policy, currentVersion, info.version)
const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false }
if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) {
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
return
}
if (!decision.mandatory && this._skippedVersion() === info.version) {
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
return
}
if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) {
logger.info(
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
)
return
}
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
this._forceFullDownload = decision.forceFull
this._setDifferentialDisabled(decision.forceFull)
const currentMajor = majorOf(currentVersion)
const targetMajor = majorOf(info.version)
const isMajorUpgrade =
currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor
this.emit('update-available', {
version: info.version,
currentVersion,
channel: this._channel,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
isMandatory: decision.mandatory,
isMajorUpgrade,
isFullDownload: decision.forceFull,
reason: decision.reason,
})
await this._promptUserConsent(
info.version,
info.releaseNotes,
decision,
)
}
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */ /** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
private async _promptUserConsent( private async _promptUserConsent(
version: string, version: string,
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined, releaseNotes: ReleaseNotes,
decision: UpdateDecision, decision: UpdateDecision,
): Promise<void> { ): Promise<void> {
if (this._downloading) return if (this._prompting) return
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선). const now = this._deps.now()
if (decision.mandatory && decision.reason === 'below-force-install') { if (!shouldPromptForUpdate(this._lastPrompt, { version, mandatory: decision.mandatory }, now)) {
logger.info(`v${version} 강제 설치 (${decision.reason})`) logger.info(`v${version} 은 최근에 물어봤음 — 다음 간격까지 다시 묻지 않음`)
void this.startDownload()
return return
} }
if (this._promptShown) return this._lastPrompt = { version, mandatory: decision.mandatory, at: now }
this._promptShown = true this._prompting = true
try {
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true const answer = await this._deps.prompter.askConsent({
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : '' version,
const mandatoryNote = decision.mandatory releaseNotes,
? isKo mandatory: decision.mandatory,
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).' })
: '\n\nThis update is required (end of support).' if (answer === 'download') {
: ''
const options = decision.mandatory
? {
type: 'info' as const,
title: isKo ? '필수 업데이트' : 'Required Update',
message: isKo
? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}`
: `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`,
buttons: isKo ? ['지금 업데이트'] : ['Update Now'],
defaultId: 0,
cancelId: -1,
}
: {
type: 'info' as const,
title: isKo ? '새 버전 업데이트' : 'Software Update',
message: isKo
? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}`
: `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`,
buttons: isKo
? ['지금 다운로드', '나중에', '이 버전 건너뛰기']
: ['Download Now', 'Later', 'Skip This Version'],
defaultId: 0,
cancelId: 1,
}
const win = getMainWindow()
const { response } =
win && !win.isDestroyed()
? await dialog.showMessageBox(win, options)
: await dialog.showMessageBox(options)
if (response === 0) {
void this.startDownload() void this.startDownload()
} else if (!decision.mandatory && response === 2) { } else if (!decision.mandatory && answer === 'skip') {
this.skipVersion(version) this.skipVersion(version)
} }
} finally {
this._prompting = false
}
} }
/** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */ /** 2단계: 다운로드 완료 시 안전한 재시작 및 설치 다이얼로그 */
private async _promptRestart(version: string): Promise<void> { private async _promptRestart(version: string): Promise<void> {
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true const answer = await this._deps.prompter.askRestart(version)
const options = { if (answer === 'restart' && this._updater) {
type: 'info' as const,
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
message: isKo
? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?`
: `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`,
buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'],
defaultId: 0,
cancelId: 1,
}
const win = getMainWindow()
const { response } =
win && !win.isDestroyed()
? await dialog.showMessageBox(win, options)
: await dialog.showMessageBox(options)
if (response === 0 && this._autoUpdater) {
logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행') logger.info('사용자 재시작 수락 — 프로세스 리소스 해제 후 quitAndInstall 실행')
// 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행 // 프로세스 락(EBUSY) 방지를 위해 isSilent=false, isForceRunAfter=true로 실행.
this._autoUpdater.quitAndInstall(false, true) // quitAndInstall 은 app.quit() 경로를 타므로 종료 등록부(ShutdownRegistry)가 자원을 정리한다.
this._updater.quitAndInstall(false, true)
} }
} }
@ -438,6 +417,177 @@ class UpdateService extends EventEmitter {
} }
} }
// ── 기본 어댑터 (electron-updater / fetch / electron dialog) ─────
function createElectronUpdater(handlers: UpdaterEventHandlers): UpdaterPort | null {
if (!app.isPackaged) {
logger.info('dev 실행 — 자동 업데이트 비활성')
return null
}
if (process.platform !== 'win32' && process.platform !== 'darwin') {
logger.info(`플랫폼 ${process.platform} — 자동 업데이트 미지원`)
return null
}
if (!UPDATE_FEED_URL) {
logger.info('UPDATE_FEED_URL 미설정 — 자동 업데이트 비활성')
return null
}
let updater: import('electron-updater').AppUpdater
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
updater = (require('electron-updater') as typeof import('electron-updater')).autoUpdater
} catch (err) {
logger.warn(
`electron-updater 로드 실패 — 자동 업데이트 비활성: ${err instanceof Error ? err.message : String(err)}`,
)
return null
}
updater.setFeedURL({ provider: 'generic', url: UPDATE_FEED_URL })
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
updater.autoDownload = false
updater.autoInstallOnAppQuit = true
updater.logger = {
info: (msg: unknown) => logger.info(String(msg)),
warn: (msg: unknown) => logger.warn(String(msg)),
error: (msg: unknown) => logger.error(String(msg)),
debug: (msg: unknown) => logger.debug(String(msg)),
}
updater.on('checking-for-update', () => handlers.onChecking())
updater.on('update-available', (info) => handlers.onAvailable(info))
updater.on('update-not-available', (info) => handlers.onNotAvailable(info.version))
updater.on('download-progress', (progress) =>
handlers.onProgress({
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
}),
)
updater.on('update-downloaded', (info) => handlers.onDownloaded(info.version))
updater.on('error', (err) => handlers.onError(err.message))
return {
checkForUpdates: () => updater.checkForUpdates(),
downloadUpdate: () => updater.downloadUpdate(),
quitAndInstall: (isSilent, isForceRunAfter) => updater.quitAndInstall(isSilent, isForceRunAfter),
setChannel: (channel, allowPrerelease) => {
updater.channel = channel
updater.allowPrerelease = allowPrerelease
},
setDifferentialDisabled: (disabled) => {
// NSIS 이외 업데이터는 이 속성을 무시한다
;(updater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload = disabled
},
}
}
/** 원격 정책 공급원 — 실패하면 마지막으로 성공한 정책, 그것도 없으면 내장 기본값 */
export function createRemotePolicySource(
url: string | null | undefined = UPDATE_POLICY_URL,
fetchImpl: typeof fetch = (input, init) => fetch(input, init),
): PolicySource {
let lastGood: UpdatePolicy | null = null
return {
async load(): Promise<UpdatePolicy> {
if (!url) return lastGood ?? DEFAULT_UPDATE_POLICY
try {
const response = await fetchImpl(url, {
cache: 'no-store',
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`HTTP ${response.status}`)
lastGood = parseUpdatePolicy(await response.json())
logger.info(
`원격 업데이트 정책 적용 — min=${lastGood.minimumSupportedVersion}, killSwitch=${lastGood.killSwitch}, staging=${lastGood.stagingPercentage}%`,
)
return lastGood
} catch (err) {
// 네트워크 실패 시 마지막 정책(없으면 내장 기본값)으로 fail-open (업데이트 자체는 계속).
logger.debug(
`원격 업데이트 정책 로드 실패 — ${lastGood ? '마지막 정책 유지' : '내장 기본값 사용'}: ${err instanceof Error ? err.message : String(err)}`,
)
return lastGood ?? DEFAULT_UPDATE_POLICY
}
},
}
}
/**
* electron dialog 기반 프롬프터.
* TODO(i18n): 문구가 아직 ko/en 인라인이다 — update.* 키를 locale 파일에 추가한 뒤 t() 로 옮긴다.
*/
function createDialogPrompter(): UpdatePrompter {
const isKorean = (): boolean => (configGet('language') as string | undefined)?.startsWith('ko') ?? true
const show = async (options: Electron.MessageBoxOptions): Promise<number> => {
const win = getMainWindow()
const { response } =
win && !win.isDestroyed()
? await dialog.showMessageBox(win, options)
: await dialog.showMessageBox(options)
return response
}
return {
async askConsent({ version, releaseNotes, mandatory }): Promise<ConsentAnswer> {
const isKo = isKorean()
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
const mandatoryNote = mandatory
? isKo
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
: '\n\nThis update is required (end of support).'
: ''
if (mandatory) {
const response = await show({
type: 'info',
title: isKo ? '필수 업데이트' : 'Required Update',
message: isKo
? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}`
: `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`,
buttons: isKo ? ['지금 업데이트'] : ['Update Now'],
defaultId: 0,
cancelId: -1,
})
return response === 0 ? 'download' : 'later'
}
const response = await show({
type: 'info',
title: isKo ? '새 버전 업데이트' : 'Software Update',
message: isKo
? `D3RO Voice v${version} 새 버전이 출시되었습니다. 지금 다운로드할까요?${notesText}`
: `A new version of D3RO Voice (v${version}) is available. Would you like to download it now?${notesText}`,
buttons: isKo
? ['지금 다운로드', '나중에', '이 버전 건너뛰기']
: ['Download Now', 'Later', 'Skip This Version'],
defaultId: 0,
cancelId: 1,
})
if (response === 0) return 'download'
if (response === 2) return 'skip'
return 'later'
},
async askRestart(version): Promise<RestartAnswer> {
const isKo = isKorean()
const response = await show({
type: 'info',
title: isKo ? '업데이트 준비 완료' : 'Update Ready',
message: isKo
? `D3RO Voice v${version} 다운로드가 완료되었습니다. 지금 앱을 재시작하여 설치를 완료할까요?`
: `D3RO Voice v${version} has been downloaded. Restart now to complete installation?`,
buttons: isKo ? ['지금 재시작 및 설치', '종료 시 자동 설치'] : ['Restart & Install Now', 'Install on Exit'],
defaultId: 0,
cancelId: 1,
})
return response === 0 ? 'restart' : 'later'
},
}
}
// ── 싱글톤 ── // ── 싱글톤 ──
let instance: UpdateService | null = null let instance: UpdateService | null = null

View file

@ -5,12 +5,12 @@
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService' import { getPremiumLLMService } from './PremiumLLMService'
import { getLocalLLMService } from './LocalLLMService'
import { getLocalSTTService } from './LocalSTTService' import { getLocalSTTService } from './LocalSTTService'
import { getAudioCaptureService } from './AudioCaptureService' import { getAudioCaptureService } from './AudioCaptureService'
import { getTTSPlaybackService } from './TTSPlaybackService' import { getTTSPlaybackService } from './TTSPlaybackService'
import { getSoundEffectService } from './SoundEffectService' import { getSoundEffectService } from './SoundEffectService'
import { configGet } from './ConfigService' import { configGet } from './ConfigService'
import { getLlmGateway } from './llm/LlmGateway'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -386,45 +386,22 @@ class VoiceConversationService extends EventEmitter {
} }
/** /**
* LLM 백엔드 선택 + chatStream 생성. * LLM 백엔드 선택 + chatStream 생성 — 선택 정책(online+가용 → Premium, 아니면 Local)은
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback. * LlmGateway 가 소유한다. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다.
*/ */
private async _createChatStream(signal: AbortSignal): Promise<{ private async _createChatStream(signal: AbortSignal): Promise<{
generator: AsyncGenerator<string, string> generator: AsyncGenerator<string, string>
backend: 'local' | 'premium' backend: 'local' | 'premium'
}> { }> {
const chatMessages = this._buildChatMessages() const chatMessages = this._buildChatMessages()
const backend = configGet('llmBackend') return getLlmGateway().openChatStream(chatMessages, {
if (backend === 'online') {
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const premium = getPremiumLLMService()
if (premium.isAvailable()) {
return { generator: premium.chatStream(chatMessages), backend: 'premium' }
}
logger.warn('Premium LLM not available for conversation, falling back to local')
} catch (err) {
logger.warn('Premium LLM init failed for conversation:', err instanceof Error ? err.message : String(err))
}
}
const local = getLocalLLMService()
if (!local.isAvailable()) {
throw new D3ROError(
ErrorCode.LLMServerUnreachable,
'Local LLM (Ollama) is not available',
)
}
return {
generator: local.chatStream(chatMessages, {
signal, signal,
local: {
maxTokens: 512, maxTokens: 512,
timeoutMs: 60_000, timeoutMs: 60_000,
keepAlive: '2m', keepAlive: '2m',
}), },
backend: 'local', })
}
} }
private _isCurrentResponse(responseAbortController: AbortController): boolean { private _isCurrentResponse(responseAbortController: AbortController): boolean {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
// src/main/services/llm/LlmGateway.ts
// LLM 백엔드 선택 정책의 단일 정본(포트 + 라우팅 구현).
//
// 예전엔 VoiceModeService / VoiceConversationService / MeetingModeService 가 각자
// `llmBackend === 'online' ? (premium.isAvailable() ? premium : local) : local` 를 복제했고,
// RAGService·ChainService 는 이 판단을 건너뛰고 Premium 에 고정돼 있었다
// (로컬 모드 익명 사용자는 기능이 깨지고, 로컬을 고른 로그인 사용자는 텍스트가 클라우드로 갔다).
// 기능 서비스는 이 게이트웨이(추상)에만 의존하고, Premium/Local 서비스는 어댑터로 남는다.
import { getLogger } from '../LoggerService'
import { configGet } from '../ConfigService'
import { getLocalLLMService } from '../LocalLLMService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LLMAction } from '@d3ro/core/types'
const logger = getLogger('LlmGateway')
// ── 포트 타입 ──────────────────────────────────────────────
export interface LlmGenerateOptions {
systemPrompt?: string
temperature?: number
maxTokens?: number
}
export interface LlmGenerateResult {
text: string
model?: string
}
export interface LlmChatMessage {
role: string
content: string
}
/** 로컬(Ollama) 채팅 스트림 전용 옵션 — 프리미엄 경로에는 전달되지 않는다. */
export interface LocalChatStreamOptions {
maxTokens?: number
timeoutMs?: number
keepAlive?: string
temperature?: number
}
export type LlmBackendKind = 'local' | 'premium'
export interface LlmCallHooks {
/** Premium 을 쓰려 했으나 로컬로 넘어갔을 때 사유와 함께 호출된다. */
onFallback?: (reason: string) => void
}
export interface LlmChatStreamHandle {
generator: AsyncGenerator<string, string>
backend: LlmBackendKind
}
export interface LlmGateway {
/** 액션 기반 텍스트 처리 (받아쓰기 후처리, 체인 단계). Premium 실패 시 로컬로 폴백한다. */
processText(
text: string,
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
hooks?: LlmCallHooks,
): Promise<string>
/** 자유 생성 (RAG 답변 등). Premium 실패 시 로컬로 폴백한다. */
generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise<LlmGenerateResult>
/**
* 스트리밍 대화. 토큰이 흐르기 시작한 뒤에는 폴백하지 않는다(부분 응답 이중 출력 방지).
* 로컬이 가용하지 않으면 LLMServerUnreachable 을 던진다.
*/
openChatStream(
messages: LlmChatMessage[],
options: { signal: AbortSignal; local?: LocalChatStreamOptions },
hooks?: LlmCallHooks,
): Promise<LlmChatStreamHandle>
/** 두 어댑터의 진행 중 생성을 모두 취소한다. */
cancel(): void
}
// ── 어댑터 계약 (기존 Premium/Local 서비스가 구조적으로 만족) ─────
export interface PremiumLlmAdapter {
isAvailable(): boolean
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string }>
chatStream(messages: LlmChatMessage[], options?: { temperature?: number }): AsyncGenerator<string, string>
cancelGeneration(): void
}
export interface LocalLlmAdapter {
isAvailable(): boolean
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string; model?: string }>
chatStream(
messages: LlmChatMessage[],
options?: LocalChatStreamOptions & { signal?: AbortSignal },
): AsyncGenerator<string, string>
cancelGeneration(): void
}
export interface LlmGatewayDeps {
/** 사용자가 고른 백엔드 ('online' 만 Premium 을 의미한다) */
getBackend: () => string | null | undefined
/** Premium 어댑터 로드 — 지연 import(순환 의존·초기화 비용 회피) */
loadPremium: () => Promise<PremiumLlmAdapter>
getLocal: () => LocalLlmAdapter
/** 이미 로드된 Premium 이 있으면 돌려준다(취소용). 없으면 null. */
peekPremium?: () => PremiumLlmAdapter | null
}
function describe(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
// ── 라우팅 구현 ─────────────────────────────────────────────
class RoutedLlmGateway implements LlmGateway {
private _premium: PremiumLlmAdapter | null = null
constructor(private readonly _deps: LlmGatewayDeps) {}
private _fallback(hooks: LlmCallHooks | undefined, reason: string): void {
logger.warn(`Premium LLM fallback → local: ${reason}`)
hooks?.onFallback?.(reason)
}
/**
* llmBackend==='online' 이고 Premium 이 가용하면 Premium, 아니면 null(로컬 사용).
* 가용하지 않거나 로드에 실패하면 폴백 사유를 알린다.
*/
private async _selectPremium(hooks: LlmCallHooks | undefined): Promise<PremiumLlmAdapter | null> {
if (this._deps.getBackend() !== 'online') return null
try {
const premium = await this._deps.loadPremium()
this._premium = premium
if (premium.isAvailable()) return premium
this._fallback(hooks, 'Premium unavailable — falling back to local Ollama')
} catch (err) {
this._fallback(hooks, `Premium init failed: ${describe(err)}`)
}
return null
}
async processText(
text: string,
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
hooks?: LlmCallHooks,
): Promise<string> {
const premium = await this._selectPremium(hooks)
if (premium) {
try {
return await premium.processText(text, action, targetLanguage, customPrompt)
} catch (err) {
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
}
}
return this._deps.getLocal().processText(text, action, targetLanguage, customPrompt)
}
async generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise<LlmGenerateResult> {
const premium = await this._selectPremium(hooks)
if (premium) {
try {
return await premium.generate(text, options)
} catch (err) {
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
}
}
return this._deps.getLocal().generate(text, options)
}
async openChatStream(
messages: LlmChatMessage[],
options: { signal: AbortSignal; local?: LocalChatStreamOptions },
hooks?: LlmCallHooks,
): Promise<LlmChatStreamHandle> {
const premium = await this._selectPremium(hooks)
if (premium) {
return { generator: premium.chatStream(messages), backend: 'premium' }
}
const local = this._deps.getLocal()
if (!local.isAvailable()) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Local LLM (Ollama) is not available')
}
return {
generator: local.chatStream(messages, { ...options.local, signal: options.signal }),
backend: 'local',
}
}
cancel(): void {
try {
this._deps.getLocal().cancelGeneration()
} catch (err) {
logger.debug(`Local cancel failed: ${describe(err)}`)
}
const premium = this._premium ?? this._deps.peekPremium?.() ?? null
try {
premium?.cancelGeneration()
} catch (err) {
logger.debug(`Premium cancel failed: ${describe(err)}`)
}
}
}
export function createRoutedLlmGateway(deps: LlmGatewayDeps): LlmGateway {
return new RoutedLlmGateway(deps)
}
// ── 싱글톤 (테스트에서 교체 가능) ───────────────────────────
function defaultDeps(): LlmGatewayDeps {
return {
getBackend: () => configGet('llmBackend'),
loadPremium: async () => {
const { getPremiumLLMService } = await import('../PremiumLLMService')
return getPremiumLLMService()
},
getLocal: () => getLocalLLMService(),
}
}
let instance: LlmGateway | null = null
export function getLlmGateway(): LlmGateway {
if (!instance) instance = createRoutedLlmGateway(defaultDeps())
return instance
}
/** 테스트 전용: 게이트웨이를 교체하거나(null 이면) 기본 구현으로 되돌린다. */
export function setLlmGatewayForTests(gateway: LlmGateway | null): void {
instance = gateway
}

View file

@ -0,0 +1,116 @@
// src/main/services/rag/docx-text.ts
// .docx(ZIP) 에서 본문 텍스트를 꺼낸다 — 순수 JS + zlib, 외부 의존 없음.
//
// 예전엔 ZIP 바이트를 그대로 UTF-8 문자열로 보고 <w:t> 정규식을 돌렸다. Word 는 항상
// word/document.xml 을 deflate 압축해 저장하므로 아무것도 찾지 못했고, 폴백이 태그만 지운
// 바이너리 잡음을 문서 내용으로 색인·임베딩·클라우드 동기화했다.
import { inflateRawSync } from 'zlib'
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_SIGNATURE = 0x02014b50
const LOCAL_SIGNATURE = 0x04034b50
const METHOD_STORED = 0
const METHOD_DEFLATE = 8
/** 압축 해제 상한 — 비정상 파일(zip bomb)로 메모리를 다 쓰지 않게 */
const MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024
export class DocxExtractionError extends Error {}
function findEndOfCentralDirectory(buffer: Buffer): number {
// EOCD 는 끝에서 22바이트 + 주석(최대 65535) 안에 있다
const minOffset = Math.max(0, buffer.length - 22 - 0xffff)
for (let offset = buffer.length - 22; offset >= minOffset; offset--) {
if (buffer.readUInt32LE(offset) === EOCD_SIGNATURE) return offset
}
throw new DocxExtractionError('Not a ZIP archive (end of central directory not found)')
}
/** ZIP 안의 한 항목을 꺼낸다. 없으면 null. */
export function readZipEntry(buffer: Buffer, entryName: string): Buffer | null {
if (buffer.length < 22) throw new DocxExtractionError('File too small to be a ZIP archive')
const eocd = findEndOfCentralDirectory(buffer)
const entryCount = buffer.readUInt16LE(eocd + 10)
let offset = buffer.readUInt32LE(eocd + 16)
for (let i = 0; i < entryCount; i++) {
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) {
throw new DocxExtractionError('Corrupt ZIP central directory')
}
const method = buffer.readUInt16LE(offset + 10)
const compressedSize = buffer.readUInt32LE(offset + 20)
const uncompressedSize = buffer.readUInt32LE(offset + 24)
const nameLength = buffer.readUInt16LE(offset + 28)
const extraLength = buffer.readUInt16LE(offset + 30)
const commentLength = buffer.readUInt16LE(offset + 32)
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
const name = buffer.toString('utf-8', offset + 46, offset + 46 + nameLength)
offset += 46 + nameLength + extraLength + commentLength
if (name !== entryName) continue
if (localHeaderOffset + 30 > buffer.length || buffer.readUInt32LE(localHeaderOffset) !== LOCAL_SIGNATURE) {
throw new DocxExtractionError('Corrupt ZIP local header')
}
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
const dataStart = localHeaderOffset + 30 + localNameLength + localExtraLength
const data = buffer.subarray(dataStart, dataStart + compressedSize)
if (uncompressedSize > MAX_UNCOMPRESSED_BYTES) {
throw new DocxExtractionError('ZIP entry too large')
}
if (method === METHOD_STORED) return Buffer.from(data)
if (method === METHOD_DEFLATE) {
return inflateRawSync(data, { maxOutputLength: MAX_UNCOMPRESSED_BYTES })
}
throw new DocxExtractionError(`Unsupported ZIP compression method: ${method}`)
}
return null
}
const XML_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
}
export function decodeXmlEntities(text: string): string {
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity: string) => {
if (entity.startsWith('#x')) return String.fromCodePoint(parseInt(entity.slice(2), 16))
if (entity.startsWith('#')) return String.fromCodePoint(parseInt(entity.slice(1), 10))
return XML_ENTITIES[entity] ?? match
})
}
/** WordprocessingML(document.xml) 에서 문단 단위 텍스트를 뽑는다. */
export function wordXmlToText(xml: string): string {
const paragraphs: string[] = []
const paragraphPattern = /<w:p[\s>][\s\S]*?<\/w:p>|<w:p\/>/g
for (const match of xml.match(paragraphPattern) ?? []) {
const runs: string[] = []
const tokenPattern = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g
let token: RegExpExecArray | null
while ((token = tokenPattern.exec(match)) !== null) {
if (token[1] !== undefined) runs.push(decodeXmlEntities(token[1]))
else if (token[0] === '<w:tab/>') runs.push('\t')
else runs.push('\n')
}
const text = runs.join('').trim()
if (text) paragraphs.push(text)
}
return paragraphs.join('\n')
}
/**
* .docx 버퍼에서 본문 텍스트를 추출한다. ZIP 이 아니거나 본문이 없으면 DocxExtractionError.
*/
export function extractDocxText(buffer: Buffer): string {
const documentXml = readZipEntry(buffer, 'word/document.xml')
if (!documentXml) throw new DocxExtractionError('word/document.xml not found')
const text = wordXmlToText(documentXml.toString('utf-8'))
if (!text.trim()) throw new DocxExtractionError('No text found in document')
return text
}

View file

@ -14,7 +14,8 @@ import type {
TestSTTConnectionResult, TestSTTConnectionResult,
STTStatus, STTStatus,
} from '@d3ro/core/types' } from '@d3ro/core/types'
import type { ISTTDriver } from './types' import type { ISTTDriver, STTEngine } from './types'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { OpenAIDriver } from './drivers/OpenAIDriver' import { OpenAIDriver } from './drivers/OpenAIDriver'
import { GroqDriver } from './drivers/GroqDriver' import { GroqDriver } from './drivers/GroqDriver'
import { DeepgramDriver } from './drivers/DeepgramDriver' import { DeepgramDriver } from './drivers/DeepgramDriver'
@ -26,6 +27,23 @@ import { normalizeLoopbackUrl } from '../../utils/loopback'
const logger = getLogger('STTManager') const logger = getLogger('STTManager')
/**
* 클라우드 실패 → 로컬 폴백의 전체 상한(모델 로딩 포함).
* 로컬 엔진의 각 단계에도 타임아웃이 있지만, 폴백 경로가 어떤 이유로든 멈추면
* 받아쓰기 액션 큐 전체가 굳으므로 여기서 한 번 더 끊는다.
*/
export const LOCAL_FALLBACK_TIMEOUT_MS = 180_000
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | null = null
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new D3ROError(ErrorCode.STTTranscriptionTimeout, message)), timeoutMs)
})
return Promise.race([promise, timeout]).finally(() => {
if (timer) clearTimeout(timer)
})
}
export const STT_PROVIDERS_META: STTProviderInfo[] = [ export const STT_PROVIDERS_META: STTProviderInfo[] = [
{ {
id: 'local', id: 'local',
@ -116,7 +134,7 @@ export const STT_PROVIDERS_META: STTProviderInfo[] = [
}, },
] ]
export class STTManager extends EventEmitter { export class STTManager extends EventEmitter implements STTEngine {
private _drivers = new Map<STTProviderType, ISTTDriver>() private _drivers = new Map<STTProviderType, ISTTDriver>()
constructor() { constructor() {
@ -210,7 +228,7 @@ export class STTManager extends EventEmitter {
async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> { async transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> {
const provider = this.getActiveProvider() const provider = this.getActiveProvider()
// 1. Local STT // 1. Local STT — LocalSTTService.transcribe가 준비되지 않은 엔진을 스스로 초기화한다
if (provider === 'local') { if (provider === 'local') {
return getLocalSTTService().transcribe(audioBuffer, options) return getLocalSTTService().transcribe(audioBuffer, options)
} }
@ -219,7 +237,7 @@ export class STTManager extends EventEmitter {
const driver = this._drivers.get(provider) const driver = this._drivers.get(provider)
if (!driver) { if (!driver) {
logger.warn(`Driver not found for provider ${provider}, falling back to local`) logger.warn(`Driver not found for provider ${provider}, falling back to local`)
return getLocalSTTService().transcribe(audioBuffer, options) return this._transcribeLocalFallback(audioBuffer, options)
} }
const config = this.getProviderConfig(provider) const config = this.getProviderConfig(provider)
@ -236,7 +254,7 @@ export class STTManager extends EventEmitter {
logger.info(`Auto-fallback: executing Local Whisper transcription...`) logger.info(`Auto-fallback: executing Local Whisper transcription...`)
this.emit('fallback-to-local', { provider, reason: errorMsg }) this.emit('fallback-to-local', { provider, reason: errorMsg })
try { try {
return await getLocalSTTService().transcribe(audioBuffer, options) return await this._transcribeLocalFallback(audioBuffer, options)
} catch (localError) { } catch (localError) {
logger.error(`Local STT fallback also failed: ${localError}`) logger.error(`Local STT fallback also failed: ${localError}`)
throw error // 원래 에러 유지 throw error // 원래 에러 유지
@ -247,6 +265,45 @@ export class STTManager extends EventEmitter {
} }
} }
/**
* 클라우드 공급자에서 로컬 엔진으로 넘어가는 경로.
* 클라우드 공급자를 쓰는 동안에는 아무도 로컬 모델을 올리지 않는다 — LocalSTTService.transcribe가
* 설정된 모델을 스스로 올린 뒤 전사하고(실패하면 reject), 여기서는 전체에 상한 시간을 두어
* 받아쓰기 액션 큐가 굳지 않게 한다.
*/
private _transcribeLocalFallback(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
return withTimeout(
getLocalSTTService().transcribe(audioBuffer, options),
LOCAL_FALLBACK_TIMEOUT_MS,
`Local STT fallback timed out after ${LOCAL_FALLBACK_TIMEOUT_MS}ms`,
)
}
/**
* STTEngine 포트: 현재 공급자로 전사할 준비를 한다.
* 로컬이면 설정된 모델을 올리고(이미 올라가 있으면 즉시 반환), 클라우드면 할 일이 없다.
*/
async ensureReady(): Promise<void> {
if (this.getActiveProvider() !== 'local') return
await getLocalSTTService().initialize(configGet('sttModelId'))
}
/** STTEngine 포트: 로컬 공급자에서 선택한 모델이 설치되지 않았으면 그 id를 돌려준다. */
getMissingModel(): string | null {
if (this.getActiveProvider() !== 'local') return null
const modelId = configGet('sttModelId')
const model = getLocalSTTService().getModels().find((m) => m.id === modelId)
return model && !model.downloaded ? modelId : null
}
/** STTEngine 포트: 실시간 미리보기는 로컬 Whisper에서만 지원한다. */
supportsPartial(): boolean {
return this.getActiveProvider() === 'local'
}
/** /**
* 녹음 중 실시간 미리보기 전사(최종 삽입과 무관). * 녹음 중 실시간 미리보기 전사(최종 삽입과 무관).
* 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다. * 로컬 Whisper에서만 지원한다 — 클라우드 공급자는 요청 비용/지연이 커서 사용하지 않는다.

View file

@ -4,6 +4,8 @@
import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService' import type { TranscriptionResult, TranscribeOptions } from '../LocalSTTService'
import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types' import type { STTProviderConfig, STTProviderType } from '@d3ro/core/types'
export type { TranscriptionResult, TranscribeOptions }
export interface ISTTDriver { export interface ISTTDriver {
readonly id: STTProviderType readonly id: STTProviderType
readonly name: string readonly name: string
@ -18,3 +20,26 @@ export interface ISTTDriver {
message: string message: string
}> }>
} }
/**
* 받아쓰기 파이프라인이 의존하는 STT 엔진 포트.
*
* 공급자(로컬/클라우드) 선택과 로컬 폴백 정책은 구현(STTManager) 한 곳에만 둔다.
* 오케스트레이터(VoiceModeService)는 `provider === 'local'` 같은 분기를 알 필요가 없다.
*/
export interface STTEngine {
/**
* 현재 공급자로 전사할 준비를 한다. 로컬 공급자면 설정된 모델을 올리고,
* 클라우드 공급자면 즉시 끝난다. 실패하면 예외를 던진다(무한 대기 없음).
*/
ensureReady(): Promise<void>
/**
* 녹음 시작 전에 확인할 수 있는 "필수 모델 미설치" 상태.
* 설치되지 않은 모델 id를 돌려주고, 문제가 없으면 null.
*/
getMissingModel(): string | null
/** 실시간 미리보기(부분 전사)를 지원하는지 — 클라우드 공급자는 요청 비용 때문에 지원하지 않는다. */
supportsPartial(): boolean
transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult>
transcribePartial(audioBuffer: Buffer, options?: TranscribeOptions): Promise<string>
}

View file

@ -0,0 +1,113 @@
// src/main/shutdown-registry.ts
// 앱 종료 시 정리할 자원(사이드카 프로세스, 전역 입력 훅, 타이머 등)의 등록부.
//
// 예전엔 lifecycle.ts 가 disposeLocalLLMService 하나만 하드코딩해 호출했고, 새로 프로세스를
// 소유하는 서비스(STT 사이드카 등)는 lifecycle 을 고치는 것을 잊어 종료 후에도 고아로 남았다.
// 이제 서비스는 시작 시점에 스스로 등록하고, lifecycle 은 이 등록부에만 의존한다(DIP/OCP).
export interface Disposable {
name: string
dispose(): Promise<void> | void
}
export interface ShutdownReport {
completed: string[]
failed: string[]
timedOut: string[]
}
export interface ShutdownRegistryOptions {
now?: () => number
onError?: (name: string, error: unknown) => void
}
const TIMED_OUT = Symbol('timed-out')
export class ShutdownRegistry {
private _entries: Disposable[] = []
private _running: Promise<ShutdownReport> | null = null
private readonly _now: () => number
private readonly _onError: (name: string, error: unknown) => void
constructor(options: ShutdownRegistryOptions = {}) {
this._now = options.now ?? (() => Date.now())
this._onError = options.onError ?? (() => undefined)
}
/** 정리 대상을 등록한다. 돌려받은 함수로 등록을 취소할 수 있다. */
register(entry: Disposable): () => void {
this._entries.push(entry)
return () => {
this._entries = this._entries.filter((e) => e !== entry)
}
}
get size(): number {
return this._entries.length
}
/**
* 등록의 역순(나중에 시작한 것부터)으로 하나씩 정리한다. 여러 번 불러도 한 번만 실행된다.
* 전체 시간 상한(timeoutMs)을 넘기면 남은 항목은 기다리지 않고 dispose 만 걸어 둔다
* (동기 구간 — 프로세스 kill 등 — 은 그래도 실행된다). 한 항목이 멈춰도 종료가 막히지 않는다.
*/
disposeAll(timeoutMs: number): Promise<ShutdownReport> {
if (!this._running) this._running = this._run(timeoutMs)
return this._running
}
private async _run(timeoutMs: number): Promise<ShutdownReport> {
const report: ShutdownReport = { completed: [], failed: [], timedOut: [] }
const deadline = this._now() + timeoutMs
const entries = [...this._entries].reverse()
for (const entry of entries) {
const remaining = deadline - this._now()
let pending: Promise<void>
try {
pending = Promise.resolve(entry.dispose())
} catch (error) {
this._onError(entry.name, error)
report.failed.push(entry.name)
continue
}
if (remaining <= 0) {
pending.catch((error: unknown) => this._onError(entry.name, error))
report.timedOut.push(entry.name)
continue
}
let timer: ReturnType<typeof setTimeout> | null = null
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
timer = setTimeout(() => resolve(TIMED_OUT), remaining)
})
try {
const outcome = await Promise.race([pending.then(() => 'done' as const), timeout])
if (outcome === TIMED_OUT) {
pending.catch((error: unknown) => this._onError(entry.name, error))
report.timedOut.push(entry.name)
} else {
report.completed.push(entry.name)
}
} catch (error) {
this._onError(entry.name, error)
report.failed.push(entry.name)
} finally {
if (timer) clearTimeout(timer)
}
}
return report
}
}
let instance: ShutdownRegistry | null = null
export function getShutdownRegistry(): ShutdownRegistry {
if (!instance) instance = new ShutdownRegistry()
return instance
}
export function resetShutdownRegistryForTests(): void {
instance = null
}

View file

@ -259,3 +259,84 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
return policy return policy
} }
// ============================================================
// 업데이트 제안 평가 (게이팅 순서의 단일 정본)
// ============================================================
export interface UpdateOfferInput {
policy: UpdatePolicy
channel: UpdateChannel
currentVersion: string
targetVersion: string
/** 사용자가 건너뛴 버전 (없으면 null) */
skippedVersion: string | null
deviceId: string
}
export type UpdateOfferIgnoreReason = 'kill-switch' | 'channel' | 'skipped' | 'rollout'
export type UpdateOffer =
| { action: 'ignore'; reason: UpdateOfferIgnoreReason; decision: UpdateDecision }
| { action: 'prompt' | 'force-download'; decision: UpdateDecision; isMajorUpgrade: boolean }
/**
* 발견된 업데이트를 어떻게 다룰지 결정한다. 순서:
* 킬 스위치 → 채널(prerelease) → 사용자 건너뛰기(필수면 무시) → staged rollout(필수면 무시)
* → 강제 설치 하한 미만이면 대화상자 없이 다운로드, 아니면 사용자에게 묻는다.
*/
export function evaluateUpdateOffer(input: UpdateOfferInput): UpdateOffer {
const { policy, channel, currentVersion, targetVersion } = input
const decision = decideUpdate(policy, currentVersion, targetVersion)
if (policy.killSwitch) return { action: 'ignore', reason: 'kill-switch', decision }
const channelPolicy = policy.channels[channel] ?? { allowPrerelease: false }
if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, targetVersion)) {
return { action: 'ignore', reason: 'channel', decision }
}
if (!decision.mandatory && input.skippedVersion === targetVersion) {
return { action: 'ignore', reason: 'skipped', decision }
}
if (!isWithinRollout(policy, input.deviceId, targetVersion, decision)) {
return { action: 'ignore', reason: 'rollout', decision }
}
const currentMajor = majorOf(currentVersion)
const targetMajor = majorOf(targetVersion)
const isMajorUpgrade = currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor
if (decision.mandatory && decision.reason === 'below-force-install') {
return { action: 'force-download', decision, isMajorUpgrade }
}
return { action: 'prompt', decision, isMajorUpgrade }
}
/** 마지막으로 사용자에게 업데이트를 물어본 기록 */
export interface UpdatePromptRecord {
version: string
mandatory: boolean
at: number
}
/** '나중에' 이후 같은 버전을 다시 묻기까지의 간격 (24시간) */
export const UPDATE_REPROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000
/**
* 이번 제안을 사용자에게 다시 물을지 결정한다.
* 예전엔 프로세스당 한 번만 물어 '나중에'나 다운로드 실패 뒤로는 새 버전·필수 업데이트도
* 앱을 다시 켤 때까지 조용히 묻혔다. 이제 새 버전, 필수로 격상, 또는 간격이 지나면 다시 묻는다.
*/
export function shouldPromptForUpdate(
last: UpdatePromptRecord | null,
offer: { version: string; mandatory: boolean },
now: number,
intervalMs: number = UPDATE_REPROMPT_INTERVAL_MS,
): boolean {
if (!last) return true
if (last.version !== offer.version) return true
if (offer.mandatory && !last.mandatory) return true
return now - last.at >= intervalMs
}

View file

@ -0,0 +1,157 @@
// src/main/windows/popup-navigation.ts
// 히스토리/명령어 팝업의 표시·키보드 내비게이션 컨트롤러.
//
// 두 팝업은 포커스를 갖지 않으므로(focusable:false) 키보드 조작은 전역 단축키
// (Up/Down/Return/Escape/1-9)로만 들어온다. 예전엔 bootstrap 이 팝업마다 단축키를 따로
// 등록했는데, Electron 은 이미 등록된 가속기에 대한 재등록을 조용히 거부(false 반환)하므로
// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 여전히 히스토리 항목을 삽입했다.
// 이제 단축키 소유자(owner)는 하나이고, 한 팝업을 열기 전에 다른 팝업을 닫는다.
// Electron 의존은 포트(PopupWindowPort/ShortcutPort)로 주입한다 — 이 파일은 순수 로직이다.
export type PopupKind = 'history' | 'command'
export interface PopupWindowPort {
/** 최신 데이터로 팝업을 띄운다 */
show(): void
hide(): void
isVisible(): boolean
sendKey(key: string): void
}
export interface ShortcutPort {
/** 성공하면 true — Electron globalShortcut.register 와 같은 의미 */
register(accelerator: string, callback: () => void): boolean
unregister(accelerator: string): void
}
export interface PopupNavigationDeps {
windows: Record<PopupKind, PopupWindowPort>
shortcuts: ShortcutPort
/** 히스토리 항목 텍스트를 포커스된 앱에 삽입한다 */
insertText(text: string): Promise<void>
/** 명령어 선택(빈 id 는 선택 해제)을 설정에 반영하고 UI 에 알린다 */
applyCommandSelection(selection: { id: string; name: string }): void
/** 지연 실행 — 기본 setTimeout */
schedule?(fn: () => void, ms: number): void
warn?(message: string): void
}
/** 팝업 키 내비게이션 가속기 → 팝업에 보낼 키 이름 */
export const POPUP_NAV_KEYS: ReadonlyArray<{ accel: string; key: string }> = [
{ accel: 'Up', key: 'ArrowUp' },
{ accel: 'Down', key: 'ArrowDown' },
{ accel: 'Return', key: 'Enter' },
{ accel: 'Escape', key: 'Escape' },
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
]
/** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */
export const HISTORY_INSERT_DELAY_MS = 200
export class PopupNavigationController {
private _owner: PopupKind | null = null
private _keysRegistered = false
constructor(private readonly _deps: PopupNavigationDeps) {}
/** 현재 내비게이션 키를 받는 팝업 */
get owner(): PopupKind | null {
return this._owner
}
toggle(kind: PopupKind): void {
if (this._deps.windows[kind].isVisible()) {
this.dismiss(kind)
return
}
this.show(kind)
}
/** 다른 팝업을 닫고 이 팝업을 띄운 뒤 내비게이션 키를 이 팝업에 묶는다. */
show(kind: PopupKind): void {
const other: PopupKind = kind === 'history' ? 'command' : 'history'
if (this._deps.windows[other].isVisible()) {
this._deps.windows[other].hide()
}
this._deps.windows[kind].show()
this._owner = kind
this._registerKeys()
}
/** 팝업을 닫는다. 키는 그 팝업이 소유자일 때만 해제한다(다른 팝업의 키를 뺏지 않게). */
dismiss(kind: PopupKind): void {
this._deps.windows[kind].hide()
if (this._owner === kind) {
this._owner = null
this._unregisterKeys()
}
}
/** 히스토리 항목 선택 → 팝업을 닫고 잠시 뒤 삽입 */
selectHistoryItem(text: string): void {
this.dismiss('history')
const schedule = this._deps.schedule ?? ((fn: () => void, ms: number) => { setTimeout(fn, ms) })
schedule(() => {
this._deps.insertText(text).catch((error: unknown) => {
this._warn(`History popup insert failed: ${error instanceof Error ? error.message : String(error)}`)
})
}, HISTORY_INSERT_DELAY_MS)
}
/** 명령어 선택 → 팝업을 닫고 선택을 반영 */
selectCommand(selection: { id: string; name: string }): void {
this.dismiss('command')
this._deps.applyCommandSelection(selection)
}
dispose(): void {
this._owner = null
this._unregisterKeys()
}
private _registerKeys(): void {
if (this._keysRegistered) return
this._keysRegistered = true
for (const { accel, key } of POPUP_NAV_KEYS) {
try {
// 콜백은 등록 시점이 아니라 "지금의 소유자"에게 보낸다
const ok = this._deps.shortcuts.register(accel, () => this._routeKey(key))
if (!ok) this._warn(`Popup nav key not registered (held elsewhere): ${accel}`)
} catch {
// 일부 키는 globalShortcut으로 등록 불가할 수 있음
this._warn(`Popup nav key registration threw: ${accel}`)
}
}
}
private _unregisterKeys(): void {
if (!this._keysRegistered) return
this._keysRegistered = false
for (const { accel } of POPUP_NAV_KEYS) {
try {
this._deps.shortcuts.unregister(accel)
} catch {
// 이미 해제된 경우 무시
}
}
}
private _routeKey(key: string): void {
const owner = this._owner
if (!owner) return
const win = this._deps.windows[owner]
if (win.isVisible()) {
win.sendKey(key)
return
}
// 팝업이 다른 경로로 사라졌다 — 전역 키(Enter/방향키/숫자)를 계속 붙잡지 않게 놓는다
this._owner = null
this._unregisterKeys()
}
private _warn(message: string): void {
this._deps.warn?.(message)
}
}

View file

@ -0,0 +1,133 @@
// src/main/windows/recording-tip-presenter.ts
// RecordingTip(녹음 팁)·결과 팝업 표시 정책의 단일 정본.
//
// 예전엔 VoiceModeService 가 WindowManager 를 직접 부르며 에러 3초 숨김 타이머를 가졌고,
// bootstrap 도 같은 'error' 이벤트에 자기 3초 숨김 타이머(핸들 없음)를 따로 걸었다.
// 그래서 에러 직후 3초 안에 시작한 새 세션의 녹음 팁이 녹음 도중 사라졌고,
// 세션을 막지 않는 'warning'(Ollama 미가용)에도 매번 에러 표시가 떴다.
// 이제 VoiceModeService 는 이벤트만 내보내고, 이 프레젠터가 취소 가능한 타이머 하나로 그린다.
import {
showRecordingTip,
hideRecordingTip,
updateRecordingTipState,
sendAudioLevelToTip,
sendPartialTranscriptToTip,
showResultPopup,
} from './WindowManager'
/** 프레젠터가 그리는 대상(창) 포트 — 테스트에서는 가짜로 바꾼다 */
export interface RecordingTipView {
showRecording(): void
hide(): void
showThinking(): void
showError(message: string): void
sendAudioLevel(level: number): void
sendPartialTranscript(text: string): void
showResult(text: string, durationMs: number): void
}
export interface RecordingTipTimers {
setTimeout(fn: () => void, ms: number): ReturnType<typeof setTimeout>
clearTimeout(handle: ReturnType<typeof setTimeout>): void
}
export interface RecordingTipPresenterOptions {
/** 에러 표시 후 팁을 숨기기까지(ms). 기본 3000 */
errorHideMs?: number
/** 자동 삽입 실패 시 결과 팝업 표시 시간(ms). 기본 10000 */
resultPopupMs?: number
timers?: RecordingTipTimers
}
export type RecordingTipPhase = 'recording' | 'thinking'
export type RecordingTipSeverity = 'error' | 'warning'
export class RecordingTipPresenter {
private _hideTimer: ReturnType<typeof setTimeout> | null = null
private readonly _errorHideMs: number
private readonly _resultPopupMs: number
private readonly _timers: RecordingTipTimers
constructor(
private readonly _view: RecordingTipView,
options: RecordingTipPresenterOptions = {},
) {
this._errorHideMs = options.errorHideMs ?? 3000
this._resultPopupMs = options.resultPopupMs ?? 10000
this._timers = options.timers ?? {
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (handle) => clearTimeout(handle),
}
}
/** 새 세션이 시작되면 이전 에러의 숨김 예약을 취소한다 (새 팁을 숨기지 않게) */
onSessionStarted(): void {
this._cancelHide()
}
onPhase(phase: RecordingTipPhase): void {
if (phase === 'recording') {
this._cancelHide()
this._view.showRecording()
} else {
this._view.showThinking()
}
}
onAudioLevel(level: number): void {
this._view.sendAudioLevel(level)
}
onPartialTranscript(text: string): void {
this._view.sendPartialTranscript(text)
}
/**
* 에러 표시 후 한 번만 숨김을 예약한다(이전 예약은 교체).
* 'warning' 은 세션을 막지 않는 알림이므로 팁을 건드리지 않는다.
*/
onError(message: string, severity: RecordingTipSeverity = 'error'): void {
if (severity === 'warning') return
this._view.showError(message)
this._cancelHide()
this._hideTimer = this._timers.setTimeout(() => {
this._hideTimer = null
this._view.hide()
}, this._errorHideMs)
}
/** 세션 완료/취소 — 팁을 즉시 숨긴다 */
onSessionEnded(): void {
this._cancelHide()
this._view.hide()
}
onInsertFailed(text: string): void {
this._view.showResult(text, this._resultPopupMs)
}
dispose(): void {
this._cancelHide()
}
private _cancelHide(): void {
if (this._hideTimer) {
this._timers.clearTimeout(this._hideTimer)
this._hideTimer = null
}
}
}
/** WindowManager 기반 실제 창 어댑터 */
export function createWindowRecordingTipView(): RecordingTipView {
return {
showRecording: () => showRecordingTip('recording'),
hide: () => hideRecordingTip(),
showThinking: () => updateRecordingTipState('thinking'),
showError: (message) => updateRecordingTipState('error', { errorMessage: message }),
sendAudioLevel: (level) => sendAudioLevelToTip(level),
sendPartialTranscript: (text) => sendPartialTranscriptToTip(text),
showResult: (text, durationMs) => showResultPopup(text, durationMs),
}
}

View file

@ -27,6 +27,17 @@ function stripThinkTags(text: string): string {
return result.trim() return result.trim()
} }
/**
* main 이 실어 보낸 sessionId 가 이 패널의 회의인지 확인한다.
* sessionId 가 없는(구버전) 이벤트는 그대로 받아들인다.
*/
function isForSession(payload: object, sessionId: string): boolean {
if ('sessionId' in payload && typeof payload.sessionId === 'string') {
return payload.sessionId === sessionId
}
return true
}
// 타이핑 인디케이터 점 3개 애니메이션 // 타이핑 인디케이터 점 3개 애니메이션
function TypingIndicator(): React.ReactElement { function TypingIndicator(): React.ReactElement {
return ( return (
@ -74,21 +85,38 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
scrollToBottom() scrollToBottom()
}, [messages, streamingContent, scrollToBottom]) }, [messages, streamingContent, scrollToBottom])
// 이벤트 구독 // 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
useEffect(() => {
setMessages([])
setStreaming(false)
setStreamingContent('')
setError(null)
isSendingChatRef.current = false
return () => {
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
void window.electronAPI.meetingChat.cancel()
}
}, [sessionId])
// 이벤트 구독 — 다른 회의로 향한 이벤트는 무시한다
useEffect(() => { useEffect(() => {
const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => { const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => {
if (!isForSession(data, sessionId)) return
setStreaming(true) setStreaming(true)
setStreamingContent((prev) => prev + data.token) setStreamingContent((prev) => prev + data.token)
}) })
const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => { const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => {
setMessages((prev) => [...prev, msg]) if (!isForSession(msg, sessionId)) return
setMessages((prev) => [...prev, { role: msg.role, content: msg.content, timestamp: msg.timestamp }])
setStreaming(false) setStreaming(false)
setStreamingContent('') setStreamingContent('')
isSendingChatRef.current = false isSendingChatRef.current = false
}) })
const unsubError = window.electronAPI.meetingChat.onError((err) => { const unsubError = window.electronAPI.meetingChat.onError((err) => {
if (!isForSession(err, sessionId)) return
setStreaming(false) setStreaming(false)
setStreamingContent('') setStreamingContent('')
setError(err.message) setError(err.message)
@ -139,7 +167,7 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
setMessages([]) setMessages([])
setStreamingContent('') setStreamingContent('')
setStreaming(false) setStreaming(false)
window.electronAPI.meetingChat.clear({ sessionId }) window.electronAPI.meetingChat.clear()
}, [sessionId]) }, [sessionId])
const handleToggleCollapse = useCallback(() => { const handleToggleCollapse = useCallback(() => {

View file

@ -0,0 +1,75 @@
// tests/main/services/audio-capture-redteam-r1-3.test.ts
// SoX 가 장치를 못 열고 끝났을 때 'error' 리스너가 없어 ERR_UNHANDLED_ERROR 가 나고
// 뒤이은 'stopped' 가 영영 나가지 않던 버그의 회귀 테스트.
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { EventEmitter } from 'events'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(() => 'usb-headset-gone'),
}))
vi.mock('../../../src/main/utils/paths', () => ({
getSoxPath: () => 'sox',
}))
class FakeProcess extends EventEmitter {
stdout = new EventEmitter()
stderr = new EventEmitter()
kill = vi.fn()
}
const spawned = vi.hoisted(() => ({ list: [] as unknown[] }))
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>()
return {
...actual,
spawn: vi.fn(() => {
const proc = new FakeProcess()
spawned.list.push(proc)
return proc
}),
}
})
type Module = typeof import('../../../src/main/services/AudioCaptureService')
let mod: Module
beforeEach(async () => {
vi.resetModules()
spawned.list = []
mod = await import('../../../src/main/services/AudioCaptureService')
mod.resetAudioCaptureServiceForTests()
})
describe('AudioCaptureService — 캡처 프로세스 종료', () => {
it("SoX 가 녹음 중 끝나도 예외 없이 'stopped'(device-lost) 를 알린다", async () => {
const audio = mod.getAudioCaptureService()
const stopped: string[] = []
audio.on('stopped', ({ reason }) => stopped.push(reason))
await audio.start()
expect(audio.state).toBe('capturing')
const proc = spawned.list[0] as FakeProcess
expect(() => proc.emit('close', 1)).not.toThrow()
expect(stopped).toEqual(['device-lost'])
expect(audio.state).toBe('error')
})
it('실패 뒤에도 다시 시작할 수 있다', async () => {
const audio = mod.getAudioCaptureService()
await audio.start()
;(spawned.list[0] as FakeProcess).emit('close', 1)
await audio.start()
expect(audio.state).toBe('capturing')
expect(spawned.list).toHaveLength(2)
})
})

View file

@ -0,0 +1,134 @@
// tests/main/services/caption-owner-redteam-r1-3.test.ts
// CaptionService 세션 계약 회귀 테스트:
// - 회의 모드가 소유한 자막 세션은 사용자 요청(stop('user'))으로 멈추지 않는다
// - stop() 은 flush 중 확정된 마지막 문장까지 담은 요약을 돌려준다
// - persistHistory:false 면 'caption' 히스토리를 만들지 않고 session-saved 도 없다
// - 저장한 경우 요약에 히스토리 행 id 가 실린다 (자동 요약이 그 id 로 찾는다)
// - audioSource 옵션은 전역 설정/다음 세션으로 새지 않는다
import { describe, it, expect, beforeEach, vi } from 'vitest'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => config.values[key]),
configSet: vi.fn((key: string, value: unknown) => {
config.values[key] = value
}),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({
initialize: vi.fn(async () => undefined),
ensureAuxModel: vi.fn(async () => undefined),
transcribe: vi.fn(async () => ({ text: '', segments: [], language: 'ko', duration: 0, processingTime: 0 })),
}),
}))
const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) }))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }),
calculateRMS: () => 0.1,
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }),
}))
const history = vi.hoisted(() => ({ create: vi.fn() }))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: () => history,
}))
// 트랙: flush 하면 말하던 마지막 문장을 확정한다
vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({
StreamingCaptionTrack: class {
constructor(private readonly deps: { onFinal: (text: string) => void }) {}
push(): void {}
async tick(): Promise<void> {}
async flush(): Promise<void> {
this.deps.onFinal('정지 직전 마지막 문장')
}
},
}))
type CaptionModule = typeof import('../../../src/main/services/CaptionService')
let mod: CaptionModule
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
config.values = { captionAudioSource: 'system' }
history.create.mockReturnValue({ id: 'history-row-1' })
mod = await import('../../../src/main/services/CaptionService')
mod.resetCaptionServiceForTests()
})
describe('CaptionService 세션 소유권', () => {
it('회의가 소유한 세션은 stop(\'user\') 를 거부하고 계속 돈다', async () => {
const caption = mod.getCaptionService()
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
expect(await caption.stop('user')).toBe(false)
expect(caption.getState()).toBe('active')
expect(audio.stop).not.toHaveBeenCalled()
// 소유자와 강제 정지(인자 없음)는 멈출 수 있다
expect(await caption.stop('meeting')).not.toBe(false)
expect(caption.getState()).toBe('inactive')
})
it('사용자 세션은 stop(\'user\') 로 멈춘다', async () => {
const caption = mod.getCaptionService()
await caption.start()
expect(caption.getOwner()).toBe('user')
await caption.stop('user')
expect(caption.getState()).toBe('inactive')
})
})
describe('CaptionService 정지 요약', () => {
it('flush 중 확정된 문장을 요약에 담아 돌려주고, persistHistory:false 면 히스토리를 만들지 않는다', async () => {
const caption = mod.getCaptionService()
const saved = vi.fn()
caption.on('session-saved', saved)
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
const summary = await caption.stop('meeting')
expect(summary).not.toBe(false)
expect(summary && summary.segments.map((s) => s.text)).toEqual(['정지 직전 마지막 문장'])
expect(summary && summary.historyId).toBeNull()
expect(history.create).not.toHaveBeenCalled()
expect(saved).not.toHaveBeenCalled()
})
it('저장한 세션은 요약에 히스토리 행 id 를 싣는다', async () => {
const caption = mod.getCaptionService()
const saved = vi.fn()
caption.on('session-saved', saved)
await caption.start({ audioSource: 'mic' })
const summary = await caption.stop()
expect(history.create).toHaveBeenCalledTimes(1)
expect(summary && summary.historyId).toBe('history-row-1')
expect(saved).toHaveBeenCalledWith(expect.objectContaining({ historyId: 'history-row-1' }))
})
it('audioSource 옵션은 다음 사용자 세션의 설정으로 새지 않는다', async () => {
const caption = mod.getCaptionService()
caption.setConfig({ audioSource: 'system' })
await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false })
await caption.stop()
expect(config.values.captionAudioSource).toBe('system')
expect(caption.getConfig().audioSource).toBe('system')
})
})

View file

@ -0,0 +1,131 @@
// tests/main/services/llm-gateway.test.ts
// LLM 백엔드 선택 정책(단일 정본)의 local / online / 불가 / 실패 매트릭스.
import { describe, it, expect, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import {
createRoutedLlmGateway,
type LocalLlmAdapter,
type PremiumLlmAdapter,
} from '../../../src/main/services/llm/LlmGateway'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
async function* tokens(...values: string[]): AsyncGenerator<string, string> {
for (const v of values) yield v
return values.join('')
}
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'premium'),
generate: vi.fn(async () => ({ text: 'premium' })),
chatStream: vi.fn(() => tokens('p')),
cancelGeneration: vi.fn(),
...overrides,
}
}
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'local'),
generate: vi.fn(async () => ({ text: 'local', model: 'gemma' })),
chatStream: vi.fn(() => tokens('l')),
cancelGeneration: vi.fn(),
...overrides,
}
}
function gateway(backend: string | undefined, p: PremiumLlmAdapter, l: LocalLlmAdapter) {
return createRoutedLlmGateway({
getBackend: () => backend,
loadPremium: async () => p,
getLocal: () => l,
})
}
describe('LlmGateway 라우팅', () => {
it('local 백엔드는 Premium 을 절대 건드리지 않는다', async () => {
const p = premium()
const l = local()
const gw = gateway('local', p, l)
expect(await gw.processText('t', 'refine')).toBe('local')
expect((await gw.generate('q', { systemPrompt: 'docs' })).text).toBe('local')
expect(p.processText).not.toHaveBeenCalled()
expect(p.generate).not.toHaveBeenCalled()
expect(p.isAvailable).not.toHaveBeenCalled()
})
it('online + 가용이면 Premium 을 쓴다', async () => {
const p = premium()
const l = local()
const gw = gateway('online', p, l)
expect(await gw.processText('t', 'custom', undefined, 'sys')).toBe('premium')
expect(p.processText).toHaveBeenCalledWith('t', 'custom', undefined, 'sys')
expect(l.processText).not.toHaveBeenCalled()
})
it('online + 불가면 로컬로 폴백하고 사유를 알린다', async () => {
const onFallback = vi.fn()
const gw = gateway('online', premium({ isAvailable: vi.fn(() => false) }), local())
expect(await gw.processText('t', 'refine', undefined, undefined, { onFallback })).toBe('local')
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('unavailable'))
})
it('online + Premium 호출 실패면 로컬로 폴백한다', async () => {
const onFallback = vi.fn()
const gw = gateway(
'online',
premium({ generate: vi.fn(async () => { throw new Error('quota') }) }),
local(),
)
expect((await gw.generate('q', undefined, { onFallback })).text).toBe('local')
expect(onFallback).toHaveBeenCalledWith(expect.stringContaining('quota'))
})
it('로컬 실패는 그대로 올린다 (성공으로 위장하지 않는다)', async () => {
const gw = gateway('local', premium(), local({ processText: vi.fn(async () => { throw new Error('ollama down') }) }))
await expect(gw.processText('t', 'refine')).rejects.toThrow('ollama down')
})
it('채팅 스트림: Premium 가용이면 Premium, 로컬은 옵션과 signal 을 받는다', async () => {
const p = premium()
const l = local()
const signal = new AbortController().signal
const online = await gateway('online', p, l).openChatStream([{ role: 'user', content: 'hi' }], { signal })
expect(online.backend).toBe('premium')
const offline = await gateway('local', p, l).openChatStream([{ role: 'user', content: 'hi' }], {
signal,
local: { maxTokens: 512, timeoutMs: 60000, keepAlive: '2m' },
})
expect(offline.backend).toBe('local')
expect(l.chatStream).toHaveBeenCalledWith(
[{ role: 'user', content: 'hi' }],
{ maxTokens: 512, timeoutMs: 60000, keepAlive: '2m', signal },
)
})
it('채팅 스트림: 로컬이 불가하면 LLMServerUnreachable', async () => {
const gw = gateway('local', premium(), local({ isAvailable: vi.fn(() => false) }))
await expect(
gw.openChatStream([], { signal: new AbortController().signal }),
).rejects.toMatchObject({ code: ErrorCode.LLMServerUnreachable })
})
it('cancel 은 로드된 두 어댑터를 모두 취소한다', async () => {
const p = premium()
const l = local()
const gw = gateway('online', p, l)
await gw.processText('t', 'refine')
gw.cancel()
expect(p.cancelGeneration).toHaveBeenCalled()
expect(l.cancelGeneration).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,43 @@
// tests/main/services/rag-docx-redteam-r1-3.test.ts
// Word 가 만든(deflate 압축) .docx 에서 본문을 뽑는지, ZIP 이 아니면 바이너리 잡음을
// 색인하지 않고 실패하는지 확인한다.
import { describe, it, expect } from 'vitest'
import { Document, Packer, Paragraph, TextRun } from 'docx'
import {
extractDocxText,
decodeXmlEntities,
wordXmlToText,
DocxExtractionError,
} from '../../../src/main/services/rag/docx-text'
async function buildDocx(paragraphs: string[]): Promise<Buffer> {
const doc = new Document({
sections: [{ children: paragraphs.map((p) => new Paragraph({ children: [new TextRun(p)] })) }],
})
return Buffer.from(await Packer.toBuffer(doc))
}
describe('extractDocxText', () => {
it('압축된 word/document.xml 에서 문단 텍스트를 뽑는다', async () => {
const buffer = await buildDocx(['분기 매출 보고서', 'List<String> 타입을 쓰고 A & B 를 비교한다'])
// 원본 ZIP 바이트에는 본문이 평문으로 보이지 않는다 (예전 정규식이 실패한 이유)
expect(buffer.toString('utf-8')).not.toContain('분기 매출 보고서')
const text = extractDocxText(buffer)
expect(text).toBe('분기 매출 보고서\nList<String> 타입을 쓰고 A & B 를 비교한다')
})
it('ZIP 이 아니면 잡음을 돌려주지 않고 실패한다', () => {
const garbage = Buffer.from('PK\u0003\u0004 this is not really a zip archive at all, just junk bytes', 'utf-8')
expect(() => extractDocxText(garbage)).toThrow(DocxExtractionError)
})
it('XML 엔티티와 탭/줄바꿈을 복원한다', () => {
expect(decodeXmlEntities('a &amp; b &lt;c&gt; &#65;&#x42;')).toBe('a & b <c> AB')
const xml =
'<w:body><w:p><w:r><w:t>하나</w:t><w:tab/><w:t xml:space="preserve"> 둘</w:t></w:r></w:p>' +
'<w:p><w:pPr><w:pStyle w:val="x"/></w:pPr><w:r><w:t>셋</w:t><w:br/><w:t>넷</w:t></w:r></w:p><w:p/></w:body>'
expect(wordXmlToText(xml)).toBe('하나\t 둘\n셋\n넷')
})
})

View file

@ -0,0 +1,70 @@
// tests/main/services/screen-context-redteam-r1-3.test.ts
// 선택 텍스트 캡처 회귀 테스트:
// - 핫키 수정자(Alt 등)를 쥔 상태면 Ctrl+C 를 보내지 않고 클립보드도 건드리지 않는다
// - 캡처 후 클립보드는 저장한 모든 형식(text/html/rtf/image)을 한 번에 복원한다
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { clipboard } from 'electron'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(() => true),
configSet: vi.fn(),
}))
const modifiers = vi.hoisted(() => ({ released: true }))
vi.mock('../../../src/main/services/modifier-state', () => ({
waitForModifiersReleased: vi.fn(async () => modifiers.released),
}))
const nut = vi.hoisted(() => ({ pressKey: vi.fn(async () => undefined), releaseKey: vi.fn(async () => undefined) }))
vi.mock('@nut-tree-fork/nut-js', () => ({
keyboard: nut,
Key: { LeftControl: 'LeftControl', C: 'C' },
}))
type Module = typeof import('../../../src/main/services/ScreenContextService')
let getScreenContextService: Module['getScreenContextService']
// 공용 electron 목에는 clipboard.clear 가 없다 — 이 테스트에서만 채운다
const clipboardClear = vi.fn()
Object.assign(clipboard, { clear: clipboardClear })
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
modifiers.released = true
getScreenContextService = (await import('../../../src/main/services/ScreenContextService')).getScreenContextService
})
describe('ScreenContextService.captureSelectedText', () => {
it('수정자를 쥐고 있으면 Ctrl+C 를 보내지 않고 클립보드도 비우지 않는다', async () => {
modifiers.released = false
const text = await getScreenContextService().captureSelectedText()
expect(text).toBeNull()
expect(nut.pressKey).not.toHaveBeenCalled()
expect(clipboardClear).not.toHaveBeenCalled()
})
it('수정자를 뗀 뒤에는 복사하고, 모든 형식을 한 번에 복원한다', async () => {
const image = { isEmpty: () => false, toPNG: () => Buffer.alloc(1) }
vi.mocked(clipboard.readText).mockReturnValueOnce('원래 텍스트').mockReturnValueOnce('선택한 문장')
vi.mocked(clipboard.readHTML).mockReturnValueOnce('<b>원래</b>')
vi.mocked(clipboard.readRTF).mockReturnValueOnce('{\\rtf1 원래}')
vi.mocked(clipboard.readImage).mockReturnValueOnce(image as unknown as Electron.NativeImage)
const text = await getScreenContextService().captureSelectedText()
expect(text).toBe('선택한 문장')
expect(nut.pressKey).toHaveBeenCalledTimes(1)
expect(clipboard.write).toHaveBeenCalledWith({
text: '원래 텍스트',
html: '<b>원래</b>',
rtf: '{\\rtf1 원래}',
image,
})
})
})

View file

@ -0,0 +1,171 @@
// tests/main/services/stt-fallback-redteam-r1-3.test.ts
// 클라우드 STT 실패 → 초기화되지 않은 로컬 엔진 폴백이 영원히 끝나지 않던 버그의 회귀 테스트.
// - LocalSTTService.transcribe 는 모델이 없으면 스스로 초기화하고, 초기화가 실패하면 reject 한다
// - 동시에 들어온 두 호출이 서로를 덮어쓰지 않고, 각자의 옵션으로 전사된다
// - STTManager 폴백은 로컬이 실패하면 원래 클라우드 에러로 끝난다(무한 대기 없음)
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getSTTManager, resetSTTManagerForTests } from '../../../src/main/services/stt/STTManager'
import {
getLocalSTTService,
resetLocalSTTServiceForTests,
} from '../../../src/main/services/LocalSTTService'
import type { TranscribeOptions, TranscriptionResult } from '../../../src/main/services/LocalSTTService'
import { initInMemoryConfig, resetInMemoryConfig, configSet } from '../../../src/main/services/ConfigService'
type LocalInternals = {
_modelReady: boolean
_sendToSidecar: (buf: Buffer, options?: TranscribeOptions) => Promise<TranscriptionResult>
}
function internals(): LocalInternals {
return getLocalSTTService() as unknown as LocalInternals
}
/** 일정 시간 안에 끝나지 않으면 실패시킨다 (무한 대기 회귀 감지) */
function settleWithin<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<T>((_resolve, reject) => setTimeout(() => reject(new Error(`did not settle within ${ms}ms`)), ms)),
])
}
const echo = (buf: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult> =>
Promise.resolve({
text: `${buf.length}:${options?.language ?? '-'}:${options?.initialPrompt ?? '-'}`,
segments: [],
language: options?.language ?? 'ko',
duration: 1,
processingTime: 1,
})
describe('LocalSTTService — 준비되지 않은 엔진의 transcribe', () => {
beforeEach(() => {
initInMemoryConfig()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
afterEach(() => {
resetLocalSTTServiceForTests()
resetInMemoryConfig()
vi.restoreAllMocks()
})
it('초기화가 실패하면 대기하지 않고 그 에러로 reject 한다', async () => {
const local = getLocalSTTService()
vi.spyOn(local, 'initialize').mockRejectedValue(
new D3ROError(ErrorCode.STTSidecarSpawnFailed, 'no sidecar'),
)
await expect(settleWithin(local.transcribe(Buffer.alloc(32000)), 1000)).rejects.toMatchObject({
code: ErrorCode.STTSidecarSpawnFailed,
})
})
it('아무도 초기화하지 않았으면 스스로 초기화한 뒤 호출자 옵션으로 전사한다', async () => {
const local = getLocalSTTService()
const init = vi.spyOn(local, 'initialize').mockImplementation(async () => {
internals()._modelReady = true
})
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
const result = await settleWithin(
local.transcribe(Buffer.alloc(100), { language: 'en', initialPrompt: 'hint' }),
1000,
)
expect(init).toHaveBeenCalledTimes(1)
expect(result.text).toBe('100:en:hint')
})
it('동시에 들어온 두 호출은 각자 결과를 받는다 (합쳐지거나 버려지지 않는다)', async () => {
const local = getLocalSTTService()
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
vi.spyOn(local, 'initialize').mockImplementation(async () => {
await gate
internals()._modelReady = true
})
vi.spyOn(internals(), '_sendToSidecar').mockImplementation(echo)
const first = local.transcribe(Buffer.alloc(10), { language: 'ko' })
const second = local.transcribe(Buffer.alloc(20), { language: 'ja' })
release()
const [a, b] = await settleWithin(Promise.all([first, second]), 1000)
expect(a.text).toBe('10:ko:-')
expect(b.text).toBe('20:ja:-')
})
})
describe('STTManager — 클라우드 실패 후 로컬 폴백', () => {
beforeEach(() => {
initInMemoryConfig()
resetSTTManagerForTests()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
resetInMemoryConfig()
resetSTTManagerForTests()
resetLocalSTTServiceForTests()
vi.restoreAllMocks()
})
function failCloud(): void {
const mgr = getSTTManager()
mgr.setProvider('openai')
mgr.setProviderConfig('openai', { apiKey: 'sk-test' })
configSet('sttFallbackToLocal', true)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 503, text: async () => 'offline' }),
)
}
it('로컬 엔진 초기화가 실패하면 원래 클라우드 에러로 끝난다 (무한 대기 없음)', async () => {
failCloud()
vi.spyOn(getLocalSTTService(), 'initialize').mockRejectedValue(
new D3ROError(ErrorCode.STTModelLoadFailed, 'model missing'),
)
const outcome = settleWithin(getSTTManager().transcribe(Buffer.alloc(32000)), 2000)
await expect(outcome).rejects.toBeInstanceOf(Error)
await expect(outcome).rejects.not.toMatchObject({ message: expect.stringContaining('did not settle') })
})
it('로컬 폴백이 끝나지 않으면 상한 시간에 끊는다', async () => {
failCloud()
vi.spyOn(getLocalSTTService(), 'transcribe').mockReturnValue(new Promise<never>(() => undefined))
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { LOCAL_FALLBACK_TIMEOUT_MS } = await import('../../../src/main/services/stt/STTManager')
const pending = getSTTManager().transcribe(Buffer.alloc(32000))
const assertion = expect(pending).rejects.toBeInstanceOf(Error)
await vi.advanceTimersByTimeAsync(LOCAL_FALLBACK_TIMEOUT_MS + 1)
await assertion
})
it('ensureReady 는 로컬 공급자에서만 모델을 올린다', async () => {
const mgr = getSTTManager()
const init = vi.spyOn(getLocalSTTService(), 'initialize').mockResolvedValue(undefined)
mgr.setProvider('openai')
await mgr.ensureReady()
expect(init).not.toHaveBeenCalled()
expect(mgr.supportsPartial()).toBe(false)
expect(mgr.getMissingModel()).toBeNull()
mgr.setProvider('local')
await mgr.ensureReady()
expect(init).toHaveBeenCalledTimes(1)
expect(mgr.supportsPartial()).toBe(true)
})
})

View file

@ -0,0 +1,232 @@
// tests/main/services/update-service-redteam-r1-3.test.ts
// 자동 업데이트 회귀 테스트 (가짜 포트 주입):
// - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영)
// - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다
// - evaluateUpdateOffer 게이팅 순서
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
DEFAULT_UPDATE_POLICY,
evaluateUpdateOffer,
shouldPromptForUpdate,
UPDATE_REPROMPT_INTERVAL_MS,
type UpdatePolicy,
} from '../../../src/main/update-policy'
import type {
ConsentAnswer,
PolicySource,
UpdatePrompter,
UpdaterEventHandlers,
UpdaterPort,
} from '../../../src/main/services/UpdateService'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
const store = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => store.values[key]),
configSet: vi.fn((key: string, value: unknown) => {
store.values[key] = value
}),
configGetAll: vi.fn(() => store.values),
}))
type ServiceModule = typeof import('../../../src/main/services/UpdateService')
let UpdateService: ServiceModule['UpdateService']
function policy(overrides: Partial<UpdatePolicy> = {}): UpdatePolicy {
return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides }
}
function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) {
const answers = [...(opts.answers ?? [])]
const policies = [...(opts.policies ?? [policy()])]
let clock = 1_000_000
let handlers: UpdaterEventHandlers | null = null
const updater: UpdaterPort & { checks: number; downloads: number } = {
checks: 0,
downloads: 0,
checkForUpdates: vi.fn(async () => {
updater.checks++
}),
downloadUpdate: vi.fn(async () => {
updater.downloads++
}),
quitAndInstall: vi.fn(),
setChannel: vi.fn(),
setDifferentialDisabled: vi.fn(),
}
const policySource: PolicySource & { loads: number } = {
loads: 0,
load: vi.fn(async () => {
policySource.loads++
return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0]
}),
}
const consents: string[] = []
const prompter: UpdatePrompter = {
askConsent: vi.fn(async ({ version }) => {
consents.push(version)
return answers.shift() ?? 'later'
}),
askRestart: vi.fn(async () => 'later' as const),
}
const service = new UpdateService({
createUpdater: (h) => {
handlers = h
return updater
},
policySource,
prompter,
currentVersion: () => '1.9.0',
now: () => clock,
})
service.initialize()
service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출
return {
service,
updater,
policySource,
consents,
handlers: () => handlers as UpdaterEventHandlers,
advance: (ms: number) => {
clock += ms
},
}
}
beforeEach(async () => {
vi.resetModules()
store.values = { updateDeviceId: 'device-1' }
UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService
})
describe('UpdateService — 원격 정책 갱신', () => {
it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => {
const h = harness({ policies: [policy(), policy({ killSwitch: true })] })
await h.service.checkForUpdates()
expect(h.updater.checks).toBe(1)
await h.service.checkForUpdates()
expect(h.policySource.loads).toBe(2)
expect(h.updater.checks).toBe(1)
})
it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => {
const h = harness({ policies: [policy({ killSwitch: true })] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual([])
})
})
describe('UpdateService — 다시 묻기', () => {
it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => {
const h = harness({ answers: ['later', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.handleUpdateAvailable({ version: '1.9.2' })
expect(h.consents).toEqual(['1.9.1', '1.9.2'])
})
it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => {
const h = harness({ answers: ['later', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1'])
h.advance(UPDATE_REPROMPT_INTERVAL_MS)
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
})
it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => {
const h = harness({
answers: ['later', 'download'],
policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })],
})
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
expect(h.updater.downloads).toBe(1)
})
it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => {
const h = harness({ answers: ['download', 'later'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.updater.downloads).toBe(1)
h.handlers().onError('network down')
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1', '1.9.1'])
})
it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => {
const h = harness({ answers: ['skip'] })
await h.service.checkForUpdates()
await h.service.handleUpdateAvailable({ version: '1.9.1' })
h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2)
await h.service.handleUpdateAvailable({ version: '1.9.1' })
expect(h.consents).toEqual(['1.9.1'])
expect(store.values.skippedUpdateVersion).toBe('1.9.1')
})
})
describe('evaluateUpdateOffer 게이팅 순서', () => {
const base = {
channel: 'latest' as const,
currentVersion: '1.9.0',
skippedVersion: null,
deviceId: 'device-1',
}
it('킬 스위치가 가장 먼저다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' })
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
})
it('stable 채널은 prerelease 를 거부한다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' })
expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' })
})
it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => {
const offer = evaluateUpdateOffer({
...base,
policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }),
targetVersion: '1.9.1',
skippedVersion: '1.9.1',
})
expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } })
})
it('건너뛴 버전 / rollout 밖은 무시한다', () => {
expect(
evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }),
).toMatchObject({ action: 'ignore', reason: 'skipped' })
expect(
evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }),
).toMatchObject({ action: 'ignore', reason: 'rollout' })
})
it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => {
const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' })
expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true })
})
it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => {
const last = { version: '1.9.1', mandatory: false, at: 0 }
expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false)
expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true)
})
})

View file

@ -0,0 +1,389 @@
// tests/main/services/voice-mode-redteam-r1-3.test.ts
// VoiceModeService 세션 런(SessionRun) 회귀 테스트:
// - 취소된 세션의 늦은 STT 결과가 다음 세션에 삽입되지 않는다
// - 지연 전사 중 두 번째 release 가 받아쓰기를 취소하지 않는다
// - 오디오 캡처 참조는 세션당 정확히 한 번 놓는다 (start 대기 중 취소 포함)
// - 녹음 중 캡처가 죽으면(장치 분리) 에러로 알린다
// - 회의 모드가 소유한 자막은 자막 핫키로 끌 수 없다
// - 표시 계층 이벤트(phase / insert-failed)
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { EventEmitter } from 'events'
import { ErrorCode } from '@d3ro/core/errors'
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
interface Deferred<T> {
promise: Promise<T>
resolve: (value: T) => void
reject: (err: unknown) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (err: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
const result = (text: string): { text: string; segments: never[]; language: string; duration: number; processingTime: number } => ({
text,
segments: [],
language: 'ko',
duration: 1,
processingTime: 1,
})
const mockSTT = vi.hoisted(() => ({
initialize: vi.fn(),
transcribe: vi.fn(),
transcribePartial: vi.fn(async () => ''),
getModels: vi.fn(() => [
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
]),
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => mockSTT,
resetLocalSTTServiceForTests: () => undefined,
}))
const audioBus = new EventEmitter()
const mockAudio = vi.hoisted(() => ({
start: vi.fn(),
stop: vi.fn(),
on: vi.fn(),
off: vi.fn(),
}))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => mockAudio,
}))
const keyBinding = vi.hoisted(() => ({
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
}))
vi.mock('../../../src/main/services/KeyBindingService', () => ({
getKeyBindingService: () => ({
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
keyBinding.handler = fn
},
off: vi.fn(),
}),
}))
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: string) => config.values[key]),
}))
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => mockInsert,
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
processText: vi.fn(async (text: string) => text),
generate: vi.fn(),
chatStream: vi.fn(),
cancelGeneration: vi.fn(),
}),
}))
const caption = vi.hoisted(() => ({
state: 'inactive' as string,
stop: vi.fn(),
start: vi.fn(async () => undefined),
}))
vi.mock('../../../src/main/services/CaptionService', () => ({
getCaptionService: () => ({
getState: () => caption.state,
stop: caption.stop,
start: caption.start,
}),
}))
vi.mock('../../../src/main/services/MeetingModeService', () => ({
getMeetingModeService: () => ({ getState: () => 'recording' }),
}))
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
const SPEECH = Buffer.alloc(16000 * 2) // 1초
async function flush(times = 5): Promise<void> {
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
}
/** Date.now 를 앞으로 당겨 accidental-press(700ms) 판정을 건너뛴다 */
function advanceClock(ms: number): void {
const base = Date.now()
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
}
beforeEach(async () => {
vi.restoreAllMocks()
vi.resetModules()
vi.clearAllMocks()
audioBus.removeAllListeners()
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
caption.state = 'inactive'
keyBinding.handler = null
// once 큐가 테스트 사이로 새지 않게 완전히 초기화한다
for (const fn of [mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels, mockAudio.start, mockAudio.stop, mockInsert.insertText, caption.stop]) {
fn.mockReset()
}
mockSTT.initialize.mockResolvedValue(undefined)
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
mockSTT.transcribePartial.mockResolvedValue('')
mockSTT.getModels.mockReturnValue([
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
])
mockAudio.start.mockResolvedValue(undefined)
mockAudio.stop.mockResolvedValue(undefined)
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.on(ev, fn)
})
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
audioBus.off(ev, fn)
})
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
const mod = await import('../../../src/main/services/VoiceModeService')
mod.resetVoiceModeServiceForTests()
getVoiceModeService = mod.getVoiceModeService
})
describe('세션 런 — 늦게 도착한 결과', () => {
it('취소된 세션 A 의 STT 결과는 새 세션 B 에 삽입되지 않는다', async () => {
const svc = getVoiceModeService()
const sttA = deferred<ReturnType<typeof result>>()
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
const completed: string[] = []
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
// 세션 A: 녹음 → 정지 → 전사 진행 중
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
const stopA = svc.stopSession()
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
// A 취소 → B 시작
svc.cancelSession()
await svc.startSession('dictation')
const sessionB = svc.currentSession
expect(sessionB).not.toBeNull()
// A 의 결과가 늦게 도착
sttA.resolve(result('세션 A 텍스트'))
await stopA
await flush()
expect(completed).toEqual([])
expect(mockInsert.insertText).not.toHaveBeenCalled()
expect(svc.currentSession?.id).toBe(sessionB?.id)
expect(svc.isActive).toBe(true)
})
it('취소된 세션의 늦은 STT 실패는 새 세션을 에러로 끝내지 않는다', async () => {
const svc = getVoiceModeService()
const sttA = deferred<ReturnType<typeof result>>()
mockSTT.transcribe.mockReturnValueOnce(sttA.promise)
const errors: number[] = []
svc.on('error', ({ error }) => errors.push(error.code))
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
const stopA = svc.stopSession()
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
svc.cancelSession()
await svc.startSession('dictation')
sttA.reject(new Error('sidecar exploded'))
await stopA
await flush()
expect(errors).toEqual([])
expect(svc.isActive).toBe(true)
})
})
describe('세션 런 — 정지 멱등성', () => {
it('지연 전사 중 두 번째 정지는 받아쓰기를 취소하지 않는다', async () => {
const init = deferred<void>()
mockSTT.initialize.mockReturnValueOnce(init.promise)
const stt = deferred<ReturnType<typeof result>>()
mockSTT.transcribe.mockReturnValueOnce(stt.promise)
const svc = getVoiceModeService()
const cancelled: string[] = []
const completed: string[] = []
svc.on('session-cancelled', ({ reason }) => cancelled.push(reason))
svc.on('session-completed', ({ finalText }) => completed.push(finalText))
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
await svc.stopSession() // STT 미준비 → 대기
init.resolve() // 모델 로딩 완료 → 지연 전사 시작 (버퍼 비움)
await vi.waitFor(() => expect(mockSTT.transcribe).toHaveBeenCalledTimes(1))
await svc.stopSession() // 두 번째 release
expect(cancelled).toEqual([])
stt.resolve(result('살아남은 받아쓰기'))
await vi.waitFor(() => expect(completed).toEqual(['살아남은 받아쓰기']))
expect(mockInsert.insertText).toHaveBeenCalledWith('살아남은 받아쓰기', undefined)
})
})
describe('세션 런 — 오디오 참조 소유', () => {
it('정지 후 "No speech" 에러가 나도 audio.stop 은 세션당 한 번이다', async () => {
mockSTT.transcribe.mockResolvedValueOnce(result(' '))
const svc = getVoiceModeService()
const errors: number[] = []
svc.on('error', ({ error }) => errors.push(error.code))
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
await svc.stopSession()
await vi.waitFor(() => expect(errors).toEqual([ErrorCode.STTNoAudioData]))
expect(mockAudio.start).toHaveBeenCalledTimes(1)
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
})
it('audio.start 대기 중 취소돼도 늦게 잡힌 참조를 정확히 한 번 놓는다', async () => {
const start = deferred<void>()
mockAudio.start.mockReturnValueOnce(start.promise)
const svc = getVoiceModeService()
const starting = svc.startSession('dictation')
await vi.waitFor(() => expect(mockAudio.start).toHaveBeenCalledTimes(1))
svc.cancelSession()
expect(mockAudio.stop).not.toHaveBeenCalled()
start.resolve()
await starting
await flush()
expect(mockAudio.stop).toHaveBeenCalledTimes(1)
expect(audioBus.listenerCount('audio-data')).toBe(0)
})
it('세션이 끝나면 오디오 핸들러가 모두 해제된다 (다음 세션 프레임 중복 없음)', async () => {
const svc = getVoiceModeService()
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
await svc.stopSession()
await vi.waitFor(() => expect(svc.isActive).toBe(false))
await svc.startSession('dictation')
expect(audioBus.listenerCount('audio-data')).toBe(1)
expect(audioBus.listenerCount('stopped')).toBe(1)
})
})
describe('캡처 손실', () => {
it('오디오 없이 캡처가 죽으면 AudioCaptureFailed 에러를 낸다', async () => {
const svc = getVoiceModeService()
const errors: number[] = []
svc.on('error', ({ error }) => errors.push(error.code))
await svc.startSession('dictation')
audioBus.emit('stopped', { reason: 'device-lost' })
await flush()
expect(errors).toEqual([ErrorCode.AudioCaptureFailed])
expect(svc.isActive).toBe(false)
// 캡처 쪽이 이미 정리했으므로 다른 소비자의 참조를 깎지 않는다
expect(mockAudio.stop).not.toHaveBeenCalled()
})
it("수동 정지('manual')는 캡처 손실로 보지 않는다", async () => {
const svc = getVoiceModeService()
const errors: number[] = []
svc.on('error', ({ error }) => errors.push(error.code))
await svc.startSession('dictation')
audioBus.emit('stopped', { reason: 'manual' })
await flush()
expect(errors).toEqual([])
expect(svc.isActive).toBe(true)
})
})
describe('표시 이벤트', () => {
it("모델 확인을 통과한 뒤에만 'recording' phase 를 내보낸다", async () => {
mockSTT.getModels.mockReturnValueOnce([
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: false, languages: [], accuracy: 2, speed: 4 },
])
const svc = getVoiceModeService()
const phases: string[] = []
const errors: number[] = []
svc.on('phase', ({ phase }) => phases.push(phase))
svc.on('error', ({ error }) => errors.push(error.code))
await svc.startSession('dictation')
expect(phases).toEqual([])
expect(errors).toEqual([ErrorCode.STTModelNotFound])
})
it("녹음 → 정지에서 'recording' 다음 'thinking' phase 가 나간다", async () => {
const svc = getVoiceModeService()
const phases: string[] = []
svc.on('phase', ({ phase }) => phases.push(phase))
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
await svc.stopSession()
expect(phases).toEqual(['recording', 'thinking'])
})
it('자동 삽입이 실패하면 insert-failed 를 내보낸다', async () => {
mockInsert.insertText.mockRejectedValueOnce(new Error('clipboard busy'))
const svc = getVoiceModeService()
const failed: string[] = []
svc.on('insert-failed', ({ text }) => failed.push(text))
await svc.startSession('dictation')
audioBus.emit('audio-data', { buffer: SPEECH })
advanceClock(1000)
await svc.stopSession()
await vi.waitFor(() => expect(failed).toEqual(['기본 전사']))
})
})
describe('자막 핫키', () => {
it('회의 모드가 소유한 자막 세션은 자막 핫키로 멈추지 않는다', async () => {
caption.state = 'active'
caption.stop.mockResolvedValue(false)
const svc = getVoiceModeService()
svc.connectKeyBindings()
expect(keyBinding.handler).not.toBeNull()
keyBinding.handler?.({
actionId: 'caption',
type: 'pressed',
timestamp: Date.now(),
isDoublePress: false,
holdMode: false,
} as KeyBindingTriggerPayload)
// 소유자('user')를 밝혀 요청하므로 CaptionService 가 거부할 수 있다
await vi.waitFor(() => expect(caption.stop).toHaveBeenCalledWith('user'))
expect(caption.start).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,72 @@
// tests/main/shutdown-registry.test.ts
// 종료 등록부 — 역순 정리, 멱등, 멈춘 항목이 종료를 막지 않음.
import { describe, it, expect, vi } from 'vitest'
import { ShutdownRegistry } from '../../src/main/shutdown-registry'
describe('ShutdownRegistry', () => {
it('등록의 역순으로 정리한다', async () => {
const order: string[] = []
const registry = new ShutdownRegistry()
registry.register({ name: 'a', dispose: () => { order.push('a') } })
registry.register({ name: 'b', dispose: async () => { order.push('b') } })
registry.register({ name: 'c', dispose: () => { order.push('c') } })
const report = await registry.disposeAll(1000)
expect(order).toEqual(['c', 'b', 'a'])
expect(report.completed).toEqual(['c', 'b', 'a'])
})
it('여러 번 불러도 한 번만 정리한다', async () => {
const dispose = vi.fn()
const registry = new ShutdownRegistry()
registry.register({ name: 'x', dispose })
await Promise.all([registry.disposeAll(1000), registry.disposeAll(1000)])
await registry.disposeAll(1000)
expect(dispose).toHaveBeenCalledTimes(1)
})
it('실패한 항목은 기록하고 나머지를 계속 정리한다', async () => {
const onError = vi.fn()
const later = vi.fn()
const registry = new ShutdownRegistry({ onError })
registry.register({ name: 'later', dispose: later })
registry.register({ name: 'throws', dispose: () => { throw new Error('sync boom') } })
registry.register({ name: 'rejects', dispose: async () => { throw new Error('async boom') } })
const report = await registry.disposeAll(1000)
expect(report.failed).toEqual(['rejects', 'throws'])
expect(report.completed).toEqual(['later'])
expect(later).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledTimes(2)
})
it('멈춘 항목이 있어도 상한 시간 안에 끝나고, 남은 항목의 dispose 는 걸어 둔다', async () => {
vi.useFakeTimers()
try {
const last = vi.fn()
const registry = new ShutdownRegistry()
registry.register({ name: 'last', dispose: last })
registry.register({ name: 'hangs', dispose: () => new Promise<void>(() => undefined) })
const running = registry.disposeAll(500)
await vi.advanceTimersByTimeAsync(501)
const report = await running
expect(report.timedOut).toEqual(['hangs', 'last'])
// 기다리진 않지만 동기 구간(프로세스 kill 등)은 실행된다
expect(last).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('등록 취소한 항목은 정리하지 않는다', async () => {
const dispose = vi.fn()
const registry = new ShutdownRegistry()
const unregister = registry.register({ name: 'x', dispose })
unregister()
await registry.disposeAll(1000)
expect(dispose).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,162 @@
// tests/main/windows/popup-navigation.test.ts
// 히스토리 팝업을 연 채 명령어 팝업을 열면 Enter 가 히스토리 항목을 삽입하던 버그의 회귀 테스트.
import { describe, it, expect, vi } from 'vitest'
import {
PopupNavigationController,
POPUP_NAV_KEYS,
HISTORY_INSERT_DELAY_MS,
type PopupKind,
type PopupWindowPort,
} from '../../../src/main/windows/popup-navigation'
interface FakeWindow extends PopupWindowPort {
visible: boolean
keys: string[]
}
function fakeWindow(): FakeWindow {
const win: FakeWindow = {
visible: false,
keys: [],
show: () => {
win.visible = true
},
hide: () => {
win.visible = false
},
isVisible: () => win.visible,
sendKey: (key) => {
win.keys.push(key)
},
}
return win
}
/** Electron globalShortcut 처럼 이미 등록된 가속기의 재등록은 false 로 거부한다 */
function fakeShortcuts(): {
registered: Map<string, () => void>
register: (accel: string, cb: () => void) => boolean
unregister: (accel: string) => void
press: (accel: string) => void
} {
const registered = new Map<string, () => void>()
return {
registered,
register: (accel, cb) => {
if (registered.has(accel)) return false
registered.set(accel, cb)
return true
},
unregister: (accel) => {
registered.delete(accel)
},
press: (accel) => registered.get(accel)?.(),
}
}
function setup(): {
controller: PopupNavigationController
windows: Record<PopupKind, FakeWindow>
shortcuts: ReturnType<typeof fakeShortcuts>
inserted: string[]
scheduled: Array<{ fn: () => void; ms: number }>
selections: Array<{ id: string; name: string }>
} {
const windows = { history: fakeWindow(), command: fakeWindow() }
const shortcuts = fakeShortcuts()
const inserted: string[] = []
const scheduled: Array<{ fn: () => void; ms: number }> = []
const selections: Array<{ id: string; name: string }> = []
const controller = new PopupNavigationController({
windows,
shortcuts,
insertText: async (text) => {
inserted.push(text)
},
applyCommandSelection: (sel) => selections.push(sel),
schedule: (fn, ms) => scheduled.push({ fn, ms }),
})
return { controller, windows, shortcuts, inserted, scheduled, selections }
}
describe('PopupNavigationController', () => {
it('히스토리 → 명령어 전환 시 히스토리를 닫고 Enter 는 명령어 팝업으로 간다', () => {
const { controller, windows, shortcuts } = setup()
controller.toggle('history')
controller.toggle('command')
expect(windows.history.visible).toBe(false)
expect(windows.command.visible).toBe(true)
expect(controller.owner).toBe('command')
shortcuts.press('Return')
expect(windows.command.keys).toEqual(['Enter'])
expect(windows.history.keys).toEqual([])
})
it('소유자가 아닌 팝업을 닫아도 소유자의 키를 뺏지 않는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('command')
controller.dismiss('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
shortcuts.press('Down')
expect(windows.command.keys).toEqual(['ArrowDown'])
})
it('소유 팝업을 닫으면 전역 내비게이션 키를 모두 해제한다', () => {
const { controller, shortcuts } = setup()
controller.toggle('history')
expect(shortcuts.registered.size).toBe(POPUP_NAV_KEYS.length)
controller.toggle('history')
expect(shortcuts.registered.size).toBe(0)
expect(controller.owner).toBeNull()
})
it('히스토리 선택은 팝업을 닫고 지연 뒤 삽입한다', async () => {
const { controller, windows, inserted, scheduled, shortcuts } = setup()
controller.show('history')
controller.selectHistoryItem('붙여넣을 텍스트')
expect(windows.history.visible).toBe(false)
expect(shortcuts.registered.size).toBe(0)
expect(scheduled).toHaveLength(1)
expect(scheduled[0].ms).toBe(HISTORY_INSERT_DELAY_MS)
expect(inserted).toEqual([])
scheduled[0].fn()
await Promise.resolve()
expect(inserted).toEqual(['붙여넣을 텍스트'])
})
it('명령어 선택은 팝업을 닫고 선택을 반영한다', () => {
const { controller, windows, selections } = setup()
controller.show('command')
controller.selectCommand({ id: 'cmd-1', name: '번역' })
expect(windows.command.visible).toBe(false)
expect(selections).toEqual([{ id: 'cmd-1', name: '번역' }])
})
it('팝업이 다른 경로로 사라졌으면 다음 키에서 전역 키를 놓는다', () => {
const { controller, windows, shortcuts } = setup()
controller.show('history')
windows.history.visible = false
shortcuts.press('Return')
expect(shortcuts.registered.size).toBe(0)
expect(windows.history.keys).toEqual([])
})
it('등록 실패(다른 앱이 점유)를 경고한다', () => {
const warn = vi.fn()
const windows = { history: fakeWindow(), command: fakeWindow() }
const controller = new PopupNavigationController({
windows,
shortcuts: { register: () => false, unregister: () => undefined },
insertText: async () => undefined,
applyCommandSelection: () => undefined,
warn,
})
controller.show('history')
expect(warn).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,94 @@
// tests/main/windows/recording-tip-presenter.test.ts
// RecordingTip 표시 정책 — 에러 숨김 타이머가 다음 세션의 녹음 팁을 숨기던 버그의 회귀 테스트.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
RecordingTipPresenter,
type RecordingTipView,
} from '../../../src/main/windows/recording-tip-presenter'
function fakeView(): RecordingTipView & { calls: string[] } {
const calls: string[] = []
return {
calls,
showRecording: () => calls.push('show'),
hide: () => calls.push('hide'),
showThinking: () => calls.push('thinking'),
showError: (message) => calls.push(`error:${message}`),
sendAudioLevel: (level) => calls.push(`level:${level}`),
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
showResult: (text, ms) => calls.push(`result:${text}:${ms}`),
}
}
describe('RecordingTipPresenter', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('에러 직후 3초 안에 시작한 새 세션의 팁은 숨겨지지 않는다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('No speech detected')
vi.advanceTimersByTime(1000)
presenter.onSessionStarted()
presenter.onPhase('recording')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:No speech detected', 'show'])
})
it('에러는 3초 뒤 한 번 숨긴다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('boom')
vi.advanceTimersByTime(2999)
expect(view.calls).toEqual(['error:boom'])
vi.advanceTimersByTime(1)
expect(view.calls).toEqual(['error:boom', 'hide'])
})
it("'warning' 은 팁을 건드리지 않는다 (Ollama 미가용 알림 등)", () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onError('Ollama unavailable', 'warning')
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['show'])
})
it('연속 에러는 숨김 예약을 하나로 유지한다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('a')
vi.advanceTimersByTime(2000)
presenter.onError('b')
vi.advanceTimersByTime(2000)
expect(view.calls).toEqual(['error:a', 'error:b'])
vi.advanceTimersByTime(1000)
expect(view.calls).toEqual(['error:a', 'error:b', 'hide'])
})
it('완료·취소는 즉시 숨기고, 삽입 실패는 결과 팝업을 띄운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onPhase('thinking')
presenter.onSessionEnded()
presenter.onInsertFailed('text')
expect(view.calls).toEqual(['show', 'thinking', 'hide', 'result:text:10000'])
})
it('dispose 는 남은 숨김 예약을 지운다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onError('x')
presenter.dispose()
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:x'])
})
})

View file

@ -0,0 +1,226 @@
// tests/red/meeting-mode-redteam-r1-3.test.ts
// MeetingModeService 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
// - 정지 직전 문장(자막 flush 로 확정)이 회의 전사에 남는다
// - 전사 줄은 epoch 이 아니라 회의 시작 기준 [MM:SS] 로 저장된다
// - 회의는 자기 소유 자막 세션을 시작한다 (마이크 고정, 오버레이·히스토리 저장 없음, 전역 설정 불변)
// - 회의 AI 채팅 히스토리는 회의마다 분리된다
// - 긴 전사 다듬기가 잘리면 editedTranscript 를 덮어쓰지 않는다
// - 회의 삭제 시 로컬 녹음 WAV 도 지운다
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
import { eq } from 'drizzle-orm'
import { ErrorCode } from '@d3ro/core/errors'
import type { CaptionSegment } from '@d3ro/core/types'
import { useRedHarness } from './harness'
const caption = vi.hoisted(() => ({
bus: null as EventEmitter | null,
state: 'inactive' as string,
startOptions: [] as unknown[],
stopOwners: [] as unknown[],
flushText: '마지막 문장',
}))
vi.mock('../../src/main/services/CaptionService', async () => {
const { EventEmitter: Emitter } = await import('events')
const bus = new Emitter()
caption.bus = bus
const service = {
getState: () => caption.state,
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
start: async (opts: unknown) => {
caption.startOptions.push(opts)
caption.state = 'active'
},
stop: async (owner?: unknown) => {
caption.stopOwners.push(owner)
if (caption.state !== 'active') return null
// CaptionService.stop 은 말하던 문장을 flush 해 'segment' 로 확정한 뒤 끝난다
const segment: CaptionSegment = {
id: 'seg-last',
text: caption.flushText,
timestamp: Date.now() + 10_000,
isFinal: true,
}
bus.emit('segment', segment)
caption.state = 'inactive'
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
},
}
return {
getCaptionService: () => service,
resetCaptionServiceForTests: () => undefined,
}
})
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
calculateRMS: () => 0,
}))
const llm = vi.hoisted(() => ({
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
generate: null as null | ((text: string, options?: { maxTokens?: number }) => Promise<{ text: string }>),
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: (text: string, options?: { maxTokens?: number }) =>
llm.generate ? llm.generate(text, options) : Promise.resolve({ text }),
cancelGeneration: vi.fn(),
}),
}
})
useRedHarness()
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { configGet, configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
function insertSession(id: string, rawTranscript: string): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({
id,
title: id,
status: 'completed',
startedAt: now,
endedAt: now + 1000,
durationMs: 1000,
rawTranscript,
createdAt: now,
updatedAt: now,
})
.run()
}
beforeEach(() => {
caption.state = 'inactive'
caption.startOptions = []
caption.stopOwners = []
llm.chatCalls = []
llm.generate = null
})
describe('회의 녹음 정지', () => {
it('정지 직전 문장을 잃지 않고, 전사는 회의 시작 기준 시각으로 저장된다', async () => {
configSet('captionAudioSource', 'system')
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
expect(caption.startOptions).toEqual([
{ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false },
])
// 전역 설정은 건드리지 않는다
expect(configGet('captionAudioSource')).toBe('system')
const startedAt = Date.now()
caption.bus?.emit('segment', {
id: 'seg-1',
text: '첫 문장',
timestamp: startedAt + 5_000,
isFinal: true,
} satisfies CaptionSegment)
await meeting.stopRecording()
expect(caption.stopOwners).toEqual(['meeting'])
await vi.waitFor(() => {
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
expect(row?.rawTranscript).toBeTruthy()
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
const lines = (row?.rawTranscript ?? '').split('\n')
expect(lines).toHaveLength(2)
expect(lines[0]).toMatch(/^\[00:0[45]\] 첫 문장$/)
expect(lines[1]).toMatch(/^\[\d{2}:\d{2}\] 마지막 문장$/)
// epoch 이 새지 않는다 (예: [29841999:45])
expect(row?.rawTranscript).not.toMatch(/\[\d{4,}:/)
})
})
describe('회의 AI 채팅', () => {
it('회의 A 의 질의응답이 회의 B 의 대화로 새지 않는다', async () => {
insertSession('meeting-a', '[00:01] A 회의 내용')
insertSession('meeting-b', '[00:01] B 회의 내용')
const meeting = getMeetingModeService()
await meeting.chatWithMeeting('meeting-a', 'A 의 결정은?')
await meeting.chatWithMeeting('meeting-b', 'B 의 결정은?')
const bMessages = llm.chatCalls[1]
expect(bMessages.some((m) => m.content.includes('A 의 결정은?'))).toBe(false)
expect(bMessages.filter((m) => m.role !== 'system')).toEqual([{ role: 'user', content: 'B 의 결정은?' }])
expect(meeting.getMeetingChatHistory('meeting-a')).toHaveLength(2)
expect(meeting.getMeetingChatHistory('meeting-b')).toHaveLength(2)
})
})
describe('Auto Polish', () => {
it('LLM 결과가 잘리면 editedTranscript 를 덮어쓰지 않고 실패한다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => {
const mm = String(Math.floor(i / 60)).padStart(2, '0')
const ss = String(i % 60).padStart(2, '0')
return `[${mm}:${ss}] 이것은 회의 발언 ${i}번째 문장입니다`
})
insertSession('long-meeting', lines.join('\n'))
// 출력 한도에 걸린 것처럼 입력의 앞 절반만 돌려준다
llm.generate = async (text) => {
const kept = text.split('\n')
return { text: kept.slice(0, Math.ceil(kept.length / 2)).join('\n') }
}
await expect(getMeetingModeService().polishTranscript('long-meeting')).rejects.toMatchObject({
code: ErrorCode.MeetingPolishFailed,
})
const row = getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, 'long-meeting')).get()
expect(row?.editedTranscript ?? null).toBeNull()
})
it('긴 전사는 줄 경계 조각으로 나눠 다듬고 이어 붙인다', async () => {
const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i}`)
insertSession('chunked', lines.join('\n'))
const maxTokens: Array<number | undefined> = []
llm.generate = async (text, options) => {
maxTokens.push(options?.maxTokens)
return { text }
}
const polished = await getMeetingModeService().polishTranscript('chunked')
expect(maxTokens.length).toBeGreaterThan(1)
expect(maxTokens.every((n) => n !== undefined && n <= 4096)).toBe(true)
expect(polished.split('\n')).toEqual(lines)
})
})
describe('회의 삭제', () => {
it('로컬 녹음 WAV 도 지운다', async () => {
insertSession('with-audio', '[00:01] x')
const audioDir = path.join(app.getPath('userData'), 'meeting-audio')
fs.mkdirSync(audioDir, { recursive: true })
const wav = path.join(audioDir, 'with-audio.wav')
fs.writeFileSync(wav, Buffer.alloc(64))
getMeetingModeService().deleteSession('with-audio')
await vi.waitFor(() => expect(fs.existsSync(wav)).toBe(false))
})
})

View file

@ -0,0 +1,32 @@
// tests/red/meeting-summary-redteam-r1-3.test.ts
// 자막 세션 자동 요약이 자막 세션 id 로 히스토리를 찾아 항상 실패하던 버그의 회귀 테스트.
import { describe, it, expect, vi } from 'vitest'
import { useRedHarness, historyInput } from './harness'
useRedHarness()
import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService'
import { getHistoryService } from '../../src/main/services/HistoryService'
describe('MeetingSummaryService.onCaptionSessionSaved', () => {
it('자막 세션 요약에 실린 historyId 로 히스토리 행을 찾아 요약한다', async () => {
const text = '오늘 회의에서는 다음 분기 출시 일정과 담당자를 정했습니다. '.repeat(3)
const entry = getHistoryService().create(historyInput({ originalText: text, mode: 'caption' }))
const service = getMeetingSummaryService()
const summarize = vi.spyOn(service, 'summarize').mockResolvedValue(
undefined as unknown as Awaited<ReturnType<typeof service.summarize>>,
)
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: entry.id })
expect(summarize).toHaveBeenCalledWith(entry.id)
})
it('저장되지 않은 세션(historyId 없음)은 조용히 건너뛴다', async () => {
const service = getMeetingSummaryService()
const summarize = vi.spyOn(service, 'summarize')
await service.onCaptionSessionSaved({ sessionId: 'caption-session-uuid', historyId: null })
expect(summarize).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,99 @@
// 회의 내보내기 마크다운 렌더러(core) 회귀 테스트.
// 예전 정규식 변환은 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라졌고,
// 한 줄짜리 줄들이 한 문단으로 붙었으며, 표가 파이프 그대로 찍혔다.
import { describe, expect, it } from 'vitest'
import { Paragraph, Table } from 'docx'
import {
escapeHtml,
markdownToSimpleHtml,
parseMarkdownBlocks,
renderMeetingPdfHtml,
} from '../src/utils/meeting-markdown'
import { blocksToDocxElements, markdownToDocx } from '../src/utils/markdown-to-docx'
describe('parseMarkdownBlocks', () => {
it('제목·불릿·체크박스(두 상태)·표·문단·구분선을 나눈다', () => {
const md = [
'## 결정',
'- 타입을 List<String> 으로 변경',
'- [ ] 문서화',
'- [x] 배포',
'',
'담당: <TBD>',
'줄1',
'---',
'| 시간 | 내용 |',
'|---|---|',
'| 00:01 | 시작 |',
].join('\n')
expect(parseMarkdownBlocks(md)).toEqual([
{ type: 'heading', level: 2, text: '결정' },
{ type: 'bullet', text: '타입을 List<String> 으로 변경' },
{ type: 'checkbox', checked: false, text: '문서화' },
{ type: 'checkbox', checked: true, text: '배포' },
{ type: 'paragraph', lines: ['담당: <TBD>', '줄1'] },
{ type: 'rule' },
{ type: 'table', header: ['시간', '내용'], rows: [['00:01', '시작']] },
])
})
it('빈 입력은 빈 목록', () => {
expect(parseMarkdownBlocks('')).toEqual([])
expect(parseMarkdownBlocks(null)).toEqual([])
})
})
describe('markdownToSimpleHtml', () => {
it('꺾쇠·앰퍼샌드·따옴표를 이스케이프해 PDF 에서 사라지지 않게 한다', () => {
const html = markdownToSimpleHtml('- List<String> & "q" <script>alert(1)</script>')
expect(html).toContain('List&lt;String&gt; &amp; &quot;q&quot; &lt;script&gt;')
expect(html).not.toContain('<script>')
})
it('연속된 한 줄짜리 줄은 <br> 로 보존하고, 불릿은 <ul> 로 감싼다', () => {
expect(markdownToSimpleHtml('줄1\n줄2')).toBe('<p>줄1<br>줄2</p>')
expect(markdownToSimpleHtml('- a\n- [x] b')).toBe('<ul>\n<li>a</li>\n<li>☑ b</li>\n</ul>')
})
it('파이프 표를 <table> 로 그린다', () => {
const html = markdownToSimpleHtml('| 시간 | 내용 |\n|------|------|\n| 00:01 | <시작> |')
expect(html).toBe(
'<table><thead><tr><th>시간</th><th>내용</th></tr></thead><tbody><tr><td>00:01</td><td>&lt;시작&gt;</td></tr></tbody></table>',
)
})
it('**굵게** 는 이스케이프 뒤 <strong> 으로 바꾼다', () => {
expect(markdownToSimpleHtml('**중요** <b>')).toBe('<p><strong>중요</strong> &lt;b&gt;</p>')
})
})
describe('renderMeetingPdfHtml', () => {
it('제목과 메타를 이스케이프하고 본문이 없으면 안내 문구를 쓴다', () => {
const html = renderMeetingPdfHtml({
title: '<img src=x onerror=alert(1)>',
meta: '일시: A & B',
markdown: null,
emptyMessage: '회의록이 생성되지 않았습니다.',
})
expect(html).toContain(`<h1>${escapeHtml('<img src=x onerror=alert(1)>')}</h1>`)
expect(html).not.toContain('<img')
expect(html).toContain('<p class="meta">일시: A &amp; B</p>')
expect(html).toContain('<p>회의록이 생성되지 않았습니다.</p>')
})
})
describe('markdown-to-docx', () => {
it('블록을 문단·표로 매핑한다 (문단 줄마다 하나)', () => {
const elements = blocksToDocxElements(
parseMarkdownBlocks('# 제목\n- 항목\n- [x] 완료\n줄1\n줄2\n\n| a | b |\n|---|---|\n| 1 | 2 |'),
)
expect(elements.map((e) => (e instanceof Table ? 'table' : e instanceof Paragraph ? 'p' : '?'))).toEqual([
'p', 'p', 'p', 'p', 'p', 'table',
])
})
it('DOCX 버퍼를 만든다', async () => {
const buffer = await markdownToDocx('## 결정\n- A', '회의록')
expect(buffer.subarray(0, 2).toString('latin1')).toBe('PK')
})
})

View file

@ -0,0 +1,99 @@
// 회의 전사 조립·화자 라벨·조각 나누기 (core meeting-transcript) 회귀 테스트.
// 예전엔 세그먼트의 epoch 시각을 그대로 써 `[29841999:45]` 가 저장됐고,
// 화자 구분을 다시 돌리면 라벨이 쌓였다.
import { describe, expect, it } from 'vitest'
import {
applySpeakerLabels,
buildTranscriptFromSegments,
chunkTranscriptByLines,
formatTranscriptLines,
looksTruncatedRewrite,
parseTranscriptLines,
} from '../src/meeting-transcript'
const START = 1_790_000_000_000 // epoch ms
describe('buildTranscriptFromSegments', () => {
it('epoch 시각을 회의 시작 기준 상대 시각으로 바꾼다', () => {
const text = buildTranscriptFromSegments(
[
{ text: '시작합니다', timestamp: START + 5_000 },
{ text: ' 다음 안건 ', timestamp: START + 65_000 },
],
START,
)
expect(text).toBe('[00:05] 시작합니다\n[01:05] 다음 안건')
// core 파서가 다시 읽을 수 있어야 한다 (동기화·상세 화면·화자 구분이 이 형식을 쓴다)
expect(parseTranscriptLines(text).map((l) => l.timestampMs)).toEqual([5_000, 65_000])
})
it('60분을 넘는 회의도 파싱 가능한 형식으로 왕복한다', () => {
const text = buildTranscriptFromSegments([{ text: '마무리', timestamp: START + 125 * 60_000 + 7_000 }], START)
expect(text).toBe('[125:07] 마무리')
const lines = parseTranscriptLines(text)
expect(lines).toEqual([{ timestampMs: (125 * 60 + 7) * 1000, timed: true, speaker: null, text: '마무리' }])
expect(formatTranscriptLines(lines)).toBe(text)
})
it('시작 전 시각은 0 으로 자르고 빈 세그먼트는 버린다', () => {
const text = buildTranscriptFromSegments(
[
{ text: '이전', timestamp: START - 3_000 },
{ text: ' ', timestamp: START + 1_000 },
],
START,
)
expect(text).toBe('[00:00] 이전')
})
})
describe('applySpeakerLabels', () => {
const diar = [
{ speaker: 'SPEAKER_00', start: 0, end: 10 },
{ speaker: 'SPEAKER_01', start: 10.5, end: 30 },
]
const label = (raw: string): string => raw.replace('SPEAKER_', '화자 ')
it('시각이 속한 구간의 화자로 라벨을 붙인다 (초 단위 구간)', () => {
const lines = parseTranscriptLines('[00:05] 안녕하세요\n[00:20] 반갑습니다\n[00:45] 혼잣말')
const labeled = applySpeakerLabels(lines, diar, label)
expect(formatTranscriptLines(labeled)).toBe('[00:05] [화자 00] 안녕하세요\n[00:20] [화자 01] 반갑습니다\n[00:45] 혼잣말')
})
it('이미 라벨이 있는 줄은 덧붙이지 않고 교체한다 (다시 돌려도 쌓이지 않음)', () => {
const lines = parseTranscriptLines('[00:05] [화자 1] 안녕하세요')
const once = applySpeakerLabels(lines, diar, label)
const twice = applySpeakerLabels(parseTranscriptLines(formatTranscriptLines(once)), diar, label)
expect(formatTranscriptLines(twice)).toBe('[00:05] [화자 00] 안녕하세요')
})
it('시각 없는 줄은 그대로 둔다', () => {
const lines = parseTranscriptLines('메모 없는 줄')
expect(applySpeakerLabels(lines, diar, label)).toEqual(lines)
})
it('parse→format 은 빈 줄을 버리고 공백을 정리한다', () => {
expect(formatTranscriptLines(parseTranscriptLines('[00:01] 가\n\n\n[00:02] 나 '))).toBe('[00:01] 가\n[00:02] 나')
})
})
describe('chunkTranscriptByLines / looksTruncatedRewrite', () => {
it('줄 경계에서만 나누고 빈 줄은 버린다', () => {
const text = ['[00:01] aaaa', '', '[00:02] bbbb', '[00:03] cccc'].join('\n')
expect(chunkTranscriptByLines(text, 26)).toEqual(['[00:01] aaaa\n[00:02] bbbb', '[00:03] cccc'])
})
it('한도보다 긴 한 줄은 그 줄 하나로 한 조각이 된다', () => {
const long = `[00:01] ${'x'.repeat(50)}`
expect(chunkTranscriptByLines(`${long}\n[00:02] y`, 20)).toEqual([long, '[00:02] y'])
})
it('시각 줄이 크게 줄었거나 비었으면 잘린 것으로 본다', () => {
const input = Array.from({ length: 10 }, (_, i) => `[00:0${i}] 문장 ${i}`).join('\n')
expect(looksTruncatedRewrite(input, input)).toBe(false)
expect(looksTruncatedRewrite(input, input.split('\n').slice(0, 5).join('\n'))).toBe(true)
expect(looksTruncatedRewrite(input, ' ')).toBe(true)
expect(looksTruncatedRewrite('시각 없는 긴 문장입니다', '시각 없는 긴 문장')).toBe(false)
expect(looksTruncatedRewrite('시각 없는 아주 긴 문장입니다 정말로', '시각')).toBe(true)
})
})

View file

@ -52,3 +52,110 @@ export function formatTranscriptLines(lines: readonly TranscriptLine[]): string
}) })
.join('\n') .join('\n')
} }
/** 캡처 세그먼트(절대 epoch 타임스탬프)를 회의 시작 기준 전사 줄로 조립한다. */
export interface TimedSegmentInput {
text: string
/** 절대 시각(epoch ms). CaptionService 가 Date.now() 로 찍는다. */
timestamp: number
}
/**
* 회의 세그먼트로 전사 텍스트를 만든다.
*
* 세그먼트 시각은 epoch 이므로 반드시 회의 시작 시각을 빼서 상대 시각으로 바꾼다
* (예전엔 epoch 을 그대로 써 `[29841999:45]` 가 저장되어 파싱·화자 구분·동기화가 모두 깨졌다).
* 시작 전 시각은 0 으로 자르고, 빈 텍스트 세그먼트는 버린다.
*/
export function buildTranscriptFromSegments(
segments: readonly TimedSegmentInput[],
sessionStartedAt: number,
): string {
const lines: TranscriptLine[] = []
for (const segment of segments) {
const text = segment.text.trim()
if (!text) continue
lines.push({
timestampMs: Math.max(0, segment.timestamp - sessionStartedAt),
timed: true,
speaker: null,
text,
})
}
return formatTranscriptLines(lines)
}
/** 화자 구분 결과 구간. start/end 는 초 단위(pyannote 응답 형식). */
export interface SpeakerSegment {
speaker: string
start: number
end: number
}
/**
* 전사 줄에 화자 구분 결과를 적용한다. 시각이 있는 줄의 시각이 속한 구간의 화자로
* speaker 필드를 **교체**한다(앞에 덧붙이지 않으므로 다시 돌려도 라벨이 쌓이지 않는다).
* 구간에 속하지 않거나 시각이 없는 줄은 그대로 둔다.
*
* @param labelFor 원시 화자 id(예: SPEAKER_01)를 표시 라벨로 바꾸는 함수 — 표시 문구는 호출자 소관
*/
export function applySpeakerLabels(
lines: readonly TranscriptLine[],
segments: readonly SpeakerSegment[],
labelFor: (rawSpeaker: string) => string = (raw) => raw,
): TranscriptLine[] {
return lines.map((line) => {
if (!line.timed) return line
const seconds = line.timestampMs / 1000
const match = segments.find((s) => seconds >= s.start && seconds <= s.end)
if (!match) return line
return { ...line, speaker: labelFor(match.speaker) }
})
}
/**
* 전사를 줄 경계에서 끊어 LLM 한 번에 보낼 크기의 조각들로 나눈다.
* 한 줄이 maxChars 보다 길면 그 줄 하나로 한 조각을 만든다(줄 중간은 자르지 않는다).
* 빈 줄은 버린다.
*/
export function chunkTranscriptByLines(text: string | null | undefined, maxChars: number): string[] {
if (!text) return []
const chunks: string[] = []
let current: string[] = []
let size = 0
for (const raw of text.split('\n')) {
const line = raw.trimEnd()
if (!line.trim()) continue
if (current.length > 0 && size + line.length + 1 > maxChars) {
chunks.push(current.join('\n'))
current = []
size = 0
}
current.push(line)
size += line.length + 1
}
if (current.length > 0) chunks.push(current.join('\n'))
return chunks
}
const TIMED_LINE = /^\s*\[\d{1,3}:\d{2}\]/
function countTimedLines(text: string): number {
return text.split('\n').filter((l) => TIMED_LINE.test(l)).length
}
/**
* LLM 이 전사 조각을 다시 쓴 결과가 잘렸는지(출력 토큰 한도 등) 추정한다.
* - 입력에 시각 줄이 있으면: 출력의 시각 줄이 입력의 80% 미만이면 잘린 것으로 본다.
* - 없으면: 출력 길이가 입력의 50% 미만이면 잘린 것으로 본다.
* 잘린 결과로 전사를 덮어쓰면 회의 뒷부분이 문서·채팅·동기화에서 조용히 사라진다.
*/
export function looksTruncatedRewrite(input: string, output: string): boolean {
const trimmed = output.trim()
if (!trimmed) return true
const inputTimed = countTimedLines(input)
if (inputTimed > 0) {
return countTimedLines(trimmed) < Math.ceil(inputTimed * 0.8)
}
return trimmed.length < input.trim().length * 0.5
}

View file

@ -1,31 +1,90 @@
// src/main/utils/markdown-to-docx.ts // packages/core/src/utils/markdown-to-docx.ts
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티 // Phase 14.5: 마크다운 → DOCX 변환 유틸리티.
// 마크다운 해석은 meeting-markdown 의 parseMarkdownBlocks 가 단일 정본이다 —
// 여기서는 블록을 docx 요소로 렌더링만 한다(docx 의존은 이 서브패스에만 격리).
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx' import {
Document,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
HeadingLevel,
WidthType,
} from 'docx'
import {
CHECKBOX_CHECKED,
CHECKBOX_UNCHECKED,
parseMarkdownBlocks,
type MarkdownBlock,
} from './meeting-markdown'
const HEADING_LEVELS = {
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
} as const
function tableCell(text: string, bold: boolean): TableCell {
return new TableCell({
children: [new Paragraph({ children: [new TextRun({ text, bold })] })],
})
}
/** 블록을 docx 문서 요소(문단/표)로 바꾼다. 순수 매핑이라 개수·종류로 검증할 수 있다. */
export function blocksToDocxElements(blocks: readonly MarkdownBlock[]): Array<Paragraph | Table> {
const elements: Array<Paragraph | Table> = []
for (const block of blocks) {
switch (block.type) {
case 'heading':
elements.push(new Paragraph({ text: block.text, heading: HEADING_LEVELS[block.level] }))
break
case 'bullet':
elements.push(new Paragraph({ text: block.text, bullet: { level: 0 } }))
break
case 'checkbox':
elements.push(
new Paragraph({
text: `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} ${block.text}`,
bullet: { level: 0 },
}),
)
break
case 'paragraph':
// 줄마다 문단 하나 — 한 줄짜리 항목이 한 문단으로 합쳐지지 않게 한다
for (const line of block.lines) {
elements.push(new Paragraph({ children: [new TextRun(line)] }))
}
break
case 'table': {
const rows: TableRow[] = []
if (block.header.length > 0) {
rows.push(new TableRow({ children: block.header.map((c) => tableCell(c, true)), tableHeader: true }))
}
for (const row of block.rows) {
rows.push(new TableRow({ children: row.map((c) => tableCell(c, false)) }))
}
if (rows.length > 0) {
elements.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } }))
}
break
}
case 'rule':
// 구분선은 문서 흐름상 빈 문단으로 둔다
elements.push(new Paragraph({ text: '' }))
break
}
}
return elements
}
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> { export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
const paragraphs: Paragraph[] = [] const children: Array<Paragraph | Table> = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
// 타이틀 추가 ...blocksToDocxElements(parseMarkdownBlocks(markdown)),
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE })) ]
const doc = new Document({ sections: [{ children }] })
// 줄 단위 파싱
for (const line of markdown.split('\n')) {
if (line.startsWith('## ')) {
paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
} else if (line.startsWith('### ')) {
paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
} else if (line.startsWith('# ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
} else if (line.startsWith('- [ ] ')) {
paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } }))
} else if (line.startsWith('- ') || line.startsWith('* ')) {
paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
} else if (line.trim()) {
paragraphs.push(new Paragraph({ children: [new TextRun(line)] }))
}
}
const doc = new Document({ sections: [{ children: paragraphs }] })
return Buffer.from(await Packer.toBuffer(doc)) return Buffer.from(await Packer.toBuffer(doc))
} }

View file

@ -1,7 +1,12 @@
// packages/core/src/utils/meeting-markdown.ts // packages/core/src/utils/meeting-markdown.ts
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티 // Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티.
//
// 회의 내보내기(PDF/DOCX)가 쓰는 마크다운 해석의 단일 정본이다. 예전엔 정규식 체인
// (markdownToSimpleHtml), DOCX 줄 스위치, 쓰이지 않던 parseMinutes 가 제각각 해석했고,
// HTML 이스케이프가 없어 `List<String>`·`<TBD>` 가 PDF에서 사라지고 줄바꿈·표가 깨졌다.
// 이제 parseMarkdownBlocks 한 곳에서 블록으로 나누고, HTML/DOCX 는 블록을 렌더링만 한다.
import type { MeetingMinutes, MeetingSessionDetail } from '../types' import type { MeetingSessionDetail } from '../types'
export function formatTime(ms: number): string { export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000) const totalSeconds = Math.floor(ms / 1000)
@ -24,55 +29,6 @@ export function formatDateRange(startedAt: number, endedAt: number | null): stri
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}` return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
} }
export function parseMinutes(markdown: string): MeetingMinutes {
const minutes: MeetingMinutes = {
summary: '',
decisions: [],
actionItems: [],
timeline: [],
}
const sections = markdown.split(/^## /m)
for (const section of sections) {
const lines = section.trim().split('\n')
const heading = lines[0]?.toLowerCase() ?? ''
const body = lines.slice(1).join('\n').trim()
if (heading.includes('요약') || heading.includes('summary')) {
minutes.summary = body
} else if (heading.includes('결정') || heading.includes('decision')) {
minutes.decisions = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => l.replace(/^[-*]\s*/, '').trim())
} else if (heading.includes('할 일') || heading.includes('action')) {
minutes.actionItems = body
.split('\n')
.filter((l) => l.startsWith('-') || l.startsWith('*'))
.map((l) => {
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
return { task: text }
})
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
const tableRows = body
.split('\n')
.filter((l) => l.includes('|') && !l.includes('---'))
for (const row of tableRows) {
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
if (cells.length >= 2) {
minutes.timeline.push({
time: cells[0],
content: cells[1],
type: cells[1].includes('📝') ? 'memo' : 'transcript',
})
}
}
}
}
return minutes
}
export function buildExportMarkdown(session: MeetingSessionDetail): string { export function buildExportMarkdown(session: MeetingSessionDetail): string {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const memoTable = session.memos.length > 0 const memoTable = session.memos.length > 0
@ -102,15 +58,228 @@ ${session.rawTranscript ?? '(없음)'}
` `
} }
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */ // ============================================================
export function markdownToSimpleHtml(md: string): string { // 블록 파서 (순수)
return md // ============================================================
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>') export type MarkdownBlock =
.replace(/^# (.+)$/gm, '<h1>$1</h1>') | { type: 'heading'; level: 1 | 2 | 3; text: string }
.replace(/^- \[.\] (.+)$/gm, '<li>$1</li>') | { type: 'bullet'; text: string }
.replace(/^- (.+)$/gm, '<li>$1</li>') | { type: 'checkbox'; checked: boolean; text: string }
.replace(/^\* (.+)$/gm, '<li>$1</li>') | { type: 'table'; header: string[]; rows: string[][] }
.replace(/\n{2,}/g, '</p><p>') | { type: 'paragraph'; lines: string[] }
.replace(/^(?!<[h|l|t|p])/gm, '') | { type: 'rule' }
const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/
const CHECKBOX = /^\s*[-*+]\s+\[([ xX])\]\s+(.*)$/
const BULLET = /^\s*[-*+]\s+(.*)$/
const RULE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/
const TABLE_ROW = /^\s*\|.*\|?\s*$/
const TABLE_SEPARATOR = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)*\|?\s*$/
function splitTableRow(line: string): string[] {
let body = line.trim()
if (body.startsWith('|')) body = body.slice(1)
if (body.endsWith('|')) body = body.slice(0, -1)
return body.split('|').map((cell) => cell.trim())
}
/** 마크다운을 블록 목록으로 나눈다 (제목, 불릿, 체크박스, 표, 문단, 구분선). */
export function parseMarkdownBlocks(markdown: string | null | undefined): MarkdownBlock[] {
if (!markdown) return []
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
const blocks: MarkdownBlock[] = []
let paragraph: string[] = []
const flushParagraph = (): void => {
if (paragraph.length > 0) {
blocks.push({ type: 'paragraph', lines: paragraph })
paragraph = []
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (!line.trim()) {
flushParagraph()
continue
}
if (TABLE_ROW.test(line) && line.trim().startsWith('|')) {
flushParagraph()
const tableLines: string[] = []
while (i < lines.length && lines[i].trim().startsWith('|')) {
tableLines.push(lines[i])
i++
}
i--
const hasHeader = tableLines.length >= 2 && TABLE_SEPARATOR.test(tableLines[1])
const rows = tableLines.filter((l) => !TABLE_SEPARATOR.test(l)).map(splitTableRow)
if (hasHeader) {
const [header, ...body] = rows
blocks.push({ type: 'table', header, rows: body })
} else {
blocks.push({ type: 'table', header: [], rows })
}
continue
}
if (RULE.test(line)) {
flushParagraph()
blocks.push({ type: 'rule' })
continue
}
const heading = HEADING.exec(line)
if (heading) {
flushParagraph()
const level = Math.min(3, heading[1].length) as 1 | 2 | 3
blocks.push({ type: 'heading', level, text: heading[2] })
continue
}
const checkbox = CHECKBOX.exec(line)
if (checkbox) {
flushParagraph()
blocks.push({ type: 'checkbox', checked: checkbox[1] !== ' ', text: checkbox[2].trim() })
continue
}
const bullet = BULLET.exec(line)
if (bullet) {
flushParagraph()
blocks.push({ type: 'bullet', text: bullet[1].trim() })
continue
}
paragraph.push(line.trim())
}
flushParagraph()
return blocks
}
// ============================================================
// HTML 렌더링 (PDF 생성용)
// ============================================================
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
export function escapeHtml(text: string): string {
return text.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch)
}
/** 이스케이프 후 인라인 강조(**굵게**)만 태그로 바꾼다. */
function renderInline(text: string): string {
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
}
export const CHECKBOX_UNCHECKED = '☐'
export const CHECKBOX_CHECKED = '☑'
export function renderBlocksToHtml(blocks: readonly MarkdownBlock[]): string {
const out: string[] = []
let listOpen = false
const closeList = (): void => {
if (listOpen) {
out.push('</ul>')
listOpen = false
}
}
for (const block of blocks) {
if (block.type === 'bullet' || block.type === 'checkbox') {
if (!listOpen) {
out.push('<ul>')
listOpen = true
}
const marker = block.type === 'checkbox'
? `${block.checked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED} `
: ''
out.push(`<li>${marker}${renderInline(block.text)}</li>`)
continue
}
closeList()
switch (block.type) {
case 'heading':
out.push(`<h${block.level}>${renderInline(block.text)}</h${block.level}>`)
break
case 'paragraph':
out.push(`<p>${block.lines.map(renderInline).join('<br>')}</p>`)
break
case 'rule':
out.push('<hr>')
break
case 'table': {
const head = block.header.length > 0
? `<thead><tr>${block.header.map((c) => `<th>${renderInline(c)}</th>`).join('')}</tr></thead>`
: ''
const body = block.rows
.map((row) => `<tr>${row.map((c) => `<td>${renderInline(c)}</td>`).join('')}</tr>`)
.join('')
out.push(`<table>${head}<tbody>${body}</tbody></table>`)
break
}
}
}
closeList()
return out.join('\n')
}
/** 마크다운→HTML 변환 (PDF 생성용). 모든 텍스트를 이스케이프한다. */
export function markdownToSimpleHtml(md: string): string {
return renderBlocksToHtml(parseMarkdownBlocks(md))
}
export interface MeetingPdfHtmlInput {
title: string
/** 제목 아래 한 줄(일시·소요 등). 호출자가 번역된 라벨로 만든다. */
meta?: string | null
markdown: string | null | undefined
/** 본문이 비었을 때 보여 줄 문구 (호출자가 번역해 넘긴다) */
emptyMessage?: string
/** html lang 속성 */
lang?: string
}
/**
* 회의 PDF 문서 템플릿(단일 정본). Electron 은 이 HTML 을 불러와 인쇄만 한다.
* 제목·메타·본문 모두 이스케이프된다.
*/
export function renderMeetingPdfHtml(input: MeetingPdfHtmlInput): string {
const bodyHtml = input.markdown && input.markdown.trim()
? markdownToSimpleHtml(input.markdown)
: input.emptyMessage
? `<p>${escapeHtml(input.emptyMessage)}</p>`
: ''
const meta = input.meta ? `\n <p class="meta">${escapeHtml(input.meta)}</p>` : ''
return `<!DOCTYPE html>
<html lang="${escapeHtml(input.lang ?? 'ko')}">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Malgun Gothic', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #3b82f6; padding-bottom: 8px; }
h2 { font-size: 16px; color: #444; margin-top: 24px; }
h3 { font-size: 14px; color: #444; margin-top: 16px; }
p { line-height: 1.6; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
th { background: #f5f5f5; font-weight: 600; }
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
</style>
</head>
<body>
<h1>${escapeHtml(input.title)}</h1>${meta}
${bodyHtml}
</body>
</html>`
} }