feat(V2-1b): packages/core 추출 — 공유 타입/에러/채널/유틸 분리

packages/core (@d3ro/core) 신규 생성:
- types.ts, errors.ts, ipc-channels.ts, constants.ts (shared에서 이동)
- utils/meeting-markdown.ts, utils/markdown-to-docx.ts (main/utils에서 이동)
- subpath exports 정의 (./types, ./errors, ./ipc-channels, ./constants,
  ./utils/meeting-markdown, ./utils/markdown-to-docx)
- src/index.ts barrel export 추가
- docx를 core 자체 dependency로 선언

apps/desktop 연결:
- package.json에 @d3ro/core: '*' dep 추가
- tsconfig.node/web.json paths에 @d3ro/core/* 추가
- electron.vite.config.ts 3개 섹션 alias 추가 (main/preload/renderer)
- externalizeDepsPlugin exclude에 @d3ro/core (workspace 소스 번들 대상)
- vitest.config.ts alias 추가

일괄 치환 (79 파일, 167건):
- @shared/{types,errors,ipc-channels,constants} → @d3ro/core/*
- static/dynamic import + type expression import 모두 포함
- MeetingModeService.ts의 ../utils/* 상대 경로 → @d3ro/core/utils/*
- @shared/theme-vars는 V2-1c 범위로 남김 (WindowManager만 사용)

M1 수정 포함:
- electron.vite.config.ts의 resolve('src/shared') → resolve(__dirname, ...)
  CWD 독립적으로 동작하도록 견고화

검증:
- typecheck 통과
- build 통과 (main+preload+renderer)
- dev 런타임 → DB/핫키/Ollama 모두 정상, 기존 데이터 연속성 유지
This commit is contained in:
yunchan8804 2026-04-08 14:39:46 +09:00
parent a4cb4c0805
commit 3b0eb3393b
95 changed files with 315 additions and 212 deletions

View file

@ -0,0 +1,45 @@
{
"name": "@d3ro/core",
"version": "1.0.0",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
},
"./errors": {
"types": "./src/errors.ts",
"default": "./src/errors.ts"
},
"./ipc-channels": {
"types": "./src/ipc-channels.ts",
"default": "./src/ipc-channels.ts"
},
"./constants": {
"types": "./src/constants.ts",
"default": "./src/constants.ts"
},
"./utils/meeting-markdown": {
"types": "./src/utils/meeting-markdown.ts",
"default": "./src/utils/meeting-markdown.ts"
},
"./utils/markdown-to-docx": {
"types": "./src/utils/markdown-to-docx.ts",
"default": "./src/utils/markdown-to-docx.ts"
}
},
"dependencies": {
"docx": "^9.6.1"
},
"devDependencies": {
"@types/node": "^22.13.0"
}
}

View file

@ -0,0 +1,58 @@
// src/shared/constants.ts
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
export const TIMING = {
/** 더블프레스 감지 간격 (ms) */
DOUBLE_PRESS_DURATION: 300,
/** 최소 녹음 시간 (ms) — 이하 자동 취소 */
MIN_AUDIO_DURATION: 700,
/** 녹음 후 STT 대기 시간 (ms) */
POST_RECORDING_WAIT: 4000,
/** 녹음 후 STT 대기 (버퍼 있을 때) (ms) */
POST_RECORDING_WAIT_BUFFERED: 6000,
/** STT 아이들 타임아웃 (ms) */
STT_IDLE_TIMEOUT: 30000,
/** 절대 최대 대기 시간 (ms) */
ABSOLUTE_MAX_WAIT: 120000,
/** 오디오 레벨 전송 간격 (ms) */
AUDIO_LEVEL_INTERVAL: 100,
/** 서비스 종료 타임아웃 (ms) */
SERVICE_DESTROY_TIMEOUT: 3000,
/** 사이드카 헬스체크 지연 (ms) */
SIDECAR_HEALTH_DELAY: 3000,
/** LLM 요청 타임아웃 (ms) */
LLM_REQUEST_TIMEOUT: 30000
} as const
/** 웨이브 바 상수 (RecordingTip) */
export const WAVE_BAR = {
COUNT: 9,
ANIMATION_INTERVAL: 100,
/** 코사인 분포 가중치 (Speakly 패턴) */
COS_WEIGHTS: Array.from({ length: 9 }, (_, i) => Math.cos((i - 4) * (Math.PI / 9)))
} as const
/** 오디오 포맷 */
export const AUDIO_FORMAT = {
SAMPLE_RATE: 16000,
CHANNELS: 1,
BIT_DEPTH: 16,
BYTES_PER_SAMPLE: 2
} as const
/** 윈도우 크기 */
export const WINDOW_SIZE = {
MAIN: { width: 1104, height: 816 },
RECORDING_TIP: { width: 280, height: 80 },
RESULT_POPUP: { width: 400, height: 200 }
} as const

