feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -43,6 +43,8 @@ import type {
const logger = getLogger('MeetingModeService')
let isShowingMeetingSaveDialog = false
interface MeetingModeServiceEvents {
'state-changed': (state: MeetingModeState) => void
'segment': (segment: CaptionSegment) => void
@ -93,13 +95,55 @@ class MeetingModeService extends EventEmitter {
return this._meetingModeActive
}
/** 활성 세션, 자막, 오디오 캡처 상태를 강제로 완전 초기화 */
async forceReset(): Promise<void> {
logger.warn('MeetingModeService: forceReset 실행')
if (this._audioLevelTimer) {
clearInterval(this._audioLevelTimer)
this._audioLevelTimer = null
}
if (this._audioDataHandler) {
try {
const { getAudioCaptureService } = await import('./AudioCaptureService')
getAudioCaptureService().off('audio-data', this._audioDataHandler)
} catch { /* noop */ }
this._audioDataHandler = null
}
try {
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
}
await captionService.stop().catch(() => {})
} catch { /* noop */ }
try {
const { getAudioCaptureService } = await import('./AudioCaptureService')
await getAudioCaptureService().stop().catch(() => {})
} catch { /* noop */ }
this._meetingModeActive = false
this._sessionId = null
this._sessionStartedAt = null
this._segments = []
this._memos = []
this._setState('idle')
this._sendStateToRenderer()
}
// ── 녹음 시작 ──
async startRecording(): Promise<MeetingStartResult> {
async startRecording(options?: { force?: boolean }): Promise<MeetingStartResult> {
if (options?.force) {
await this.forceReset()
}
if (this._state !== 'idle') {
throw new D3ROError(
ErrorCode.MeetingAlreadyRecording,
`회의 모드가 이미 활성 상태입니다: ${this._state}`,
`회의 모드가 이미 실행 중입니다 (${this._state}). 이전 회의를 종료하거나 강제 초기화 후 다시 시도해주세요.`,
)
}
@ -108,10 +152,14 @@ class MeetingModeService extends EventEmitter {
const captionService = getCaptionService()
const captionState = captionService.getState()
if (captionState !== 'inactive') {
throw new D3ROError(
ErrorCode.MeetingAlreadyRecording,
'자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.',
)
if (options?.force) {
await captionService.stop().catch(() => {})
} else {
throw new D3ROError(
ErrorCode.MeetingAlreadyRecording,
'실시간 자막 모드가 아직 실행 중입니다. 자막을 먼저 종료하거나 강제 초기화 후 시작해주세요.',
)
}
}
this._setState('recording')
@ -569,6 +617,39 @@ class MeetingModeService extends EventEmitter {
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
}
/**
* 회의 문서/폴리시/채팅/화자추정 LLM.
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
*/
private async _resolveMeetingLlm(): Promise<{
generate: (
text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
) => Promise<{ text: string; model?: string }>
chatStream: (
messages: Array<{ role: string; content: string }>,
options?: { temperature?: number },
) => AsyncGenerator<string, string>
}> {
const backend = configGet('llmBackend')
if (backend === 'online') {
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const premium = getPremiumLLMService()
if (premium.isAvailable()) {
return premium
}
logger.warn('Premium LLM unavailable for meeting — falling back to local')
} catch (err) {
logger.warn(
`Premium LLM init failed for meeting: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
const { getLocalLLMService } = await import('./LocalLLMService')
return getLocalLLMService()
}
// ── Phase 14.5: 전사 수정 ──
updateTranscript(sessionId: string, editedTranscript: string): void {
@ -626,8 +707,7 @@ class MeetingModeService extends EventEmitter {
sendProgress(10)
const { getLocalLLMService } = await import('./LocalLLMService')
const llmService = getLocalLLMService()
const llmService = await this._resolveMeetingLlm()
sendProgress(30)
@ -759,11 +839,26 @@ class MeetingModeService extends EventEmitter {
break
}
const { filePath } = await dialog.showSaveDialog({
title: '문서 내보내기',
defaultPath: defaultName,
filters,
})
if (isShowingMeetingSaveDialog) {
return ''
}
isShowingMeetingSaveDialog = true
let filePath: string | undefined
try {
const { getMainWindow } = await import('../windows/WindowManager')
const mainWindow = getMainWindow()
const dialogOptions = {
title: '문서 내보내기',
defaultPath: defaultName,
filters,
}
const res = mainWindow
? await dialog.showSaveDialog(mainWindow, dialogOptions)
: await dialog.showSaveDialog(dialogOptions)
filePath = res.filePath
} finally {
isShowingMeetingSaveDialog = false
}
if (!filePath) return ''
switch (format) {
@ -780,14 +875,12 @@ class MeetingModeService extends EventEmitter {
const html = `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta charset="UTF-8">
<style>
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; 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; }
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>
@ -916,8 +1009,7 @@ class MeetingModeService extends EventEmitter {
const transcript = row.rawTranscript
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
const { getLocalLLMService } = await import('./LocalLLMService')
const llm = getLocalLLMService()
const llm = await this._resolveMeetingLlm()
const result = await llm.generate(transcript, {
systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
temperature: 0.3,
@ -950,8 +1042,7 @@ ${transcript}`
this._chatHistory.push({ role: 'user', content: userMessage })
const { getLocalLLMService } = await import('./LocalLLMService')
const llm = getLocalLLMService()
const llm = await this._resolveMeetingLlm()
const messages = [
{ role: 'system' as const, content: systemPrompt },
@ -1102,8 +1193,7 @@ ${transcript}`
): Promise<void> {
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 })
const { getLocalLLMService } = await import('./LocalLLMService')
const llm = getLocalLLMService()
const llm = await this._resolveMeetingLlm()
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
const speakerHint = numSpeakers && numSpeakers > 0
@ -1171,3 +1261,8 @@ export function getMeetingModeService(): MeetingModeService {
}
return instance
}
export function resetMeetingModeServiceForTests(): void {
if (instance) instance.removeAllListeners()
instance = null
}