256
packages/core/src/errors.ts Normal file
View file

@ -0,0 +1,256 @@
// src/shared/errors.ts
export enum ErrorCode {
// === Success ===
Success = 0,
// === STT (100-199) ===
STTEngineNotInstalled = 100,
STTModelNotFound = 101,
STTModelNotLoaded = 102,
STTModelLoadFailed = 103,
STTModelDownloadFailed = 104,
STTModelDownloadCancelled = 105,
STTTranscriptionFailed = 110,
STTTranscriptionTimeout = 111,
STTTranscriptionCancelled = 112,
STTNoAudioData = 113,
STTAudioTooShort = 114,
STTLanguageNotSupported = 120,
STTSidecarSpawnFailed = 130,
STTSidecarCrashed = 131,
STTSidecarCommunicationFailed = 132,
STTGPUNotAvailable = 140,
// === TTS (200-299) ===
TTSEngineNotInstalled = 200,
TTSVoiceNotFound = 201,
TTSVoiceNotLoaded = 202,
TTSVoiceLoadFailed = 203,
TTSVoiceDownloadFailed = 204,
TTSSynthesisFailed = 210,
TTSPlaybackFailed = 211,
TTSPlaybackInterrupted = 212,
TTSTextTooLong = 220,
TTSTextEmpty = 221,
// === LLM / Ollama (300-399) ===
LLMServerUnreachable = 300,
LLMServerConnectionFailed = 301,
LLMServerTimeout = 302,
LLMModelNotFound = 310,
LLMModelNotLoaded = 311,
LLMModelLoadFailed = 312,
LLMModelPullFailed = 313,
LLMModelPullCancelled = 314,
LLMProcessingFailed = 320,
LLMProcessingTimeout = 321,
LLMProcessingCancelled = 322,
LLMResponseParseFailed = 323,
LLMInvalidAction = 330,
LLMPromptTooLong = 331,
// === Audio (400-499) ===
AudioDeviceNotFound = 400,
AudioDeviceAccessDenied = 401,
AudioDeviceBusy = 402,
AudioCaptureStartFailed = 410,
AudioCaptureStopFailed = 411,
AudioCaptureFailed = 412,
AudioNoPermission = 420,
AudioStreamError = 430,
AudioBufferOverflow = 431,
// === Hotkey (500-599) ===
HotkeyRegistrationFailed = 500,
HotkeyConflict = 501,
HotkeySystemReserved = 502,
HotkeyHookInitFailed = 510,
HotkeyHookCrashed = 511,
// === TextInsert (600-699) ===
TextInsertFailed = 600,
TextInsertClipboardSaveFailed = 601,
TextInsertClipboardRestoreFailed = 602,
TextInsertKeySimulationFailed = 603,
TextInsertNoActiveWindow = 610,
TextInsertTargetAppNotResponding = 611,
// === History / Dictionary / DB (700-799) ===
DBOpenFailed = 700,
DBMigrationFailed = 701,
DBQueryFailed = 702,
DBWriteFailed = 703,
HistoryNotFound = 710,
HistoryExportFailed = 711,
DictionaryNotFound = 720,
DictionaryDuplicate = 721,
DictionaryImportFailed = 722,
DictionaryExportFailed = 723,
DictionaryImportInvalidFormat = 724,
// === Phase 10: Memo Tags (730-739) ===
MemoTagDuplicate = 730,
MemoTagNotFound = 731,
MemoExportFailed = 732,
// === Phase 10: Voice Commands (740-749) ===
VoiceCommandMatchFailed = 740,
VoiceCommandNotFound = 741,
// === Phase 10: Screen Context (750-759) ===
ContextCaptureFailed = 750,
ContextSelectedTextFailed = 751,
// === Phase 10: LLM Chain (760-769) ===
ChainNotFound = 760,
ChainExecutionFailed = 761,
ChainStepFailed = 762,
ChainCancelled = 763,
// === Phase 10: Live Caption (770-779) ===
CaptionStartFailed = 770,
CaptionAlreadyActive = 771,
CaptionSTTFailed = 772,
// === Phase 12: File Transcription (780-784) ===
FileTranscriptionFFmpegFailed = 780,
FileTranscriptionInvalidFormat = 781,
FileTranscriptionChunkFailed = 782,
FileTranscriptionCancelled = 783,
FileTranscriptionFileTooLarge = 784,
// === Phase 12: Meeting Summary (785-789) ===
MeetingSummaryGenerationFailed = 785,
MeetingSummaryNoTranscript = 786,
MeetingSummaryExportFailed = 787,
// === Phase 12: Dictation Template (790-794) ===
TemplateNotFound = 790,
TemplateSessionAlreadyActive = 791,
TemplateSessionNotActive = 792,
TemplateFieldRecordingFailed = 793,
TemplateInvalidFormat = 794,
// === Phase 13: Voice Conversation (795-799) ===
ConversationSessionAlreadyActive = 795,
ConversationNoActiveSession = 796,
ConversationTTSFailed = 797,
ConversationLLMFailed = 798,
// === Phase 13: Local RAG (870-874) ===
RAGDocumentNotFound = 870,
RAGIndexingFailed = 871,
RAGEmbeddingFailed = 872,
RAGQueryFailed = 873,
RAGUnsupportedFormat = 874,
// === Phase 13: Voice Action (875-879) ===
VoiceActionPlanFailed = 875,
VoiceActionExecutionFailed = 876,
VoiceActionBlocked = 877,
VoiceActionInvalidPlan = 878,
// === Phase 14: Meeting Mode (880-889) ===
MeetingAlreadyRecording = 880,
MeetingNotRecording = 881,
MeetingProcessingFailed = 882,
MeetingSessionNotFound = 883,
MeetingExportFailed = 884,
MeetingPdfFailed = 885,
MeetingDocumentNotFound = 886,
MeetingDocGenerationFailed = 887,
MeetingDocExportFailed = 888,
MeetingTranscriptUpdateFailed = 889,
MeetingDocTemplateNotFound = 890,
MeetingDocTemplateBuiltinDelete = 891,
MeetingDocxExportFailed = 892,
MeetingPolishFailed = 893,
MeetingChatFailed = 894,
DiarizationFailed = 895,
DiarizationModelNotLoaded = 896,
DiarizationTokenRequired = 897,
// === Config (800-849) ===
ConfigReadFailed = 800,
ConfigWriteFailed = 801,
ConfigInvalidValue = 802,
ConfigKeyNotFound = 803,
ConfigResetFailed = 804,
ConfigMigrationFailed = 810,
// === License (850-869) ===
LicenseKeyInvalid = 850,
LicenseKeyExpired = 851,
LicenseActivationFailed = 852,
LicenseDeactivationFailed = 853,
LicenseMachineIdMismatch = 854,
LicenseOfflineGraceExpired = 855,
LicenseVerificationFailed = 856,
FeatureNotAvailable = 860,
QuotaExceeded = 861,
TierRequired = 862,
// === System / Window (900-999) ===
WindowCreationFailed = 900,
WindowNotFound = 901,
TrayCreationFailed = 910,
NotificationFailed = 920,
PermissionDenied = 930,
ExternalOpenFailed = 940,
SoundPlayFailed = 950,
AppAlreadyRunning = 960,
UnknownError = 999
}
/**
* D3RO-VOICE (Speakly NXError )
*/
export class D3ROError extends Error {
readonly code: ErrorCode
readonly details?: Record<string, unknown>
constructor(code: ErrorCode, message: string, details?: Record<string, unknown>) {
super(message)
this.name = 'D3ROError'
this.code = code
this.details = details
}
toJSON(): D3ROErrorJSON {
return {
code: this.code,
message: this.message,
details: this.details
}
}
static fromJSON(json: D3ROErrorJSON): D3ROError {
return new D3ROError(json.code, json.message, json.details)
}
}
export interface D3ROErrorJSON {
code: ErrorCode
message: string
details?: Record<string, unknown>
}
/**
* IPC .
*/
export type IPCResult<T> =
| { success: true; data: T }
| { success: false; error: D3ROErrorJSON }
export function ipcSuccess<T>(data: T): IPCResult<T> {
return { success: true, data }
}
export function ipcError<T>(
code: ErrorCode,
message: string,
details?: Record<string, unknown>
): IPCResult<T> {
return { success: false, error: { code, message, details } }
}

View file

@ -0,0 +1,8 @@
// packages/core — barrel export
// 개별 sub-path import 권장: '@d3ro/core/types', '@d3ro/core/errors' 등
// 본 파일은 편의를 위한 통합 re-export
export * from './types'
export * from './errors'
export * from './ipc-channels'
export * from './constants'

View file

@ -0,0 +1,381 @@
// src/shared/ipc-channels.ts
// IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다.
export const IPC_CHANNELS = {
VOICE: {
START_RECORDING: 'voice:startRecording',
STOP_RECORDING: 'voice:stopRecording',
CANCEL_RECORDING: 'voice:cancelRecording',
GET_STATE: 'voice:getState',
SET_MODE: 'voice:setMode',
GET_MODE: 'voice:getMode',
// Main → Renderer events
STATE_CHANGED: 'voice:stateChanged',
TRANSCRIPTION_DELTA: 'voice:transcriptionDelta',
TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete',
ERROR: 'voice:error',
AUDIO_LEVEL: 'voice:audioLevel'
},
AUDIO: {
GET_DEVICES: 'audio:getDevices',
GET_SELECTED_DEVICE: 'audio:getSelectedDevice',
SET_SELECTED_DEVICE: 'audio:setSelectedDevice',
TEST_DEVICE: 'audio:testDevice',
// Main → Renderer events
DEVICE_CHANGED: 'audio:deviceChanged'
},
STT: {
GET_STATUS: 'stt:getStatus',
GET_MODELS: 'stt:getModels',
GET_ACTIVE_MODEL: 'stt:getActiveModel',
SET_MODEL: 'stt:setModel',
DOWNLOAD_MODEL: 'stt:downloadModel',
CANCEL_DOWNLOAD: 'stt:cancelDownload',
GET_LANGUAGE: 'stt:getLanguage',
SET_LANGUAGE: 'stt:setLanguage',
// Main → Renderer events
STATUS_CHANGED: 'stt:statusChanged',
DOWNLOAD_PROGRESS: 'stt:downloadProgress'
},
TTS: {
SPEAK: 'tts:speak',
STOP: 'tts:stop',
GET_VOICES: 'tts:getVoices',
GET_ACTIVE_VOICE: 'tts:getActiveVoice',
SET_VOICE: 'tts:setVoice',
GET_STATUS: 'tts:getStatus',
DOWNLOAD_VOICE: 'tts:downloadVoice',
// Main → Renderer events
STATUS_CHANGED: 'tts:statusChanged',
SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged'
},
LLM: {
GET_STATUS: 'llm:getStatus',
GET_MODELS: 'llm:getModels',
GET_ACTIVE_MODEL: 'llm:getActiveModel',
SET_MODEL: 'llm:setModel',
PROCESS: 'llm:process',
CANCEL_PROCESS: 'llm:cancelProcess',
GET_SERVER_URL: 'llm:getServerUrl',
SET_SERVER_URL: 'llm:setServerUrl',
PULL_MODEL: 'llm:pullModel',
// Main → Renderer events
STATUS_CHANGED: 'llm:statusChanged',
PROCESS_PROGRESS: 'llm:processProgress',
PULL_PROGRESS: 'llm:pullProgress'
},
HOTKEY: {
GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut',
SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut',
GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut',
SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut',
GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut',
SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut',
GET_CAPTION_SHORTCUT: 'hotkey:getCaptionShortcut',
SET_CAPTION_SHORTCUT: 'hotkey:setCaptionShortcut',
IS_ENABLED: 'hotkey:isEnabled',
SET_ENABLED: 'hotkey:setEnabled',
START_RECORDING: 'hotkey:startRecording',
STOP_RECORDING: 'hotkey:stopRecording',
// Main → Renderer events
TRIGGERED: 'hotkey:triggered',
RECORDING_RESULT: 'hotkey:recordingResult'
},
CONFIG: {
GET: 'config:get',
SET: 'config:set',
GET_ALL: 'config:getAll',
RESET: 'config:reset',
GET_THEME: 'config:getTheme',
SET_THEME: 'config:setTheme',
GET_LANGUAGE: 'config:getLanguage',
SET_LANGUAGE: 'config:setLanguage',
GET_AUTO_LAUNCH: 'config:getAutoLaunch',
SET_AUTO_LAUNCH: 'config:setAutoLaunch',
GET_CLOSE_TO_TRAY: 'config:getCloseToTray',
SET_CLOSE_TO_TRAY: 'config:setCloseToTray',
// Main → Renderer events
CHANGED: 'config:changed'
},
HISTORY: {
GET_ALL: 'history:getAll',
GET_BY_ID: 'history:getById',
DELETE: 'history:delete',
DELETE_ALL: 'history:deleteAll',
SEARCH: 'history:search',
EXPORT: 'history:export',
// Main → Renderer events
ADDED: 'history:added'
},
DICTIONARY: {
GET_ALL: 'dictionary:getAll',
ADD: 'dictionary:add',
UPDATE: 'dictionary:update',
DELETE: 'dictionary:delete',
IMPORT: 'dictionary:import',
EXPORT: 'dictionary:export',
SEARCH: 'dictionary:search'
},
WINDOW: {
MINIMIZE: 'window:minimize',
MAXIMIZE: 'window:maximize',
CLOSE: 'window:close',
IS_MAXIMIZED: 'window:isMaximized',
SHOW_RECORDING_TIP: 'window:showRecordingTip',
HIDE_RECORDING_TIP: 'window:hideRecordingTip',
SHOW_RESULT_POPUP: 'window:showResultPopup',
HIDE_RESULT_POPUP: 'window:hideResultPopup',
TIP_MEASURED: 'window:tipMeasured',
// Main → Renderer events
TIP_STATE_CHANGED: 'window:tipStateChanged',
TIP_PREPARE: 'window:tipPrepare',
TIP_SHOW: 'window:tipShow'
},
SYSTEM: {
GET_PLATFORM: 'system:getPlatform',
GET_VERSION: 'system:getVersion',
CHECK_MIC_PERMISSION: 'system:checkMicPermission',
REQUEST_MIC_PERMISSION: 'system:requestMicPermission',
SHOW_NOTIFICATION: 'system:showNotification',
OPEN_EXTERNAL: 'system:openExternal',
GET_ACTIVE_APP: 'system:getActiveApp',
INSERT_TEXT: 'system:insertText',
PLAY_SOUND: 'system:playSound',
SET_SOUND_ENABLED: 'system:setSoundEnabled',
IS_SOUND_ENABLED: 'system:isSoundEnabled'
},
STATS: {
GET_SUMMARY: 'stats:getSummary',
GET_DAILY: 'stats:getDaily',
GET_WEEKLY: 'stats:getWeekly',
// Main → Renderer events
UPDATED: 'stats:updated'
},
// ── Phase 10: Memo Tags (10.3) ──
MEMO: {
GET_TAGS: 'memo:getTags',
ADD_TAG: 'memo:addTag',
REMOVE_TAG: 'memo:removeTag',
GET_ALL_TAGS: 'memo:getAllTags',
SEARCH_BY_TAG: 'memo:searchByTag',
EXPORT: 'memo:export',
},
// ── Phase 10: Voice Commands (10.5) ──
VOICE_COMMAND: {
GET_ALL: 'voiceCommand:getAll',
SET_KEYWORDS: 'voiceCommand:setKeywords',
SET_ENABLED: 'voiceCommand:setEnabled',
IS_ENABLED: 'voiceCommand:isEnabled',
// Main → Renderer events
MATCHED: 'voiceCommand:matched',
},
// ── Phase 10: Screen Context (10.2) ──
CONTEXT: {
CAPTURE: 'context:capture',
GET_CONFIG: 'context:getConfig',
SET_ENABLED: 'context:setEnabled',
IS_ENABLED: 'context:isEnabled',
},
// ── Phase 10: LLM Chain (10.4) ──
CHAIN: {
GET_ALL: 'chain:getAll',
CREATE: 'chain:create',
UPDATE: 'chain:update',
DELETE: 'chain:delete',
EXECUTE: 'chain:execute',
// Main → Renderer events
PROGRESS: 'chain:progress',
},
// ── Phase 10: Live Caption (10.1) ──
CAPTION: {
START: 'caption:start',
STOP: 'caption:stop',
GET_STATE: 'caption:getState',
SET_CONFIG: 'caption:setConfig',
GET_CONFIG: 'caption:getConfig',
/** 렌더러 → 메인: 시스템 오디오 PCM 데이터 전달 */
SYSTEM_AUDIO_DATA: 'caption:systemAudioData',
// Main → Renderer events
SEGMENT: 'caption:segment',
DELTA: 'caption:delta',
STATE_CHANGED: 'caption:stateChanged',
SESSION_SAVED: 'caption:sessionSaved',
/** 메인 → 렌더러: 시스템 오디오 캡처 시작/정지 요청 */
START_SYSTEM_AUDIO: 'caption:startSystemAudio',
STOP_SYSTEM_AUDIO: 'caption:stopSystemAudio',
},
// ── Phase 12: File Transcription (12.1) ──
FILE_TRANSCRIPTION: {
START: 'fileTranscription:start',
CANCEL: 'fileTranscription:cancel',
GET_STATE: 'fileTranscription:getState',
// Main → Renderer events
PROGRESS: 'fileTranscription:progress',
COMPLETE: 'fileTranscription:complete',
ERROR: 'fileTranscription:error',
},
// ── Phase 12: Meeting Summary (12.2) ──
MEETING_SUMMARY: {
SUMMARIZE: 'meetingSummary:summarize',
GET_SUMMARY: 'meetingSummary:getSummary',
EXPORT_MARKDOWN: 'meetingSummary:exportMarkdown',
// Main → Renderer events
SUMMARY_READY: 'meetingSummary:summaryReady',
SUMMARY_PROGRESS: 'meetingSummary:progress',
},
// ── Phase 12: Dictation Templates (12.3) ──
DICTATION_TEMPLATE: {
GET_ALL: 'dictationTemplate:getAll',
CREATE: 'dictationTemplate:create',
UPDATE: 'dictationTemplate:update',
DELETE: 'dictationTemplate:delete',
START_SESSION: 'dictationTemplate:startSession',
CANCEL_SESSION: 'dictationTemplate:cancelSession',
GET_SESSION_STATE: 'dictationTemplate:getSessionState',
SET_FIELD_VALUE: 'dictationTemplate:setFieldValue',
// Main → Renderer events
SESSION_STATE_CHANGED: 'dictationTemplate:sessionStateChanged',
FIELD_COMPLETED: 'dictationTemplate:fieldCompleted',
SESSION_COMPLETED: 'dictationTemplate:sessionCompleted',
},
// ── Phase 13: Voice Conversation (13.1) ──
VOICE_CONVERSATION: {
START_SESSION: 'voiceConversation:startSession',
STOP_SESSION: 'voiceConversation:stopSession',
SEND_MESSAGE: 'voiceConversation:sendMessage',
GET_STATE: 'voiceConversation:getState',
GET_HISTORY: 'voiceConversation:getHistory',
CLEAR_HISTORY: 'voiceConversation:clearHistory',
CANCEL_RESPONSE: 'voiceConversation:cancelResponse',
// Main → Renderer events
STATE_CHANGED: 'voiceConversation:stateChanged',
USER_MESSAGE: 'voiceConversation:userMessage',
ASSISTANT_DELTA: 'voiceConversation:assistantDelta',
ASSISTANT_MESSAGE: 'voiceConversation:assistantMessage',
TTS_STARTED: 'voiceConversation:ttsStarted',
TTS_FINISHED: 'voiceConversation:ttsFinished',
ERROR: 'voiceConversation:error',
},
// ── Phase 13: Local RAG (13.2) ──
RAG: {
ADD_DOCUMENT: 'rag:addDocument',
REMOVE_DOCUMENT: 'rag:removeDocument',
GET_DOCUMENTS: 'rag:getDocuments',
QUERY: 'rag:query',
GET_STATE: 'rag:getState',
REINDEX: 'rag:reindex',
// Main → Renderer events
INDEX_PROGRESS: 'rag:indexProgress',
INDEX_COMPLETE: 'rag:indexComplete',
QUERY_RESULT: 'rag:queryResult',
},
// ── Phase 13: Voice Action / OS Automation (13.3) ──
VOICE_ACTION: {
EXECUTE: 'voiceAction:execute',
GET_PRESETS: 'voiceAction:getPresets',
GET_HISTORY: 'voiceAction:getHistory',
CLEAR_HISTORY: 'voiceAction:clearHistory',
SET_ENABLED: 'voiceAction:setEnabled',
IS_ENABLED: 'voiceAction:isEnabled',
// Main → Renderer events
ACTION_PLANNED: 'voiceAction:actionPlanned',
ACTION_EXECUTED: 'voiceAction:actionExecuted',
ACTION_ERROR: 'voiceAction:actionError',
},
// ── Phase 14: Meeting Mode ──
MEETING_MODE: {
START_RECORDING: 'meetingMode:startRecording',
STOP_RECORDING: 'meetingMode:stopRecording',
ADD_MEMO: 'meetingMode:addMemo',
GET_STATE: 'meetingMode:getState',
GET_SESSIONS: 'meetingMode:getSessions',
GET_SESSION: 'meetingMode:getSession',
DELETE_SESSION: 'meetingMode:deleteSession',
UPDATE_TITLE: 'meetingMode:updateTitle',
EXPORT_PDF: 'meetingMode:exportPdf',
EXPORT_MARKDOWN: 'meetingMode:exportMarkdown',
// Phase 14.5: 문서 생성/편집/내보내기
UPDATE_TRANSCRIPT: 'meetingMode:updateTranscript',
GENERATE_DOCUMENT: 'meetingMode:generateDocument',
GET_DOCUMENTS: 'meetingMode:getDocuments',
UPDATE_DOCUMENT: 'meetingMode:updateDocument',
DELETE_DOCUMENT: 'meetingMode:deleteDocument',
EXPORT_DOCUMENT: 'meetingMode:exportDocument',
EXPORT_TRANSCRIPT: 'meetingMode:exportTranscript',
EDIT_SEGMENT: 'meetingMode:editSegment',
// Main → Renderer events
STATE_CHANGED: 'meetingMode:stateChanged',
SEGMENT: 'meetingMode:segment',
PROCESSING_PROGRESS: 'meetingMode:processingProgress',
SESSION_COMPLETED: 'meetingMode:sessionCompleted',
DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress',
POLISH_TRANSCRIPT: 'meetingMode:polishTranscript',
DIARIZE: 'meetingMode:diarize',
DIARIZATION_PROGRESS: 'meetingMode:diarizationProgress',
ERROR: 'meetingMode:error',
},
// ── Phase 14.5: Meeting Document Templates ──
MEETING_DOC_TEMPLATE: {
GET_ALL: 'meetingDocTemplate:getAll',
CREATE: 'meetingDocTemplate:create',
UPDATE: 'meetingDocTemplate:update',
DELETE: 'meetingDocTemplate:delete',
},
// ── Phase 15: Meeting AI Chat ──
MEETING_CHAT: {
SEND: 'meetingChat:send',
CANCEL: 'meetingChat:cancel',
CLEAR: 'meetingChat:clear',
DELTA: 'meetingChat:delta',
MESSAGE: 'meetingChat:message',
ERROR: 'meetingChat:error',
},
// ── Phase 11: License & Monetization ──
LICENSE: {
GET_INFO: 'license:getInfo',
ACTIVATE: 'license:activate',
DEACTIVATE: 'license:deactivate',
CHECK_FEATURE: 'license:checkFeature',
GET_USAGE: 'license:getUsage',
GET_ALL_USAGE: 'license:getAllUsage',
GET_TIER_COMPARISON: 'license:getTierComparison',
// Main → Renderer events
UPGRADE_PROMPT: 'license:upgradePrompt',
TIER_CHANGED: 'license:tierChanged',
},
} as const
// 타입 유틸리티: 채널명 유니온 추출
type NestedValues<T> = T extends Record<string, infer V>
? V extends string
? V
: NestedValues<V>
: never
export type IPCChannel = NestedValues<typeof IPC_CHANNELS>

1498
packages/core/src/types.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
// src/main/utils/markdown-to-docx.ts
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
const paragraphs: Paragraph[] = []
// 타이틀 추가
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE }))
// 줄 단위 파싱
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))
}

View file

@ -0,0 +1,116 @@
// packages/core/src/utils/meeting-markdown.ts
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
import type { MeetingMinutes, MeetingSessionDetail } from '../types'
export function formatTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
export function formatDateFile(epochMs: number): string {
const d = new Date(epochMs)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
}
export function formatDateRange(startedAt: number, endedAt: number | null): string {
const fmt = (ts: number): string => {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
if (!endedAt) return fmt(startedAt)
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 {
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
const memoTable = session.memos.length > 0
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
: ''
return `# 회의록 — ${session.title ?? '무제 회의'}
- ****: ${formatDateRange(session.startedAt, session.endedAt)}
- ** **: ${durationMin}
- **STT **: ${session.sttModel ?? '-'}
- **LLM **: ${session.llmModel ?? '-'}
---
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
---
${memoTable}
---
##
${session.rawTranscript ?? '(없음)'}
`
}
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
export function markdownToSimpleHtml(md: string): string {
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
.replace(/^\- (.+)$/gm, '<li>$1</li>')
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/\n{2,}/g, '</p><p>')
.replace(/^(?!<[h|l|t|p])/gm, '')
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"]
}