fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -4,7 +4,7 @@ import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi'
import { VK } from '@d3ro/core/keybinding'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { initConfigService, configGet, configSet, onConfigChanged } from './services/ConfigService'
import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService'
import { acquireGlobalInputHook } from './services/global-input-hook'
import { trackModifierKeys } from './services/modifier-state'
@ -26,7 +26,8 @@ import { getSoundEffectService } from './services/SoundEffectService'
import { getAutoLaunchService } from './services/AutoLaunchService'
import { getAudioCaptureService } from './services/AudioCaptureService'
import { initLicenseService } from './services/LicenseService'
import { openLocal } from './db'
import { openForUser, openLocal } from './db'
import { openStartupDatabase } from './db/startup-database'
import {
createMainWindow,
getMainWindow,
@ -39,17 +40,16 @@ import {
hideCommandPopup,
sendKeyToCommandPopup,
isCommandPopupVisible,
showSuggestionOverlay,
updateSuggestionOverlay,
hideSuggestionOverlay,
isSuggestionOverlayVisible,
reapplyThemeToAllPopups,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter'
import { SuggestionOverlayPresenter, createWindowSuggestionOverlayView } from './windows/suggestion-overlay-presenter'
import { ConfigEffects } from './config-effects'
import { PopupNavigationController } from './windows/popup-navigation'
import { setupLifecycle } from './lifecycle'
import { getShutdownRegistry } from './shutdown-registry'
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
import { shouldDismissOnEscape } from './suggestion-overlay-policy'
import { registerAllIpcHandlers } from './ipc'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import type { IPCChannel } from '@d3ro/core/ipc-channels'
@ -71,13 +71,15 @@ export async function bootstrap(): Promise<void> {
const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig },
// 빅뱅 Phase 1.5: 앱 시작 시 익명 로컬 DB를 먼저 연다.
// 무료 로컬 모드가 사용자 entry point — 회원가입 없이 바로 킬러 피처 사용 가능.
// CloudSyncService._onAuthenticated()에서 로그인 시점에 user DB로 스위치한다.
{ name: 'database', critical: true, fn: initLocalDatabase },
// 빅뱅 Phase 1.5: 앱 시작 시 DB 를 먼저 연다. 로그인하지 않았으면 익명 로컬 DB(무료 로컬 모드 —
// 회원가입 없이 바로 사용), 마지막으로 로그인한 계정이 있으면 그 계정 DB 를 바로 연다(세션 복원이
// 끝나기 전·오프라인에도 기록이 익명 DB 로 새지 않게). 복원이 확실히 실패하면 CloudSyncService 가
// 로컬 DB 로 되돌린다.
{ name: 'database', critical: true, fn: initStartupDatabase },
{ name: 'license', critical: false, fn: initLicense },
{ name: 'create-windows', critical: true, fn: createWindows },
{ name: 'tray', critical: false, fn: initTray },
{ name: 'config-effects', critical: false, fn: initConfigEffects },
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
{ name: 'voice-commands', critical: false, fn: initVoiceCommands },
@ -132,9 +134,35 @@ async function initConfig(): Promise<void> {
initConfigService()
}
async function initLocalDatabase(): Promise<void> {
const { created, dbPath } = openLocal()
logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`)
async function initStartupDatabase(): Promise<void> {
const { getCloudSyncService } = await import('./services/CloudSyncService')
const { created, dbPath, accountUserId } = openStartupDatabase({
storedAccountUserId: () => getCloudSyncService().peekStoredAccountUserId(),
openForUser,
openLocal,
warn: (message) => logger.warn(`[bootstrap] ${message}`),
})
logger.info(
`[bootstrap] ${accountUserId ? 'account' : 'local'} database opened: ${dbPath} (created=${created})`
)
}
/**
* 설정 키별 부수효과 — 어느 경로로 바뀌든(설정 화면의 범용 config:set, 전용 IPC, 원격 동기화) 한 번씩 적용한다.
* 예전엔 전용 IPC 에만 붙어 있어 설정 화면에서 바꾼 자동 실행·테마가 OS·팝업에 반영되지 않았다.
*/
async function initConfigEffects(): Promise<void> {
const effects = new ConfigEffects({
subscribe: (listener) => onConfigChanged(listener),
onError: (key, error) =>
logger.warn(`[config-effects] ${key} 적용 실패: ${error instanceof Error ? error.message : String(error)}`),
})
// OS 로그인 항목 — setEnabled 가 같은 값을 다시 저장해도 값이 같으면 효과는 다시 돌지 않는다
effects.register('autoLaunch', (value) => getAutoLaunchService().setEnabled(value === true))
// 미리 로드된 팝업(녹음 팁·기록·명령·제안·자막)의 테마 CSS
effects.register('theme', () => reapplyThemeToAllPopups())
effects.start()
getShutdownRegistry().register({ name: 'config-effects', dispose: () => effects.dispose() })
}
async function initLicense(): Promise<void> {
@ -177,56 +205,19 @@ async function initInputIntelligence(): Promise<void> {
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, payload)
)
// 오버레이 표시 정책은 프레젠터 한 곳 — 여기서는 조립과 메인 창 중계만 한다.
// (프레젠터는 오버레이 예외를 삼키므로 메인 창 중계는 항상 실행된다)
const overlay = new SuggestionOverlayPresenter({
view: createWindowSuggestionOverlayView(),
setPresentationActive: (active) => telemetry.setSuggestionPresentationActive(active),
warn: (message) => logger.warn(message),
})
suggestion.on('updated', (state) => {
// 오버레이/윈도우 호출이 예외를 던지면 서비스의 진행 플래그가 굳을 수 있다.
// 이벤트 경로는 어떤 경우에도 조용히 삼킨다 (UI 문제로 기능 전체가 멈추면 안 된다).
try {
const hasSomethingToShow =
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
const shouldPresent = hasSomethingToShow && !!state.anchor
telemetry.setSuggestionPresentationActive(shouldPresent)
const action = decideSuggestionOverlayAction(isSuggestionOverlayVisible(), shouldPresent)
if (action === 'hide') {
hideSuggestionOverlay()
} else if (action === 'update') {
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
updateSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
provenance: state.provenance
})
} else {
showSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
anchor: state.anchor,
anchorKind: state.anchorKind,
appName: state.appName,
provenance: state.provenance
})
}
} catch (error) {
logger.warn(`제안 오버레이 표시 실패: ${error instanceof Error ? error.message : String(error)}`)
}
overlay.onUpdated(state)
sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state)
})
suggestion.on('cleared', (payload) => {
telemetry.setSuggestionPresentationActive(false)
try {
hideSuggestionOverlay()
} catch (error) {
logger.warn(`제안 오버레이 숨기기 실패: ${error instanceof Error ? error.message : String(error)}`)
}
overlay.onCleared()
sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload)
})
suggestion.on('state-changed', (state) =>
@ -469,6 +460,14 @@ async function initCloudSync(): Promise<void> {
const { getCloudSyncService } = await import('./services/CloudSyncService')
const sync = getCloudSyncService()
await sync.init()
// 지난 실행에서 'recording'/'processing' 으로 남은 회의(강제 종료·정전)를 닫는다. 세션 복원 뒤에 해야
// 바뀐 상태가 동기화 outbox 에 기록돼 다른 기기에서도 끝없는 녹음으로 보이지 않는다.
try {
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService().recoverInterruptedSessions()
} catch (err) {
logger.warn('Interrupted meeting recovery failed:', err)
}
}
async function initMeetingSummaryWiring(): Promise<void> {
@ -503,7 +502,13 @@ async function initMeetingSummaryWiring(): Promise<void> {
async function initMeetingMode(): Promise<void> {
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService()
const { getCaptionService } = await import('./services/CaptionService')
const meetingMode = getMeetingModeService()
// 종료 정리는 등록의 역순이다 — 회의(전사·녹음 저장)가 자막보다 먼저, 둘 다 STT 사이드카보다 먼저 정리된다.
// 예전엔 등록되지 않아, 녹음 중 종료(트레이 종료·업데이트 재시작)가 전사와 녹음을 모두 버리고
// 세션을 영원히 'recording' 으로 남겼다.
getShutdownRegistry().register({ name: 'caption', dispose: () => getCaptionService().dispose() })
getShutdownRegistry().register({ name: 'meeting-mode', dispose: () => meetingMode.shutdown() })
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
getMeetingDocTemplateService()

View file

@ -0,0 +1,87 @@
// src/main/config-effects.ts
// 설정 키별 부수효과 레지스트리 — 어느 경로로 바뀌든(범용 config:set, 전용 IPC, 원격 동기화)
// 같은 효과가 한 번씩 적용되게 한다.
//
// 예전엔 효과가 입력 경로에 붙어 있었다: 테마 팝업 다시 칠하기는 SET_THEME 와 원격 동기화 경로에만,
// OS 로그인 항목 갱신은 SET_AUTO_LAUNCH 에만 있었다. 설정 화면은 범용 config:set 을 써서 두 효과가
// 모두 빠졌다(자동 실행이 재부팅 뒤 동작하지 않고, 미리 로드된 팝업이 이전 테마로 남았다).
// 이제 효과는 설정 변경 이벤트 하나를 구독하는 이 레지스트리에 등록한다(OCP — 새 키는 등록만 한다).
// Electron 에 의존하지 않는다: 구독 함수와 효과는 주입받는다(테스트는 가짜를 넘긴다).
export interface ConfigChange {
key: string
value: unknown
previousValue?: unknown
}
export type ConfigEffect = (value: unknown, previousValue: unknown) => void
export interface ConfigEffectsDeps {
/** 설정 변경 구독 (ConfigService.onConfigChanged) — 해지 함수를 돌려준다 */
subscribe: (listener: (change: ConfigChange) => void) => () => void
/** 효과가 던진 오류 — 다른 효과와 설정 저장은 계속된다 */
onError?: (key: string, error: unknown) => void
}
function isPrimitive(value: unknown): boolean {
return value === null || (typeof value !== 'object' && typeof value !== 'function')
}
/**
* 값이 실제로 바뀌었는가. 원시값은 같으면 건너뛴다 — 효과 안에서 같은 값을 다시 저장해도
* (AutoLaunchService.setEnabled 는 적용 후 configSet 을 부른다) 재귀하지 않는다.
* 객체·배열은 비교하지 않고 적용한다(효과는 멱등이어야 한다).
*/
export function isEffectiveChange(change: ConfigChange): boolean {
if (isPrimitive(change.value) && isPrimitive(change.previousValue)) {
return !Object.is(change.value, change.previousValue)
}
return true
}
export class ConfigEffects {
private readonly _effects = new Map<string, ConfigEffect[]>()
private _unsubscribe: (() => void) | null = null
constructor(private readonly _deps: ConfigEffectsDeps) {}
/** 키에 효과를 등록한다. 돌려받은 함수로 등록을 푼다. */
register(key: string, effect: ConfigEffect): () => void {
const list = this._effects.get(key) ?? []
list.push(effect)
this._effects.set(key, list)
return () => {
const current = this._effects.get(key)
if (!current) return
const next = current.filter((e) => e !== effect)
if (next.length > 0) this._effects.set(key, next)
else this._effects.delete(key)
}
}
/** 설정 변경 구독을 시작한다(멱등). */
start(): void {
if (this._unsubscribe) return
this._unsubscribe = this._deps.subscribe((change) => this.dispatch(change))
}
dispose(): void {
this._unsubscribe?.()
this._unsubscribe = null
this._effects.clear()
}
/** 변경 하나를 등록된 효과에 전달한다(테스트·직접 호출용). */
dispatch(change: ConfigChange): void {
const effects = this._effects.get(change.key)
if (!effects || effects.length === 0) return
if (!isEffectiveChange(change)) return
for (const effect of [...effects]) {
try {
effect(change.value, change.previousValue)
} catch (err) {
this._deps.onError?.(change.key, err)
}
}
}
}

View file

@ -435,7 +435,7 @@ export interface OpenForUserResult {
/**
* 사용자별 DB 열기 — userData/users/${userId}/d3ro.db
* 이미 다른 사용자 DB가 열려있으면 먼저 close 후 오픈.
* 이미 다른 사용자 DB가 열려있으면 새 DB 를 연 뒤에 닫는다(실패하면 이전 DB 유지).
* 동일 사용자면 no-op.
*/
export function openForUser(userId: string): OpenForUserResult {
@ -448,11 +448,6 @@ export function openForUser(userId: string): OpenForUserResult {
return { created: false, dbPath: getUserDbPath(userId) }
}
// 다른 사용자가 열려있으면 먼저 닫기
if (sqlite) {
closeCurrent()
}
// 레거시 DB는 첫 오픈 직전 silent archive
archiveLegacyDbIfExists()
@ -466,26 +461,33 @@ export function openForUser(userId: string): OpenForUserResult {
logger.info(`Opening database for user ${userId} at: ${dbPath} (existed=${existed})`)
// 원자적 교체: 새 DB 를 열고 스키마까지 적용한 뒤에야 이전 DB 를 닫는다.
// 예전엔 먼저 닫고 열었다 — 계정 DB 가 손상되면(applySchema 실패) 로컬 DB 도 이미 닫혀 앱이
// 재시작 전까지 모든 저장·조회에 실패했다. 실패하면 이전 DB 가 그대로 열려 있다.
let next: Database.Database | null = null
let nextDb: BetterSQLite3Database<typeof schema>
try {
sqlite = new Database(dbPath)
applySchema(sqlite)
db = drizzle(sqlite, { schema })
currentUserId = userId
next = new Database(dbPath)
applySchema(next)
nextDb = drizzle(next, { schema })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`Failed to open database for user ${userId}: ${message}`)
// 부분 상태 초기화
logger.error(`Failed to open database for user ${userId}: ${message} (keeping ${currentUserId ?? 'none'} open)`)
try {
sqlite?.close()
next?.close()
} catch {
// ignore
}
sqlite = null
db = null
currentUserId = null
throw new D3ROError(ErrorCode.DBOpenFailed, `Failed to open user DB: ${message}`)
}
if (sqlite) {
closeCurrent()
}
sqlite = next
db = nextDb
currentUserId = userId
logger.info(`Database opened for user: ${userId}`)
// 계정 범위 저장소(명령·템플릿)도 이 사용자로 바꾼다 — 동기화 엔진이 만들어지기 전에 동기적으로 끝난다.
emitAccountScopeChanged(userId)

View file

@ -0,0 +1,30 @@
// src/main/db/like-pattern.ts
// 사용자 검색어로 SQLite LIKE 부분 일치를 만드는 단일 헬퍼 (History·Dictionary 검색이 함께 쓴다).
//
// 검색어를 그대로 `%${query}%` 에 넣으면 '%'·'_' 가 와일드카드가 되어 '_' 는 모든 행과,
// '100%' 는 '100' 이 들어간 모든 행과 일치했다. 메타문자를 이스케이프하고 ESCAPE 절을 붙인다.
import { sql, type AnyColumn, type SQL } from 'drizzle-orm'
const LIKE_ESCAPE_CHAR = '\\'
/** LIKE 메타문자(%, _, 이스케이프 문자 자체)를 글자 그대로 찾도록 이스케이프한다. */
export function escapeLikePattern(text: string): string {
return text.replace(/[\\%_]/g, (char) => `${LIKE_ESCAPE_CHAR}${char}`)
}
export interface ContainsPattern {
/** 바인딩될 LIKE 패턴 (`%…%`, 이스케이프됨) */
readonly pattern: string
/** `column LIKE pattern ESCAPE '\'` */
match(column: AnyColumn | SQL): SQL
}
/** 검색어를 포함하는(부분 일치) LIKE 조건 빌더 */
export function containsPattern(query: string): ContainsPattern {
const pattern = `%${escapeLikePattern(query)}%`
return {
pattern,
match: (column) => sql`${column} LIKE ${pattern} ESCAPE ${LIKE_ESCAPE_CHAR}`,
}
}

View file

@ -0,0 +1,42 @@
// src/main/db/startup-database.ts
// 앱 시작 시 어느 DB 를 열지 정하는 정책 (bootstrap 의 'database' 단계).
//
// 예전엔 항상 익명 로컬 DB(_local)를 열고, 세션 복원(최대 ~30초, 오프라인이면 실패)이 끝난 뒤에야
// 계정 DB 로 바꿨다. 그 사이 받아쓰기·회의는 _local 에 저장됐고, 이미 가져오기(localImport:v1)를
// 마친 계정은 그 기록을 다시 가져오지 않아 계정·다른 기기에서 영영 사라졌다.
// 이제 마지막으로 로그인한 계정이 있으면 그 계정 DB 를 바로 연다. 복원이 확실히 실패하면
// CloudSyncService 가 로컬 DB 로 되돌린다.
import type { OpenForUserResult } from './index'
export interface StartupDatabaseDeps {
/** 저장된(마지막 로그인) 계정 id. 없으면 null */
storedAccountUserId: () => string | null
openForUser: (userId: string) => OpenForUserResult
openLocal: () => OpenForUserResult
warn: (message: string) => void
}
export interface StartupDatabaseResult extends OpenForUserResult {
/** 연 DB 의 계정 id (익명 로컬 모드면 null) */
accountUserId: string | null
}
export function openStartupDatabase(deps: StartupDatabaseDeps): StartupDatabaseResult {
let userId: string | null = null
try {
userId = deps.storedAccountUserId()
} catch (err) {
deps.warn(`Stored account lookup failed: ${err instanceof Error ? err.message : String(err)}`)
}
if (userId) {
try {
return { ...deps.openForUser(userId), accountUserId: userId }
} catch (err) {
deps.warn(
`Account DB open failed at startup — falling back to local mode: ${err instanceof Error ? err.message : String(err)}`
)
}
}
return { ...deps.openLocal(), accountUserId: null }
}

View file

@ -8,7 +8,6 @@ import { getCloudSyncService } from '../services/CloudSyncService'
import { getLogger } from '../services/LoggerService'
import { onConfigChanged } from '../services/ConfigService'
import { isApplyingRemoteSettings } from '../services/sync/settings-sync'
import { reapplyThemeToAllPopups } from '../windows/WindowManager'
const logger = getLogger('cloud-sync-handlers')
@ -119,10 +118,10 @@ export function registerCloudSyncHandlers(): void {
})
// 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독).
// 동기화가 메인에서 바꾼 설정(언어·테마 등)을 화면에도 알린다 — IPC로 바꿀 때만 방송되던 경로.
// (팝업 테마 등 키별 부수효과는 config-effects 레지스트리가 모든 경로에 한 번씩 적용한다)
onConfigChanged((event) => {
if (!isApplyingRemoteSettings()) return
broadcast(IPC_CHANNELS.CONFIG.CHANGED, event)
if (event.key === 'theme') reapplyThemeToAllPopups()
})
sync.on('data-changed', (payload) => {

View file

@ -4,8 +4,6 @@ import { ipcMain, BrowserWindow } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
import { getAutoLaunchService } from '../services/AutoLaunchService'
import { reapplyThemeToAllPopups } from '../windows/WindowManager'
/** 설정 변경 시 모든 렌더러에 broadcast */
function broadcastConfigChanged(key: string, value: unknown, previousValue?: unknown): void {
@ -64,11 +62,12 @@ export function registerConfigHandlers(): void {
return ipcSuccess(configGet('theme'))
})
// 팝업 테마 다시 칠하기·OS 로그인 항목 갱신 같은 키별 부수효과는 config-effects 레지스트리가 설정 변경
// 이벤트로 한 번씩 적용한다 — 전용 IPC 든 범용 config:set 이든 같은 효과가 난다.
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_THEME, async (_event, params: SetThemeParams) => {
const prev = configGet('theme')
configSet('theme', params.theme)
broadcastConfigChanged('theme', params.theme, prev)
reapplyThemeToAllPopups()
return ipcSuccess(undefined)
})
@ -86,7 +85,9 @@ export function registerConfigHandlers(): void {
})
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, async (_event, params: SetAutoLaunchParams) => {
getAutoLaunchService().setEnabled(params.enabled)
const prev = configGet('autoLaunch')
configSet('autoLaunch', params.enabled)
broadcastConfigChanged('autoLaunch', params.enabled, prev)
return ipcSuccess(undefined)
})

View file

@ -6,6 +6,7 @@ import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getLocalLLMService } from '../services/LocalLLMService'
import { getPremiumLLMService } from '../services/PremiumLLMService'
import { getOnlineLLMService } from '../services/OnlineLLMService'
import { getLlmGateway } from '../services/llm/LlmGateway'
import { configGet, configSet } from '../services/ConfigService'
import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts'
import { normalizeLoopbackUrl } from '../utils/loopback'
@ -83,9 +84,6 @@ export function registerLLMHandlers(): void {
ipcMain.handle(IPC_CHANNELS.LLM.PROCESS, async (_event, params: LLMProcessParams) => {
try {
const backend = configGet('llmBackend')
const service =
backend === 'online' ? getOnlineLLMService() : getLocalLLMService()
const start = performance.now()
// 지시문 프롬프트를 시스템 프롬프트 자리로 보내고 플레이스홀더를 치환하는 판단은
@ -100,12 +98,25 @@ export function registerLLMHandlers(): void {
)
: { text: params.text, systemPrompt: params.customPrompt }
const processedText = await service.processText(
invocation.text,
params.action,
params.targetLanguage,
invocation.systemPrompt
)
// 실제 받아쓰기(VoiceModeService)와 같은 경로 — 백엔드 선택은 LlmGateway 한 곳이 정한다
// (online 이면 Supabase Premium, 불가·실패 시 로컬 폴백). 예전엔 online 이면 레거시 .NET
// OnlineLLMService 로 가서, Supabase 로 로그인한 사용자는 명령 테스트 벤치·Ollama 가이드 테스트가
// '로그인 필요'나 127.0.0.1:5000 연결 실패로 떨어졌는데 실제 받아쓰기는 성공했다.
// 레거시 .NET 계정 토큰(authToken)으로 로그인한 경우만 예전 경로를 유지한다(그 계정 전용 서버).
const useLegacyOnline = configGet('llmBackend') === 'online' && !!configGet('authToken')
const processedText = useLegacyOnline
? await getOnlineLLMService().processText(
invocation.text,
params.action,
params.targetLanguage,
invocation.systemPrompt
)
: await getLlmGateway().processText(
invocation.text,
params.action,
params.targetLanguage,
invocation.systemPrompt
)
return ipcSuccess({
originalText: params.text,
@ -121,7 +132,8 @@ export function registerLLMHandlers(): void {
})
ipcMain.handle(IPC_CHANNELS.LLM.CANCEL_PROCESS, async () => {
getLocalLLMService().cancelGeneration()
// 전역 취소 IPC — 게이트웨이(로컬 + 로드된 Premium)와, 아직 게이트웨이가 로드하지 않은 Premium·레거시 경로까지
getLlmGateway().cancel()
getPremiumLLMService().cancelGeneration()
getOnlineLLMService().cancelGeneration()
return ipcSuccess(undefined)

View file

@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
import { getMeetingModeService } from '../services/MeetingModeService'
import { getLogger } from '../services/LoggerService'
import { LiveSegmentEditor } from '../services/meeting/live-segment-edits'
import { LiveSegmentEditor, normalizeSegmentText } from '../services/meeting/live-segment-edits'
import {
MEETING_CHAT_GET_HISTORY_CHANNEL,
MEETING_MODE_GET_LIVE_SESSION_CHANNEL,
} from '@d3ro/core/meeting-live'
import type { LiveSegmentEditResult } from '../services/meeting/live-segment-edits'
import type {
CaptionSegment,
@ -32,6 +36,28 @@ interface MeetingEditSegmentParams {
let liveSegmentEditor: LiveSegmentEditor | null = null
/**
* 녹음 중 받아들인 세그먼트 수정(표시용 사본). 편집기는 세션 완료 때 전사에 반영하고, 이 사본은
* 녹음 화면을 다시 열었을 때 고친 문장을 그대로 보여 주는 데 쓴다.
*/
const acceptedLiveEdits = { sessionId: null as string | null, texts: new Map<string, string>() }
function rememberLiveEdit(sessionId: string, segmentId: string, text: string): void {
if (acceptedLiveEdits.sessionId !== sessionId) {
acceptedLiveEdits.sessionId = sessionId
acceptedLiveEdits.texts.clear()
}
acceptedLiveEdits.texts.set(segmentId, normalizeSegmentText(text))
}
function withLiveEdits(sessionId: string, segments: CaptionSegment[]): CaptionSegment[] {
if (acceptedLiveEdits.sessionId !== sessionId) return segments
return segments.map((segment) => {
const edited = acceptedLiveEdits.texts.get(segment.id)
return edited === undefined ? segment : { ...segment, text: edited }
})
}
/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */
function attachLiveSegmentEditor(): LiveSegmentEditor {
liveSegmentEditor?.dispose()
@ -114,6 +140,18 @@ export function registerMeetingModeHandlers(): void {
}
})
// 녹음 화면을 다시 열 때(페이지 이동 후 복귀) 그동안의 실시간 전사·메모를 복원한다.
// 녹음 중 고친 세그먼트는 고친 문장으로 돌려준다(세션 완료 시 저장될 내용과 같게).
ipcMain.handle(MEETING_MODE_GET_LIVE_SESSION_CHANNEL, () => {
try {
const live = getMeetingModeService().getLiveSession()
if (!live) return ipcSuccess(null)
return ipcSuccess({ ...live, segments: withLiveEdits(live.sessionId, live.segments) })
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.GET_SESSIONS, (_event, params: MeetingGetSessionsParams) => {
try {
return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize))
@ -190,7 +228,10 @@ export function registerMeetingModeHandlers(): void {
ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => {
try {
const result = liveEditor.edit(params.sessionId, params.segmentId, params.text)
if (result === 'ok') return ipcSuccess(undefined)
if (result === 'ok') {
rememberLiveEdit(params.sessionId, params.segmentId, params.text)
return ipcSuccess(undefined)
}
const failure = EDIT_SEGMENT_FAILURES[result]
logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`)
return ipcError(failure.code, failure.message)
@ -302,24 +343,35 @@ export function registerMeetingModeHandlers(): void {
}
})
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CANCEL, () => {
// sessionId 를 주면 그 회의만 — 예전엔 한 회의의 '지우기'가 모든 회의의 대화를 지웠다.
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CANCEL, (_event, params?: { sessionId?: string }) => {
try {
getMeetingModeService().cancelMeetingChat()
getMeetingModeService().cancelMeetingChat(params?.sessionId)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CLEAR, () => {
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CLEAR, (_event, params?: { sessionId?: string }) => {
try {
getMeetingModeService().clearMeetingChatHistory()
getMeetingModeService().clearMeetingChatHistory(params?.sessionId)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
// 채팅 패널을 다시 열 때 main 이 기억하는 대화를 보여 준다 — 화면은 비었는데 모델은 숨은 대화를
// 이어받던 문제를 없앤다.
ipcMain.handle(MEETING_CHAT_GET_HISTORY_CHANNEL, (_event, params: { sessionId: string }) => {
try {
return ipcSuccess(getMeetingModeService().getMeetingChatHistory(params.sessionId))
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.DIARIZE, async (_event, params: { sessionId: string; numSpeakers?: number }) => {
try {
await getMeetingModeService().diarizeSession(params.sessionId, params.numSpeakers)

View file

@ -15,6 +15,7 @@ import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService'
import { buildCaptionRefinePrompt } from './llm-prompts'
import { StreamingCaptionTrack, type CaptionTranscription } from './caption/StreamingCaptionTrack'
import { CaptionAudioSources } from './caption/CaptionAudioSources'
import { acceptCaptionRefinement } from '@d3ro/core/caption-streaming'
import {
showCaptionOverlay,
@ -91,7 +92,12 @@ interface CaptionServiceEvents {
'segment': (segment: CaptionSegment) => void
'delta': (data: { text: string; isFinal: boolean }) => void
'session-saved': (summary: CaptionSessionResult) => void
'error': (error: D3ROError) => void
/**
* 인식·시작 실패 알림. 'error' 가 아니다 — 리스너 없는 'error' 는 EventEmitter 가 던져서,
* 정지 중 마지막 문장 인식이 실패하면 stop() 이 해제 전에 튕겨 나가 서비스가 'stopping' 에
* 마이크가 열린 채 굳었다.
*/
'failure': (error: D3ROError) => void
}
class CaptionService extends EventEmitter {
@ -113,7 +119,8 @@ class CaptionService extends EventEmitter {
private _refineQueue: CaptionSegment[] = []
private _refining = false
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
/** 이번 세션이 잡은 오디오 소스(마이크 캡처·시스템 소리 요청) — 해제는 멱등 */
private _audio: CaptionAudioSources = this._createAudioSources()
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
@ -203,22 +210,16 @@ class CaptionService extends EventEmitter {
}
logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`)
// 마이크 캡처 (mic 또는 both)
// 트랙 먼저 — 마이크 첫 청크부터 받을 수 있게
if (audioSource === 'mic' || audioSource === 'both') {
this._micTrack = this._createTrack('mic', initialContext)
const audioCaptureService = getAudioCaptureService()
this._audioDataHandler = (payload) => {
this._onAudioData(payload.buffer)
}
audioCaptureService.on('audio-data', this._audioDataHandler)
await audioCaptureService.start()
}
// 시스템 오디오 캡처 요청 (system 또는 both) — 렌더러에 시작 요청
if (audioSource === 'system' || audioSource === 'both') {
this._systemTrack = this._createTrack('system', initialContext)
this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {})
}
// 마이크 캡처(mic/both) + 시스템 오디오 캡처 요청(system/both — 렌더러에 시작 요청)
this._audio = this._createAudioSources()
await this._audio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
// 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다
this._tickTimer = setInterval(() => {
@ -236,13 +237,8 @@ class CaptionService extends EventEmitter {
}
this._micTrack = null
this._systemTrack = null
if (this._audioDataHandler) {
const audioCaptureService = getAudioCaptureService()
audioCaptureService.off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
await audioCaptureService.stop().catch(() => {})
}
hideCaptionOverlay()
await this._audio.release()
this._hideOverlaySafely()
this._session = { ...DEFAULT_START_OPTIONS }
this._setState('inactive')
const d3roErr =
@ -252,7 +248,7 @@ class CaptionService extends EventEmitter {
ErrorCode.CaptionStartFailed,
`캡션 시작 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this.emit('error', d3roErr)
this._reportFailure(d3roErr)
throw d3roErr
}
}
@ -284,28 +280,31 @@ class CaptionService extends EventEmitter {
this._tickTimer = null
}
// 잔여 오디오 처리 — 말하던 중이면 마지막 문장을 확정한다
await Promise.all([this._micTrack?.flush(), this._systemTrack?.flush()])
this._micTrack = null
this._systemTrack = null
// 마이크 캡처 정리
if (this._audioDataHandler) {
const audioCaptureService = getAudioCaptureService()
audioCaptureService.off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
await audioCaptureService.stop()
// 잔여 오디오 처리 — 말하던 중이면 마지막 문장을 확정한다.
// 인식이 실패해도(사이드카 500·타임아웃) 아래 해제·초기화는 반드시 실행한다 — 예전엔 여기서 튕겨
// 마이크가 열린 채 'stopping' 에 굳어 재시작 전까지 자막·회의를 다시 시작할 수 없었다.
try {
const results = await Promise.allSettled([this._micTrack?.flush(), this._systemTrack?.flush()])
for (const result of results) {
if (result.status === 'rejected') this._reportFailure(this._toSttError(result.reason, 'flush'))
}
} finally {
this._micTrack = null
this._systemTrack = null
// 마이크 캡처 정리 + 시스템 오디오 캡처 중지 요청 (멱등, 실패해도 계속)
await this._audio.release()
// 오버레이 숨김
this._hideOverlaySafely()
}
// 시스템 오디오 캡처 중지 요청
this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {})
// 오버레이 숨김
hideCaptionOverlay()
// 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다)
const persist = this._session.persistHistory
const summary = this._saveSession(persist)
let summary: CaptionSessionResult | null = null
try {
summary = this._saveSession(persist)
} catch (err) {
logger.error(`캡션 세션 요약 실패: ${err instanceof Error ? err.message : String(err)}`)
}
this._sessionId = null
this._sessionStartedAt = null
@ -363,22 +362,65 @@ class CaptionService extends EventEmitter {
isVoiced: (chunk) => calculateRMS(chunk) >= SILENCE_RMS_THRESHOLD,
onPartial: (text, stable) => this._emitPartial(text, stable),
onFinal: (text) => this._emitFinal(text, source),
onError: (err) => {
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.CaptionSTTFailed,
`자막 전사 실패(${source}): ${err instanceof Error ? err.message : String(err)}`,
)
logger.error(d3roErr.message)
this.emit('error', d3roErr)
},
// 절대 던지지 않는 보고 경로 — 트랙의 인식 실패가 정지·틱을 깨뜨리지 않게
onError: (err) => this._reportFailure(this._toSttError(err, source)),
},
initialContext,
)
}
private _toSttError(err: unknown, source: string): D3ROError {
return err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.CaptionSTTFailed,
`자막 전사 실패(${source}): ${err instanceof Error ? err.message : String(err)}`,
)
}
/** 실패를 로그와 'failure' 이벤트로 알린다. 리스너가 던져도 호출자에게 새지 않는다. */
private _reportFailure(error: D3ROError): void {
logger.error(error.message)
try {
this.emit('failure', error)
} catch (err) {
logger.warn(`caption failure listener threw: ${err instanceof Error ? err.message : String(err)}`)
}
}
private _hideOverlaySafely(): void {
try {
hideCaptionOverlay()
} catch (err) {
logger.warn(`캡션 오버레이 숨기기 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
/** 오디오 소스 어댑터 — 마이크는 AudioCaptureService, 시스템 소리는 렌더러 IPC */
private _createAudioSources(): CaptionAudioSources {
return new CaptionAudioSources({
mic: () => {
const capture = getAudioCaptureService()
return {
onChunk: (listener) => {
const handler = (payload: { buffer: Buffer; timestamp: number }): void => listener(payload.buffer)
capture.on('audio-data', handler)
return () => { capture.off('audio-data', handler) }
},
start: () => capture.start(),
stop: () => capture.stop(),
}
},
system: {
requestStart: () => this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {}),
requestStop: () => this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {}),
},
onReleaseError: (stage, err) => {
logger.warn(`캡션 오디오 해제 실패(${stage}): ${err instanceof Error ? err.message : String(err)}`)
},
})
}
/** 자막은 항상 로컬 엔진으로 인식한다 — 1초마다 다시 인식하므로 클라우드로는 보낼 수 없다. */
private async _transcribe(
audio: Buffer,

View file

@ -64,6 +64,8 @@ function sendProgressToRenderer(progress: ChainProgress): void {
class ChainService {
private _cancelRequested = false
/** 진행 중인 실행의 취소 신호 (실행마다 새로 만든다) */
private _executionAbort: AbortController | null = null
initialize(): void {
if (initialized) return
@ -146,6 +148,11 @@ class ChainService {
}
this._cancelRequested = false
// 이 실행만의 취소 신호 — 진행 중인 LLM 단계까지 끊는다(예전엔 전역 cancelGeneration 이 단일 턴 호출을
// 멈추지 못해, 응답 없는 프록시에서 체인이 끝없이 기다렸고 다른 기능의 스트림은 함께 끊겼다).
this._executionAbort?.abort()
const executionAbort = new AbortController()
this._executionAbort = executionAbort
const startTime = Date.now()
const stepResults: Array<{ instructionId: string; output: string; durationMs: number }> = []
let previousOutput = inputText
@ -203,6 +210,7 @@ class ChainService {
'custom',
undefined,
invocation.systemPrompt,
{ signal: executionAbort.signal },
)
const stepDuration = Date.now() - stepStart
@ -220,6 +228,10 @@ class ChainService {
if (error instanceof D3ROError && error.code === ErrorCode.ChainCancelled) {
throw error
}
if (executionAbort.signal.aborted || this._cancelRequested) {
logger.info(`Chain execution cancelled during step ${i + 1}/${chain.steps.length}`)
throw new D3ROError(ErrorCode.ChainCancelled, 'Chain execution cancelled')
}
const msg =
error instanceof Error ? error.message : String(error)
throw new D3ROError(
@ -229,6 +241,7 @@ class ChainService {
}
}
if (this._executionAbort === executionAbort) this._executionAbort = null
const totalDuration = Date.now() - startTime
logger.info(`Chain "${chain.name}" completed in ${totalDuration}ms`)
@ -246,13 +259,14 @@ class ChainService {
*/
cancelExecution(): void {
this._cancelRequested = true
// LLM 생성도 취소
getPremiumLLMService().cancelGeneration()
// 이 체인의 LLM 호출만 취소한다 — 다른 기능의 진행 중 호출은 건드리지 않는다
this._executionAbort?.abort()
logger.info('Chain execution cancel requested')
}
dispose(): void {
this._cancelRequested = true
this._executionAbort?.abort()
logger.info('ChainService disposed')
}
}

View file

@ -17,13 +17,26 @@ import {
import { getLogger } from './LoggerService'
import { configGet, configSet, onConfigChanged } from './ConfigService'
import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@d3ro/core/supabase-config'
import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalModeData } from '../db'
import {
openForUser,
openLocal,
closeCurrent,
getCurrentUserId,
importLocalModeData,
LOCAL_USER_ID,
} from '../db'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { LicenseTier } from '@d3ro/core/types'
import { SyncEngine, type SyncStatus } from './sync/SyncEngine'
import { SyncAbortedError } from './sync/sync-adapters'
import { SyncScheduler } from './sync/sync-scheduler'
import { EncryptedFileTokenStore, bindAuthEvents, type SessionTokenStore } from './sync/session-token-store'
import {
EncryptedFileTokenStore,
bindAuthEvents,
type SessionTokenStore,
type StoredSessionRecord,
} from './sync/session-token-store'
import { classifySessionRestoreFailure, sessionRestoreRetryDelayMs } from './sync/session-restore-policy'
import { openExternalSafe } from '../windows/web-contents-hardening'
import { SupabaseSyncRemote } from './sync/supabase-sync-remote'
import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox'
@ -105,6 +118,15 @@ class CloudSyncService extends EventEmitter {
private _pendingSignInUntil: number | null = null
private _tokenStore: SessionTokenStore | null = null
private _unbindAuthEvents: (() => void) | null = null
/**
* 로그인된 계정이지만 세션을 아직 복원하지 못한 상태(오프라인으로 시작 등)의 계정 id.
* 이 동안 기록은 그 계정 DB 에 쓰이고 outbox 에 쌓였다가, 복원되면 올라간다. 세션이 있으면 null.
*/
private _offlineUserId: string | null = null
private _restoreRetryTimer: NodeJS.Timeout | null = null
private _restoreAttempt = 0
/** _onSignOut 진행 중 — 자기 signOut() 이 일으킨 SIGNED_OUT 을 다시 처리하지 않게 */
private _signingOut = false
private readonly _scheduler = new SyncScheduler({
flushDelayMs: FLUSH_DEBOUNCE_MS,
pullDelayMs: PULL_DEBOUNCE_MS,
@ -144,7 +166,8 @@ class CloudSyncService extends EventEmitter {
() => this._session?.user.id ?? null,
(session) => {
this._session = session
}
},
() => this._onRemoteSignedOut()
)
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
@ -165,39 +188,9 @@ class CloudSyncService extends EventEmitter {
})
// 저장된 refresh token 복원
const stored = this._loadStoredRefreshToken()
const stored = this._loadStoredSession()
if (stored) {
try {
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
if (error) {
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
logger.error(
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session expired. Please sign in again. (${error.message})`,
})
this.emit('auth-changed', { user: null })
} else if (data.session) {
this._saveRefreshToken(data.session.refresh_token)
logger.info(
`Restored session for user: ${data.session.user.email ?? data.session.user.id}`
)
// 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작
await this._onAuthenticated(data.session, { reason: 'restore' })
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
this._clearStoredRefreshToken()
this.emit('sync-error', {
error: `Sign-in session restore failed. Please sign in again. (${message})`,
})
this.emit('auth-changed', { user: null })
}
await this._restoreSession(stored)
} else {
// 저장된 토큰 없음 → 미인증 상태를 렌더러에 명시 전달
this.emit('auth-changed', { user: null })
@ -206,6 +199,141 @@ class CloudSyncService extends EventEmitter {
logger.info('CloudSyncService initialized')
}
/**
* 저장된 계정 id (없거나 구버전 토큰 파일이면 null). bootstrap 이 세션 복원을 기다리지 않고
* 그 계정 DB 를 먼저 열어, 복원 중·오프라인 동안의 기록이 익명 DB 로 새지 않게 한다.
*/
peekStoredAccountUserId(): string | null {
try {
return this._loadStoredSession()?.userId ?? null
} catch {
return null
}
}
// ── 세션 복원 (시작 시 + 오프라인 재시도) ─────────────────
private async _restoreSession(stored: StoredSessionRecord): Promise<void> {
const client = this._client
if (!client) return
try {
const { data, error } = await client.auth.refreshSession({ refresh_token: stored.refreshToken })
if (this._session) return // 기다리는 동안 사용자가 새로 로그인했다
if (error) {
this._handleRestoreFailure(error, error.message, stored)
return
}
if (!data.session) {
this._handleRestoreFailure(null, 'refreshSession returned no session', stored)
return
}
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
logger.info(`Restored session for user: ${data.session.user.email ?? data.session.user.id}`)
// 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작
await this._onAuthenticated(data.session, { reason: 'restore' })
} catch (err) {
if (this._session) return
this._handleRestoreFailure(err, err instanceof Error ? err.message : String(err), stored)
}
}
/**
* 복원 실패 처리. 서버가 토큰을 확실히 거부했을 때만 토큰을 지우고 로그아웃한다.
* 네트워크·서버 일시 오류면 토큰을 보관하고, 저장된 계정 DB 에서 오프라인으로 계속 동작하며
* 백오프로 다시 시도한다(예전엔 어떤 오류든 토큰을 지워, 오프라인 시작만으로 로그아웃되고
* 그 뒤 기록이 익명 DB 에 갇혔다).
*/
private _handleRestoreFailure(error: unknown, message: string, stored: StoredSessionRecord): void {
if (classifySessionRestoreFailure(error) === 'invalid') {
// U3: 재인증 필요를 사용자에게 알린다(조용히 토큰만 지우면 "왜 로그아웃됐지?" 상태로 방치된다).
logger.error(`세션 복원 실패 — 재로그인 필요: ${message} (토큰 만료 또는 서버 세션 무효화)`)
this._cancelRestoreRetry()
this._offlineUserId = null
this._clearStoredRefreshToken()
this._revertToLocalDatabase('restore-invalid')
this.emit('sync-error', { error: `Sign-in session expired. Please sign in again. (${message})` })
this.emit('auth-changed', { user: null })
return
}
logger.warn(`세션 복원 보류(일시 오류) — 토큰 보관, 나중에 다시 시도: ${message}`)
const firstFailure = this._restoreAttempt === 0 && this._offlineUserId === null
if (stored.userId) this._enterOfflineAccount(stored.userId)
this._scheduleRestoreRetry()
if (firstFailure) {
this.emit('sync-error', {
error: `Could not reach the sync server. You are still signed in; sync resumes automatically when the connection is back. (${message})`,
})
this.emit('auth-changed', { user: null })
}
}
/** 세션 없이 저장된 계정 DB 로 동작한다(쓰기는 outbox 에 쌓인다). */
private _enterOfflineAccount(userId: string): void {
if (getCurrentUserId() !== userId) {
try {
openForUser(userId)
} catch (err) {
logger.warn(`Offline account DB open failed (staying in current DB): ${err instanceof Error ? err.message : String(err)}`)
return
}
}
this._offlineUserId = userId
}
private _scheduleRestoreRetry(): void {
if (this._restoreRetryTimer) return
const delay = sessionRestoreRetryDelayMs(this._restoreAttempt)
this._restoreAttempt++
logger.info(`Session restore retry #${this._restoreAttempt} in ${delay}ms`)
this._restoreRetryTimer = setTimeout(() => {
this._restoreRetryTimer = null
if (this._session) return
const stored = this._loadStoredSession()
if (!stored) return
void this._restoreSession(stored)
}, delay)
this._restoreRetryTimer.unref?.()
}
private _cancelRestoreRetry(): void {
if (this._restoreRetryTimer) {
clearTimeout(this._restoreRetryTimer)
this._restoreRetryTimer = null
}
this._restoreAttempt = 0
}
/** 로그인되지 않은 상태로 돌아갈 때 익명 로컬 DB 로 되돌린다(이미 로컬이면 그대로). */
private _revertToLocalDatabase(reason: string): void {
if (getCurrentUserId() === LOCAL_USER_ID) return
try {
const { dbPath } = openLocal()
logger.info(`[${reason}] Reverted to local DB: ${dbPath}`)
} catch (err) {
logger.error(`[${reason}] Local DB reopen failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
/**
* auth 클라이언트가 세션을 버렸다(SIGNED_OUT) — 다른 기기의 전역 로그아웃, 토큰 폐기 등.
* 예전엔 무시해 엔진·Realtime 이 익명 키로 계속 돌며 푸시가 RLS 에 막히고, 5분 뒤 heartbeat 가
* "다른 기기에서 연결 해제됨"이라는 틀린 사유로 로그아웃시켰다.
*/
private _onRemoteSignedOut(): void {
if (this._signingOut || !this._session) return
logger.warn('Auth session ended outside this app (SIGNED_OUT) — signing out locally')
void this._onSignOut({ unregister: false })
.then(() => {
this.emit('sync-error', {
error: 'Your sign-in session has ended (signed out elsewhere or expired). Sign in again to keep syncing.',
})
})
.catch((err) => {
logger.warn(`Remote sign-out cleanup failed: ${err instanceof Error ? err.message : String(err)}`)
})
}
// ── 인증 lifecycle 단일 진입점 ─────────────────────────
/**
@ -220,6 +348,8 @@ class CloudSyncService extends EventEmitter {
opts: { reason: 'restore' | 'signin' }
): Promise<void> {
const userId = session.user.id
// 로그인(또는 복원) 성공 — 오프라인 재시도는 더 필요 없다.
this._cancelRestoreRetry()
// 0) 이전 계정의 동기화를 먼저 멈춘다 — 새 DB가 열린 뒤 이전 엔진이 그 DB에 쓰지 않게.
const previous = this._engine
@ -229,8 +359,10 @@ class CloudSyncService extends EventEmitter {
await previous.whenIdle(ENGINE_STOP_TIMEOUT_MS)
}
this._session = session
this._offlineUserId = null
// 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리)
// 이 시점부터 이 계정 DB 의 쓰기는 엔진이 만들어지기 전에도 outbox 에 기록된다(_recordChange).
try {
const { created, dbPath } = openForUser(userId)
logger.info(
@ -239,9 +371,11 @@ class CloudSyncService extends EventEmitter {
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.error(`[auth:${opts.reason}] DB open failed for ${userId}: ${message}`)
// DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut
// DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut.
// openForUser 는 실패해도 이전 DB 를 닫지 않는다. 그것이 다른 계정의 DB 면 로컬로 되돌린다.
this._session = null
this._clearStoredRefreshToken()
this._revertToLocalDatabase(`auth:${opts.reason}`)
this.emit('sync-error', { error: `Database open failed: ${message}` })
this.emit('auth-changed', { user: null })
return
@ -343,6 +477,20 @@ class CloudSyncService extends EventEmitter {
* 그 다음 auth-changed(null) emit, 마지막으로 DB close.
*/
private async _onSignOut(options: { unregister: boolean } = { unregister: true }): Promise<void> {
if (this._signingOut) return
this._signingOut = true
try {
await this._runSignOut(options)
} finally {
this._signingOut = false
}
}
private async _runSignOut(options: { unregister: boolean }): Promise<void> {
// 오프라인 복원 대기도 끝낸다 — 로그아웃한 계정의 토큰으로 다시 복원하지 않게.
this._cancelRestoreRetry()
this._offlineUserId = null
// 1) 활성 녹음/세션 강제 종료 (DB close 전에 flush 기회 제공)
try {
const { getVoiceModeService } = await import('./VoiceModeService')
@ -392,10 +540,11 @@ class CloudSyncService extends EventEmitter {
await unregisterDesktopDevice(this._client).catch(() => undefined)
}
// 4) Supabase 세션 무효화
// 4) Supabase 세션 무효화 — 이 기기의 세션만(scope:'local').
// 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 휴대폰·다른 PC 까지 로그아웃시켰다.
if (this._client && this._session) {
try {
await this._client.auth.signOut()
await this._client.auth.signOut({ scope: 'local' })
} catch (err) {
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
}
@ -582,6 +731,11 @@ class CloudSyncService extends EventEmitter {
}
}
/** 세션은 없지만 저장된 계정으로 오프라인 동작 중인지 (복원 재시도 대기) */
isOfflineAccount(): boolean {
return this._session === null && this._offlineUserId !== null
}
getState(): CloudSyncState {
const status = this.getSyncStatus()
return {
@ -787,7 +941,7 @@ class CloudSyncService extends EventEmitter {
)
}
this._saveRefreshToken(data.session.refresh_token)
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
@ -822,7 +976,7 @@ class CloudSyncService extends EventEmitter {
)
}
this._saveRefreshToken(data.session.refresh_token)
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
await this._onAuthenticated(data.session, { reason: 'signin' })
@ -850,8 +1004,14 @@ class CloudSyncService extends EventEmitter {
this._recordChange(entity, id, 'delete')
}
/**
* 변경을 이 계정 DB 의 outbox 에 기록한다. 엔진이 아직 없어도(로그인 직후 티어 조회 중,
* 오프라인 복원 대기 중) 기록한다 — 예전엔 버려져 최초 대조가 끝난 계정에서는 영영 올라가지 않았다.
* 엔진이 있을 때만 곧바로 flush 를 예약한다(없으면 엔진 생성 후 최초 동기화가 올린다).
*/
private _recordChange(entity: SyncEntity, id: string, op: 'upsert' | 'delete'): void {
if (!this._session || !this._engine || getCurrentUserId() !== this._session.user.id) return
const accountUserId = this._session?.user.id ?? this._offlineUserId
if (!accountUserId || getCurrentUserId() !== accountUserId) return
try {
enqueueChange(entity, id, op)
} catch (err) {
@ -859,7 +1019,7 @@ class CloudSyncService extends EventEmitter {
logger.warn(`Sync enqueue ${entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`)
return
}
this._scheduleFlush()
if (this._engine && this._session) this._scheduleFlush()
}
private _scheduleFlush(): void {
@ -1029,12 +1189,12 @@ class CloudSyncService extends EventEmitter {
return this._tokenStore
}
private _saveRefreshToken(token: string): void {
this._tokens().save(token)
private _saveRefreshToken(token: string, userId: string): void {
this._tokens().save(token, userId)
}
private _loadStoredRefreshToken(): string | null {
return this._tokens().load()
private _loadStoredSession(): StoredSessionRecord | null {
return this._tokens().loadRecord()
}
private _clearStoredRefreshToken(): void {
@ -1044,6 +1204,7 @@ class CloudSyncService extends EventEmitter {
/** 테스트 전용: 타이머·auth 구독을 해제한다. */
disposeForTests(): void {
this._scheduler.stop()
this._cancelRestoreRetry()
this._unbindAuthEvents?.()
this._unbindAuthEvents = null
void this._client?.auth.stopAutoRefresh().catch(() => undefined)

View file

@ -15,6 +15,12 @@ import {
} from './account-scope'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { CustomInstruction } from '@d3ro/core/types'
import {
INSTRUCTION_LIMITS,
instructionLimitViolation,
validateInstructionDraft,
type InstructionLimitField,
} from '@d3ro/core/instruction-policy'
const logger = getLogger('CustomInstructionService')
@ -76,41 +82,29 @@ const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' |
]
// ============================================================
// 길이 제한 — 서버 custom_instructions CHECK 와 같은 값(마이그레이션 20260821000005)
// 서버 쓰기 제약 — 정본은 @d3ro/core/instruction-policy (서버 custom_instructions CHECK 와 같은 값)
// 기존 import 경로(동기화 어댑터·테스트)를 위해 다시 내보낸다.
// ============================================================
export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
export { INSTRUCTION_LIMITS, instructionLimitViolation }
export type { InstructionLimitField }
export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS
/** Postgres char_length 와 같은 코드 포인트 수 */
function charLength(value: string): number {
return [...value].length
}
/** 서버 제한을 넘는 첫 필드. 없으면 null. (이름·프롬프트는 앞뒤 공백을 빼고 센다 — 서버 btrim 과 같다) */
export function instructionLimitViolation(input: {
name?: string
description?: string
prompt?: string
}): InstructionLimitField | null {
if (input.name !== undefined && charLength(input.name.trim()) > INSTRUCTION_LIMITS.name) return 'name'
if (input.description !== undefined && charLength(input.description.trim()) > INSTRUCTION_LIMITS.description) {
return 'description'
}
if (input.prompt !== undefined && charLength(input.prompt.trim()) > INSTRUCTION_LIMITS.prompt) return 'prompt'
return null
}
function assertWithinLimits(input: { name?: string; description?: string; prompt?: string }): void {
const field = instructionLimitViolation(input)
if (field !== null) {
/** 서버 제약(빈 값·길이)을 어기면 저장하지 않는다 — 동기화가 올리지 못해 편집이 다른 기기로 가지 않는다. */
function assertValidDraft(input: { name?: string; description?: string; prompt?: string }): void {
const problem = validateInstructionDraft(input)
if (problem === null) return
if (problem.reason === 'empty') {
throw new D3ROError(
ErrorCode.ConfigInvalidValue,
`Instruction ${field} is too long (max ${INSTRUCTION_LIMITS[field]} characters)`,
{ field, max: INSTRUCTION_LIMITS[field] }
`Instruction ${problem.field} is empty`,
{ field: problem.field }
)
}
throw new D3ROError(
ErrorCode.ConfigInvalidValue,
`Instruction ${problem.field} is too long (max ${problem.max} characters)`,
{ field: problem.field, max: problem.max }
)
}
// ============================================================
@ -199,14 +193,8 @@ class CustomInstructionService {
}
create(input: CreateInput): CustomInstruction {
if (!input.name.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction name is empty')
}
if (!input.prompt.trim()) {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
}
// 서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
assertWithinLimits(input)
// 빈 값·서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
assertValidDraft(input)
const now = Date.now()
const instruction: CustomInstruction = {
id: crypto.randomUUID(),
@ -232,7 +220,8 @@ class CustomInstructionService {
if (index === -1) return null
const existing = instructions[index]
assertWithinLimits(existing.isBuiltin ? { prompt: data.prompt } : data)
// 생성과 같은 규칙(빈 이름·빈 프롬프트 포함) — 예전엔 편집이 빈 프롬프트를 받아들여 서버 CHECK 에 막혔다.
assertValidDraft(existing.isBuiltin ? { prompt: data.prompt } : data)
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
if (existing.isBuiltin) {

View file

@ -1,7 +1,7 @@
// src/main/services/DictionaryService.ts
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
import { eq, and, like, desc, count, sql } from 'drizzle-orm'
import { eq, and, desc, count, sql } from 'drizzle-orm'
import path from 'path'
import fs from 'fs'
import { app, dialog } from 'electron'
@ -12,8 +12,11 @@ import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import { getMainWindow } from '../windows/WindowManager'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { containsPattern } from '../db/like-pattern'
import {
buildDictionaryUpdatePatch,
dictionaryIdentityKey,
findDictionaryIdentityConflict,
isUniqueConstraintViolation,
normalizeDictionaryPronunciation,
normalizeDictionaryWord
@ -118,6 +121,43 @@ function normalizeCategory(value: string | null): DictionaryEntry['category'] {
return 'user'
}
type DictionaryDb = Pick<ReturnType<typeof getDatabase>, 'select'>
/**
* 같은 카테고리에 대소문자·앞뒤 공백만 다른 단어가 있으면 그 행. 서버 유일성(lower(btrim(word)))과 같은
* 기준으로 로컬 입력 단계에서 거절한다 — 로컬 인덱스는 대소문자를 구분해 그대로 받아들이고, 동기화가
* 나중에 사용자의 항목을 서버 행으로 조용히 대체했다. (Unicode 대소문자까지 JS 로 비교한다)
*/
function findCaseInsensitiveDuplicate(
db: DictionaryDb,
word: string,
category: DictionaryEntry['category'],
excludeId?: string
): { id: string; word: string } | null {
const candidates = db
.select({ id: dictionary.id, word: dictionary.word })
.from(dictionary)
.where(eq(dictionary.category, category))
.all()
return findDictionaryIdentityConflict(word, candidates, excludeId)
}
/**
* 가져오기용 한 번 조회 — 대량 행을 JS 로 비교하지 않고 SQL 에서 (lower(trim(word)), category) 로 찾는다.
* SQLite lower() 는 ASCII 만 접으므로 비 ASCII 대소문자 변형은 추가·수정 경로(JS 비교)만큼 엄밀하지 않다.
*/
function findImportDuplicate(
db: DictionaryDb,
word: string,
category: DictionaryEntry['category']
): { id: string } | undefined {
return db
.select({ id: dictionary.id })
.from(dictionary)
.where(and(sql`lower(trim(${dictionary.word})) = ${dictionaryIdentityKey(word)}`, eq(dictionary.category, category)))
.get()
}
class DictionaryService {
add(params: DictionaryAddParams): DictionaryEntry {
const word = normalizeDictionaryWord(params.word)
@ -128,12 +168,17 @@ class DictionaryService {
const db = getDatabase()
const now = Date.now()
const id = crypto.randomUUID()
const category = params.category ?? 'user'
const duplicate = findCaseInsensitiveDuplicate(db, word, category)
if (duplicate) {
throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${word} (existing: ${duplicate.word})`)
}
const entry: NewDictionary = {
id,
word,
pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null,
category: params.category ?? 'user',
category,
usageCount: 0,
lastUsedAt: null,
createdAt: now,
@ -164,6 +209,15 @@ class DictionaryService {
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
if (!existing) return null
if (result.patch.word !== undefined || result.patch.category !== undefined) {
const nextWord = result.patch.word ?? existing.word
const nextCategory = result.patch.category ?? (existing.category as DictionaryEntry['category'])
const duplicate = findCaseInsensitiveDuplicate(db, nextWord, nextCategory, existing.id)
if (duplicate) {
throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${nextWord} (existing: ${duplicate.word})`)
}
}
const updates: Partial<NewDictionary> = { ...result.patch, updatedAt: Date.now() }
try {
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
@ -217,19 +271,20 @@ class DictionaryService {
search(params: DictionarySearchParams): DictionaryPage {
const db = getDatabase()
const { query, page, pageSize } = params
const pattern = `%${query}%`
// 검색어의 %·_ 는 글자 그대로 찾는다(와일드카드로 해석하지 않는다)
const condition = containsPattern(query).match(dictionary.word)
const totalResult = db
.select({ count: count() })
.from(dictionary)
.where(like(dictionary.word, pattern))
.where(condition)
.get()
const total = totalResult?.count ?? 0
const entries = db
.select()
.from(dictionary)
.where(like(dictionary.word, pattern))
.where(condition)
.orderBy(desc(dictionary.usageCount))
.limit(pageSize)
.offset(page * pageSize)
@ -365,11 +420,8 @@ class DictionaryService {
continue
}
const category = normalizeCategory(pickString(row, ['category']))
const existing = tx
.select({ id: dictionary.id })
.from(dictionary)
.where(and(eq(dictionary.word, word), eq(dictionary.category, category)))
.get()
// 대소문자만 다른 단어도 같은 단어로 본다(서버 유일성과 같은 기준)
const existing = findImportDuplicate(tx, word, category)
if (existing) {
outcome.skipped += 1
continue

View file

@ -3,7 +3,7 @@
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
import { EventEmitter } from 'events'
import { spawn } from 'child_process'
import { spawn, type ChildProcess } from 'child_process'
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
@ -54,16 +54,31 @@ const defaultFfmpegLocator: FfmpegLocator = {
provision: () => getRuntimeProvisioner().ensure('ffmpeg'),
}
/**
* 전사 작업 한 건의 상태. 싱글톤 필드에 두면 취소된 작업의 늦은 정리(임시 폴더 삭제·idle 복귀)가
* 다음 작업을 덮었다 — 작업마다 따로 갖는다.
*/
interface TranscriptionJob {
readonly id: string
readonly tempDir: string
cancelled: boolean
/** 이 작업이 띄운 ffmpeg 프로세스 — 취소하면 모두 종료한다 */
readonly processes: Set<ChildProcess>
}
function cancelledError(): D3ROError {
return new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
class FileTranscriptionService extends EventEmitter {
constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) {
super()
}
private _state: FileTranscriptionState = 'idle'
private _jobId: string | null = null
private _cancelled = false
/** 현재 작업. 취소하면 곧바로 null 이 되고, 남은 정리는 그 작업 객체만 건드린다. */
private _job: TranscriptionJob | null = null
private _progress: FileTranscriptionProgress | null = null
private _tempDir: string | null = null
get state(): FileTranscriptionState {
return this._state
@ -72,7 +87,7 @@ class FileTranscriptionService extends EventEmitter {
getStateInfo(): FileTranscriptionStateInfo {
return {
state: this._state,
jobId: this._jobId,
jobId: this._job?.id ?? null,
progress: this._progress,
}
}
@ -110,45 +125,49 @@ class FileTranscriptionService extends EventEmitter {
if (err instanceof D3ROError) throw err
}
this._jobId = crypto.randomUUID()
this._cancelled = false
this._tempDir = path.join(app.getPath('temp'), `d3ro-ft-${this._jobId}`)
fs.mkdirSync(this._tempDir, { recursive: true })
const jobId = crypto.randomUUID()
const job: TranscriptionJob = {
id: jobId,
tempDir: path.join(app.getPath('temp'), `d3ro-ft-${jobId}`),
cancelled: false,
processes: new Set(),
}
this._job = job
this._progress = null
fs.mkdirSync(job.tempDir, { recursive: true })
const startTime = Date.now()
const fileName = path.basename(filePath)
const throwIfCancelled = (): void => {
if (job.cancelled) throw cancelledError()
}
try {
// Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환
this._setState('converting')
this._setJobState(job, 'converting')
const ffmpegPath = await this._ensureFfmpeg()
if (this._cancelled) {
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
const wavPath = path.join(this._tempDir, 'audio.wav')
await this._convertToWav(ffmpegPath, filePath, wavPath)
if (this._cancelled) {
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
throwIfCancelled()
const wavPath = path.join(job.tempDir, 'audio.wav')
await this._convertToWav(job, ffmpegPath, filePath, wavPath)
throwIfCancelled()
// 오디오 길이 확인
const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath)
const totalDurationSec = await this._probeDuration(job, ffmpegPath, wavPath)
throwIfCancelled()
const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC)
// Phase 2: 청크별 STT
this._setState('transcribing')
this._setJobState(job, 'transcribing')
const allSegments: FileTranscriptionSegment[] = []
const allTexts: string[] = []
let previousContext = ''
for (let i = 0; i < totalChunks; i++) {
if (this._cancelled) {
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
throwIfCancelled()
const startSec = i * CHUNK_DURATION_SEC
const chunkBuffer = await this._extractChunk(ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC)
const chunkBuffer = await this._extractChunk(job, ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC)
throwIfCancelled()
const sttService = getSTTManager()
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
@ -158,6 +177,8 @@ class FileTranscriptionService extends EventEmitter {
initialPrompt: previousContext || undefined,
vadFilter: true,
})
// 인식 중에 취소됐으면 결과를 버린다(STT 호출 자체는 끊을 수 없다)
throwIfCancelled()
if (result.text && result.text.trim().length > 0) {
allTexts.push(result.text.trim())
@ -173,23 +194,24 @@ class FileTranscriptionService extends EventEmitter {
}
}
this._progress = {
jobId: this._jobId!,
const progress: FileTranscriptionProgress = {
jobId,
currentChunk: i + 1,
totalChunks,
percent: Math.round(((i + 1) / totalChunks) * 100),
currentText: result.text?.trim() ?? '',
}
if (this._job === job) this._progress = progress
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, this._progress)
this.emit('progress', this._progress)
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, progress)
this.emit('progress', progress)
}
const fullText = allTexts.join(' ')
const processingTimeMs = Date.now() - startTime
const resultData: FileTranscriptionResult = {
jobId: this._jobId!,
jobId,
filePath,
fileName,
fullText,
@ -225,39 +247,50 @@ class FileTranscriptionService extends EventEmitter {
logger.warn('Failed to save file transcription to history:', err)
}
this._setState('completed')
this._setJobState(job, 'completed')
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.COMPLETE, resultData)
this.emit('complete', resultData)
return resultData
} catch (err) {
if (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled) {
this._setState('idle')
throw err
// 취소된 작업의 실패(ffmpeg 종료 등)는 모두 취소로 본다
if (job.cancelled || (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled)) {
throw cancelledError()
}
this._setState('error')
this._setJobState(job, 'error')
const errorMsg = err instanceof Error ? err.message : String(err)
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.ERROR, { message: errorMsg })
this.emit('error', err)
// 'error' 는 리스너가 없으면 던져진다 — 실패 알림은 'failure' 로 보낸다
this.emit('failure', err)
throw err instanceof D3ROError
? err
: new D3ROError(ErrorCode.FileTranscriptionChunkFailed, errorMsg)
} finally {
this._cleanup()
// 완료/에러 후 idle로 복귀
setTimeout(() => {
this._state = 'idle'
this._jobId = null
this._killProcesses(job)
this._cleanup(job)
// 완료/에러/취소 후 곧바로 idle — 예전엔 1초 뒤 타이머가 작업 확인 없이 idle 로 돌려, 그사이 시작한
// 다음 작업의 상태·jobId·진행률을 지웠다. 이 작업이 아직 현재 작업일 때만 되돌린다.
if (this._job === job) {
this._job = null
this._progress = null
}, 1000)
this._state = 'idle'
}
}
}
/**
* 현재 작업을 취소한다 — 실행 중인 ffmpeg 를 곧바로 종료하고, 서비스는 즉시 idle 이 되어 새 작업을
* 받을 수 있다(취소된 작업의 남은 정리는 그 작업의 임시 폴더만 건드린다).
*/
cancel(): void {
if (this._state !== 'idle') {
this._cancelled = true
logger.info(`File transcription cancelled: ${this._jobId}`)
}
const job = this._job
if (!job) return
job.cancelled = true
this._killProcesses(job)
this._job = null
this._progress = null
this._setState('idle')
logger.info(`File transcription cancelled: ${job.id}`)
}
private _setState(state: FileTranscriptionState): void {
@ -265,6 +298,37 @@ class FileTranscriptionService extends EventEmitter {
this.emit('state-changed', state)
}
/** 현재 작업의 상태만 바꾼다 — 취소된 작업이 뒤늦게 상태를 덮지 않게 */
private _setJobState(job: TranscriptionJob, state: FileTranscriptionState): void {
if (this._job !== job) return
this._setState(state)
}
/** 작업의 ffmpeg 프로세스를 추적한다(취소 시 종료). 이미 취소된 작업이면 곧바로 종료한다. */
private _trackProcess(job: TranscriptionJob, proc: ChildProcess): ChildProcess {
job.processes.add(proc)
const forget = (): void => {
job.processes.delete(proc)
}
proc.on('close', forget)
proc.on('error', forget)
if (job.cancelled) this._killProcess(proc)
return proc
}
private _killProcesses(job: TranscriptionJob): void {
for (const proc of job.processes) this._killProcess(proc)
job.processes.clear()
}
private _killProcess(proc: ChildProcess): void {
try {
if (proc.exitCode === null && !proc.killed) proc.kill()
} catch (err) {
logger.warn(`ffmpeg kill failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
private _sendToRenderer(channel: string, data: unknown): void {
try {
const mainWindow = getMainWindow()
@ -298,13 +362,16 @@ class FileTranscriptionService extends EventEmitter {
/**
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
*/
private _convertToWav(ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
private _convertToWav(job: TranscriptionJob, ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const args = buildConvertToWavArgs(inputPath, outputPath)
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const proc = this._trackProcess(
job,
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
)
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
@ -329,13 +396,16 @@ class FileTranscriptionService extends EventEmitter {
/**
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
*/
private _probeDuration(ffmpegPath: string, wavPath: string): Promise<number> {
private _probeDuration(job: TranscriptionJob, ffmpegPath: string, wavPath: string): Promise<number> {
return new Promise((resolve, reject) => {
// ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나,
// 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출
const args = buildProbeDurationArgs(wavPath)
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const proc = this._trackProcess(
job,
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
)
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
@ -362,6 +432,7 @@ class FileTranscriptionService extends EventEmitter {
* WAV 파일에서 특정 구간을 PCM16 Buffer로 추출
*/
private _extractChunk(
job: TranscriptionJob,
ffmpegPath: string,
wavPath: string,
startSec: number,
@ -370,7 +441,10 @@ class FileTranscriptionService extends EventEmitter {
return new Promise((resolve, reject) => {
const args = buildExtractChunkArgs(wavPath, startSec, durationSec)
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const proc = this._trackProcess(
job,
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
)
const chunks: Buffer[] = []
proc.stdout?.on('data', (data: Buffer) => {
@ -391,20 +465,21 @@ class FileTranscriptionService extends EventEmitter {
})
}
private _cleanup(): void {
if (this._tempDir && fs.existsSync(this._tempDir)) {
/** 이 작업의 임시 폴더만 지운다 */
private _cleanup(job: TranscriptionJob): void {
if (fs.existsSync(job.tempDir)) {
try {
fs.rmSync(this._tempDir, { recursive: true, force: true })
fs.rmSync(job.tempDir, { recursive: true, force: true })
} catch (err) {
logger.warn('Failed to cleanup temp dir:', err)
}
this._tempDir = null
}
}
dispose(): void {
const job = this._job
this.cancel()
this._cleanup()
if (job) this._cleanup(job)
this.removeAllListeners()
}
}

View file

@ -1,7 +1,7 @@
// src/main/services/HistoryService.ts
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
import { eq, desc, and, or, sql, count } from 'drizzle-orm'
import { getDatabase } from '../db'
import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
@ -10,6 +10,7 @@ import { getCloudSyncService } from './CloudSyncService'
import { getInputTelemetryService } from './InputTelemetryService'
import { deleteHistoryLocally } from './history-deletion'
import { computeStreakDays } from './history-stats'
import { containsPattern } from '../db/like-pattern'
import type {
HistoryEntry,
HistoryQueryParams,
@ -96,10 +97,14 @@ class HistoryService {
search(params: HistorySearchParams): HistoryPage {
const db = getDatabase()
const { query, page, pageSize } = params
const pattern = `%${query}%`
const conditions = and(
like(history.originalText, pattern)
// 카드에 보이는 텍스트(다듬은 문장·번역문·제목·요약)로도 찾는다 — 예전엔 원문만 봤다.
// 검색어의 %·_ 는 글자 그대로 찾는다(와일드카드로 해석하지 않는다).
const pattern = containsPattern(query)
const conditions = or(
pattern.match(history.originalText),
pattern.match(history.polishedText),
pattern.match(history.title),
pattern.match(history.summaryText)
)
const totalResult = db
@ -253,9 +258,10 @@ class HistoryService {
if (!text || text.length < 10) return null
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const llm = getPremiumLLMService()
const result = await llm.generate(
// 백엔드 선택은 게이트웨이가 한다 — 예전엔 Premium 에 고정돼, 로컬을 고른 로그인 사용자의 받아쓰기마다
// 원문이 클라우드로 가고 주간 쿼터를 썼다. 로컬이면 로컬 LLM 만 쓴다(없으면 제목 없이 넘어간다).
const { getLlmGateway } = await import('./llm/LlmGateway')
const result = await getLlmGateway().generate(
text.slice(0, 2000),
{
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',

View file

@ -3,15 +3,22 @@
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
import { EventEmitter } from 'events'
import { BrowserWindow, Notification, dialog } from 'electron'
import { BrowserWindow, Notification, app, dialog } from 'electron'
import fs from 'fs'
import { dirname as pathDirname } from 'path'
import { eq, desc, sql } from 'drizzle-orm'
import { dirname as pathDirname, join as pathJoin } from 'path'
import { eq, desc, sql, inArray } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getCloudSyncService } from './CloudSyncService'
import { getMainWindow } from '../windows/WindowManager'
import { getDatabase } from '../db'
import { onAccountScopeChanged } from './account-scope'
import { getLlmGateway } from './llm/LlmGateway'
import { MeetingRecordingSession, type RecordingSnapshot } from './meeting/MeetingRecordingSession'
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
import { selectTranscriptExcerpts } from '@d3ro/core/meeting-llm-input'
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
import type { MeetingChatHistoryTurn, MeetingLiveSession } from '@d3ro/core/meeting-live'
import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
@ -46,7 +53,6 @@ import type {
MeetingGenerateDocParams,
MeetingExportFormat,
MeetingDocGeneratingProgress,
CaptionSegment,
} from '@d3ro/core/types'
const logger = getLogger('MeetingModeService')
@ -57,46 +63,82 @@ const REWRITE_CHUNK_CHARS = 1500
const REWRITE_CHUNK_MAX_TOKENS = 2048
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
const MAX_CHAT_HISTORY_MESSAGES = 20
/** 녹음 중 전사를 DB 에 남기는 주기 — 비정상 종료 시 잃는 양의 상한 */
const TRANSCRIPT_CHECKPOINT_MS = 15_000
/** 중단된 녹음(전사 없음)의 오류 메시지 — 저장용(UI 는 status 로 표시) */
const INTERRUPTED_MESSAGE = 'Recording was interrupted before it was saved'
/**
* 회의 기능이 쓰는 LLM 포트 — 백엔드 선택(local/online, 폴백)은 LlmGateway 한 곳이 정한다.
* 문서 생성·요약 입력은 이 한도(프록시 메시지 한도) 안으로 맞춘다.
*/
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>
}
/** 문서 생성 사용자 메시지의 머리말(LLM 지시문) */
const DOCUMENT_REQUEST_PREFIX = '다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n'
const CONDENSED_NOTE = '(회의가 길어 구간별로 정리한 내용입니다)\n'
/** 채팅 system 프롬프트 머리말 여유 — 지시문·발췌 안내가 들어갈 자리 */
const CHAT_SYSTEM_OVERHEAD_CHARS = 600
/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */
function meetingAudioFilePathSync(sessionId: string): string {
return pathJoin(app.getPath('userData'), 'meeting-audio', `${sessionId}.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`)
return meetingAudioFilePathSync(sessionId)
}
let isShowingMeetingSaveDialog = false
class MeetingModeService extends EventEmitter {
private _state: MeetingModeState = 'idle'
private _sessionId: string | null = null
private _sessionStartedAt: number | null = null
private _segments: CaptionSegment[] = []
private _memos: MeetingMemo[] = []
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
/**
* 진행 중인 녹음. 녹음 한 건의 세그먼트·메모·오디오·구독은 이 객체가 단독으로 소유한다.
* 후처리가 끝날 때까지 남아 있어 getStateInfo 가 세션 id 를 계속 보고한다(실시간 편집 반영용).
*/
private _current: MeetingRecordingSession | null = null
/** 진행 중인 정지 — 동시에 온 정지 요청(두 번 클릭, 로그아웃)은 이 promise 를 함께 기다린다 */
private _stopInFlight: Promise<void> | null = null
private _audioLevelTimer: ReturnType<typeof setInterval> | null = null
private _lastRms = 0
private _checkpointTimer: ReturnType<typeof setInterval> | null = null
private _checkpointedSegments = 0
/** CaptionService session-saved 방지 플래그 */
private _meetingModeActive = false
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
private _audioBuffersForFile: Buffer[] = []
// Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게)
private _chatHistories = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
private _chatAbortController: AbortController | null = null
/** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */
private _chatAbortControllers = new Map<string, AbortController>()
private readonly _unsubscribeScope: () => void
constructor() {
super()
// 이 서비스가 내는 'error' 는 알림용이다 — 듣는 쪽이 없어도 throw 되지 않게 기본 수신자를 둔다
// (EventEmitter 는 리스너 없는 'error' 를 던져 후처리 catch 를 깨뜨렸다).
this.on('error', () => { /* default sink */ })
// 계정 DB 가 바뀔 때마다(로그인·로그아웃) 그 DB 에 남은 중단된 녹음을 정리한다.
this._unsubscribeScope = onAccountScopeChanged(() => {
try {
this.recoverInterruptedSessions()
} catch (err) {
logger.warn(`중단된 회의 정리 실패: ${err instanceof Error ? err.message : String(err)}`)
}
})
}
/** 테스트 전용: 타이머·구독을 푼다 */
disposeForTests(): void {
this._stopTimers()
this._unsubscribeScope()
this.cancelMeetingChat()
this.removeAllListeners()
}
// ── 공개 접근자 ──
@ -105,12 +147,30 @@ class MeetingModeService extends EventEmitter {
}
getStateInfo(): MeetingModeStateInfo {
const session = this._current
return {
state: this._state,
sessionId: this._sessionId,
elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
memoCount: this._memos.length,
segmentCount: this._segments.length,
sessionId: session?.id ?? null,
elapsedMs: session ? session.elapsedMs() : 0,
memoCount: session?.memos.length ?? 0,
segmentCount: session?.segments.length ?? 0,
}
}
/**
* 진행 중인 녹음의 세그먼트·메모 사본. 녹음 화면을 다시 열었을 때(페이지 이동 후 복귀) 그동안의
* 실시간 전사와 메모를 복원한다. 녹음 중이 아니면 null.
*/
getLiveSession(): MeetingLiveSession | null {
const session = this._current
if (!session) return null
return {
sessionId: session.id,
state: this._state,
startedAt: session.startedAt,
elapsedMs: session.elapsedMs(),
segments: session.segments.map((segment) => ({ ...segment })),
memos: session.memos.map((memo) => ({ ...memo })),
}
}
@ -121,26 +181,23 @@ class MeetingModeService extends EventEmitter {
/** 활성 세션, 자막, 오디오 캡처 상태를 강제로 완전 초기화 */
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
this._stopTimers()
const session = this._current
if (session) {
session.detach()
// 아직 녹음 중이던 세션은 지금까지의 전사를 남기고 닫는다(행이 'recording' 으로 굳지 않게).
if (!session.isStopping) {
try {
this._persistSnapshot(session.checkpoint({ includeAudio: true }), { interrupted: true })
} catch (err) {
logger.warn(`forceReset 전사 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
try {
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
// 강제 정지(소유자 무시) 후 구독을 푼다
await captionService.stop().catch(() => null)
if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
}
// 강제 정지(소유자 무시)
await getCaptionService().stop().catch(() => null)
} catch { /* noop */ }
try {
@ -148,11 +205,8 @@ class MeetingModeService extends EventEmitter {
await getAudioCaptureService().stop().catch(() => {})
} catch { /* noop */ }
this._meetingModeActive = false
this._sessionId = null
this._sessionStartedAt = null
this._segments = []
this._memos = []
this._stopInFlight = null
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
}
@ -192,11 +246,6 @@ class MeetingModeService extends EventEmitter {
const sessionId = crypto.randomUUID()
const now = Date.now()
this._sessionId = sessionId
this._sessionStartedAt = now
this._segments = []
this._memos = []
// DB에 세션 생성
const db = getDatabase()
db.insert(meetingSessions).values({
@ -207,13 +256,41 @@ class MeetingModeService extends EventEmitter {
updatedAt: now,
}).run()
// CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
this._segmentHandler = (segment: CaptionSegment) => {
this._segments.push(segment)
this.emit('segment', segment)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
}
captionService.on('segment', this._segmentHandler)
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
const audioCaptureService = getAudioCaptureService()
const session = new MeetingRecordingSession(
sessionId,
now,
{
caption: {
onSegment: (listener) => {
captionService.on('segment', listener)
return () => { captionService.off('segment', listener) }
},
// 회의가 소유한 세션만 멈춘다. 다른 소유자면 false(요약 없음).
stop: () => captionService.stop('meeting'),
},
audio: {
onChunk: (listener) => {
const handler = (payload: { buffer: Buffer; timestamp: number }): void => listener(payload.buffer)
audioCaptureService.on('audio-data', handler)
return () => { audioCaptureService.off('audio-data', handler) }
},
},
onSegment: (segment) => {
this.emit('segment', segment)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
},
onStopError: (err) => {
logger.error(`자막 정지 실패 — 지금까지의 전사로 저장한다: ${err instanceof Error ? err.message : String(err)}`)
},
},
calculateRMS,
)
this._current = session
this._checkpointedSegments = 0
// 세그먼트 구독은 자막 시작 전에 건다 — 첫 문장을 놓치지 않게.
session.attachCaption()
try {
// 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음
@ -226,11 +303,9 @@ class MeetingModeService extends EventEmitter {
})
} catch (err) {
// 시작 실패 시 복원
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
session.detach()
if (this._current === session) this._current = null
this._meetingModeActive = false
this._sessionId = null
this._sessionStartedAt = null
db.update(meetingSessions).set({
status: 'error',
@ -243,20 +318,13 @@ class MeetingModeService extends EventEmitter {
throw err
}
// 오디오 레벨 모니터링 시작
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
const audioCaptureService = getAudioCaptureService()
this._audioBuffersForFile = []
this._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => {
this._lastRms = calculateRMS(payload.buffer)
// Phase 15.5-2: 오디오 버퍼 축적 (WAV 저장용)
this._audioBuffersForFile.push(Buffer.from(payload.buffer))
}
audioCaptureService.on('audio-data', this._audioDataHandler)
// 오디오 레벨 모니터링 + WAV 저장용 버퍼 (Phase 15.5-2)
session.attachAudio()
this._audioLevelTimer = setInterval(() => {
this._sendToRenderer('meetingMode:audioLevel', { level: this._lastRms })
this._sendToRenderer('meetingMode:audioLevel', { level: session.lastRms })
}, 100)
// 전사를 주기적으로 DB 에 남긴다 — 앱이 비정상 종료돼도 회의 전체가 사라지지 않게.
this._checkpointTimer = setInterval(() => this._checkpointTranscript(), TRANSCRIPT_CHECKPOINT_MS)
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
this._sendStateToRenderer()
@ -272,20 +340,15 @@ class MeetingModeService extends EventEmitter {
// ── 메모 추가 ──
async addMemo(content: string): Promise<MeetingMemo> {
if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
const session = this._current
if (this._state !== 'recording' || !session) {
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
}
const memo: MeetingMemo = {
id: crypto.randomUUID(),
sessionId: this._sessionId,
content,
timestampMs: Date.now() - this._sessionStartedAt,
createdAt: Date.now(),
const memo = session.addMemo(crypto.randomUUID(), content)
if (!memo) {
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
}
this._memos.push(memo)
// DB 저장
const db = getDatabase()
db.insert(meetingMemos).values({
@ -307,69 +370,113 @@ class MeetingModeService extends EventEmitter {
// ── 녹음 종료 ──
/**
* 녹음을 멈추고 전사를 저장한다. 정지가 진행 중이면 그 정지를 함께 기다린다 — 정지를 두 번 눌러도
* (또는 로그아웃이 동시에 멈춰도) 정지·저장·완료 알림은 한 번만 일어난다.
*/
async stopRecording(): Promise<void> {
if (this._state !== 'recording') {
if (this._stopInFlight) return this._stopInFlight
const session = this._current
if (this._state !== 'recording' || !session) {
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
}
const sessionId = this._sessionId!
const now = Date.now()
// 오디오 레벨 모니터링 정리
if (this._audioLevelTimer) {
clearInterval(this._audioLevelTimer)
this._audioLevelTimer = null
}
if (this._audioDataHandler) {
const { getAudioCaptureService } = await import('./AudioCaptureService')
getAudioCaptureService().off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
const run = this._stopSession(session)
this._stopInFlight = run
try {
await run
} finally {
if (this._stopInFlight === run) this._stopInFlight = null
}
}
// CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다)
// 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다.
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
private async _stopSession(session: MeetingRecordingSession): Promise<void> {
this._stopTimers()
// 정지(마지막 문장 flush)가 끝날 때까지 '처리 중'으로 보인다 — 정지 버튼이 사라져 다시 누를 수 없다.
this._setState('processing')
this._sendStateToRenderer()
const captionSummary = await captionService.stop('meeting')
const snapshot = await session.stop()
if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
}
// 혹시 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다
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()
db.update(meetingSessions).set({
endedAt: now,
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
endedAt: snapshot.endedAt,
durationMs: snapshot.durationMs,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, snapshot.id)).run()
getCloudSyncService().pushOne('meetings', snapshot.id)
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
logger.info(`회의 녹음 종료: sessionId=${snapshot.id}, segments=${snapshot.segments.length}`)
// 후처리 시작 (비동기)
this._runPostProcessing().catch((err: unknown) => {
logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
})
await this._runPostProcessing(snapshot)
}
/**
* 앱 종료(ShutdownRegistry)용. 녹음 중이면 지금까지의 전사·녹음을 곧바로 저장하고(자막 flush 를
* 기다리지 않는다 — 종료 예산 안에 끝나야 한다), 자막·마이크를 멈춘다. 알림·렌더러 전송은 하지 않는다.
*/
async shutdown(): Promise<void> {
this._stopTimers()
const session = this._current
if (!session) return
if (!session.isStopping) {
try {
this._persistSnapshot(session.checkpoint({ includeAudio: true }), { interrupted: false })
logger.info(`종료 중 회의 저장: sessionId=${session.id}`)
} catch (err) {
logger.error(`종료 중 회의 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
session.detach()
try {
const { getCaptionService } = await import('./CaptionService')
await getCaptionService().stop().catch(() => null)
} catch { /* noop */ }
this._resetSession()
this._state = 'idle'
}
/**
* 이 DB 에 'recording'/'processing' 으로 남은 회의(앱 강제 종료·정전 등)를 닫는다.
* 저장된 전사가 있으면 'completed', 없으면 'error'. 진행 중인 녹음은 건드리지 않는다.
* @returns 정리한 회의 수
*/
recoverInterruptedSessions(): number {
let db: ReturnType<typeof getDatabase>
try {
db = getDatabase()
} catch {
return 0
}
const liveId = this._current?.id ?? null
const rows = db.select().from(meetingSessions).where(inArray(meetingSessions.status, ['recording', 'processing'])).all()
let recovered = 0
for (const row of rows) {
if (row.id === liveId) continue
const now = Date.now()
const endedAt = row.endedAt ?? row.updatedAt ?? now
const hasTranscript = !!row.rawTranscript && row.rawTranscript.trim().length > 0
db.update(meetingSessions).set({
status: hasTranscript ? 'completed' : 'error',
endedAt,
durationMs: row.durationMs ?? Math.max(0, endedAt - row.startedAt),
errorMessage: hasTranscript ? row.errorMessage : (row.errorMessage ?? INTERRUPTED_MESSAGE),
updatedAt: now,
}).where(eq(meetingSessions.id, row.id)).run()
getCloudSyncService().pushOne('meetings', row.id)
recovered++
}
if (recovered > 0) logger.warn(`중단된 회의 ${recovered}건을 정리했다`)
return recovered
}
// ── 후처리 파이프라인 ──
private async _runPostProcessing(): Promise<void> {
/** 불변 스냅샷만 읽는다 — 서비스 필드(다른 호출이 초기화할 수 있다)에 기대지 않는다. */
private async _runPostProcessing(snapshot: RecordingSnapshot): Promise<void> {
this._setState('processing')
this._sendStateToRenderer()
const sessionId = this._sessionId!
const sessionId = snapshot.id
const db = getDatabase()
try {
@ -381,41 +488,22 @@ class MeetingModeService extends EventEmitter {
// Step 1: 전사 텍스트 합산
this._sendProgress(sessionId, 'merging', 40)
// 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다
const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0
const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt)
// Step 2: DB 저장 (rawTranscript + completed)
// Step 2: DB 저장 (rawTranscript + completed) + Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
this._sendProgress(sessionId, 'saving', 80)
const now = Date.now()
db.update(meetingSessions).set({
status: 'completed',
rawTranscript,
sttModel: configGet('sttModelId') as string | undefined,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
if (this._audioBuffersForFile.length > 0) {
try {
const wavPath = await meetingAudioFilePath(sessionId)
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
getCloudSyncService().pushOne('meeting_audio', sessionId)
logger.info(`회의 오디오 저장: ${wavPath}`)
} catch (err) {
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
this._audioBuffersForFile = []
this._persistSnapshot(snapshot, { interrupted: false })
// Step 3: 알림
this._sendProgress(sessionId, 'notifying', 95)
new Notification({
title: '녹음 완료',
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
}).show()
// 알림 실패는 저장된 회의를 실패로 만들지 않는다
try {
new Notification({
title: '녹음 완료',
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
}).show()
} catch (err) {
logger.warn(`완료 알림 실패: ${err instanceof Error ? err.message : String(err)}`)
}
// 완료 이벤트
this._sendProgress(sessionId, 'notifying', 100)
@ -425,21 +513,21 @@ class MeetingModeService extends EventEmitter {
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
}
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
this._finishSession(snapshot.id)
logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`)
} catch (err) {
db.update(meetingSessions).set({
status: 'error',
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
try {
db.update(meetingSessions).set({
status: 'error',
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
getCloudSyncService().pushOne('meetings', sessionId)
} catch (dbErr) {
logger.error(`후처리 오류 기록 실패: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`)
}
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
this._finishSession(snapshot.id)
const d3roErr = new D3ROError(
ErrorCode.MeetingProcessingFailed,
@ -454,6 +542,76 @@ class MeetingModeService extends EventEmitter {
}
}
/**
* 스냅샷(전사·종료 시각·녹음)을 DB·파일에 쓴다. 동기 — 앱 종료 예산 안에서도 끝난다.
* interrupted 면 전사가 없을 때 'error' 로 닫는다.
*/
private _persistSnapshot(snapshot: RecordingSnapshot, options: { interrupted: boolean }): void {
const db = getDatabase()
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
const hasTranscript = rawTranscript.trim().length > 0
const status = options.interrupted && !hasTranscript ? 'error' : 'completed'
db.update(meetingSessions).set({
status,
rawTranscript,
endedAt: snapshot.endedAt,
durationMs: snapshot.durationMs,
sttModel: configGet('sttModelId') as string | undefined,
...(status === 'error' ? { errorMessage: INTERRUPTED_MESSAGE } : {}),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, snapshot.id)).run()
getCloudSyncService().pushOne('meetings', snapshot.id)
if (snapshot.pcm.length > 0) {
try {
const wavPath = meetingAudioFilePathSync(snapshot.id)
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
this._saveWav(wavPath, snapshot.pcm)
getCloudSyncService().pushOne('meeting_audio', snapshot.id)
logger.info(`회의 오디오 저장: ${wavPath}`)
} catch (err) {
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
/** 녹음 중 주기 저장 — 새 세그먼트가 있을 때만 rawTranscript 를 갱신한다(푸시는 정지 때). */
private _checkpointTranscript(): void {
const session = this._current
if (!session || session.isStopping || this._state !== 'recording') return
if (session.segments.length === this._checkpointedSegments) return
try {
const snapshot = session.checkpoint({ includeAudio: false })
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
getDatabase().update(meetingSessions).set({
rawTranscript,
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, snapshot.id)).run()
this._checkpointedSegments = snapshot.segments.length
} catch (err) {
logger.warn(`전사 중간 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
}
}
private _stopTimers(): void {
if (this._audioLevelTimer) {
clearInterval(this._audioLevelTimer)
this._audioLevelTimer = null
}
if (this._checkpointTimer) {
clearInterval(this._checkpointTimer)
this._checkpointTimer = null
}
}
/** 이 세션의 후처리가 끝났다 — 그사이 새 녹음이 시작됐으면 그것은 건드리지 않는다. */
private _finishSession(sessionId: string): void {
if (this._current && this._current.id !== sessionId) return
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
}
// ── 세션 조회 ──
getSessions(page: number, pageSize: number): MeetingSessionPage {
@ -518,6 +676,7 @@ class MeetingModeService extends EventEmitter {
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
getCloudSyncService().pushDelete('meetings', sessionId)
this.cancelMeetingChat(sessionId)
this._chatHistories.delete(sessionId)
// 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort)
void this._deleteMeetingAudio(sessionId)
@ -604,12 +763,11 @@ class MeetingModeService extends EventEmitter {
}
private _resetSession(): void {
this._sessionId = null
this._sessionStartedAt = null
this._segments = []
this._memos = []
this._stopTimers()
this._current?.detach()
this._current = null
this._checkpointedSegments = 0
this._meetingModeActive = false
this._audioBuffersForFile = []
}
/** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */
@ -657,27 +815,17 @@ class MeetingModeService extends EventEmitter {
}
/**
* 회의 문서/폴리시/채팅/화자추정 LLM.
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
* 회의 문서/폴리시/화자추정 LLM — LlmGateway(백엔드 선택 정책의 정본)에 위임한다.
* llmBackend==='local' 이면 로컬, online 이면 Premium(불가·실패 시 로컬 폴백).
*/
private async _resolveMeetingLlm(): Promise<MeetingLlm> {
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 gateway = getLlmGateway()
return {
generate: (text, options) =>
gateway.generate(text, options, {
onFallback: (reason) => logger.warn(`Meeting LLM fallback: ${reason}`),
}),
}
const { getLocalLLMService } = await import('./LocalLLMService')
return getLocalLLMService()
}
// ── Phase 14.5: 전사 수정 ──
@ -746,8 +894,17 @@ class MeetingModeService extends EventEmitter {
const memoSection = formattedMemos
? `\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}`
: ''
// 긴 회의는 한 번에 보내면 프록시 한도(8,000자)를 넘어 거부된다 — 구간별로 줄인 뒤 보낸다.
const budget =
LLM_PROXY_CHAT_LIMITS.maxMessageChars - DOCUMENT_REQUEST_PREFIX.length - CONDENSED_NOTE.length - memoSection.length
const fitted = await condenseTranscriptToBudget(llmService, transcript, budget)
if (fitted.condensed) {
logger.info(`문서 생성: 전사를 ${fitted.rounds}회 구간 정리해 한도 안에 맞췄다 (sessionId=${params.sessionId})`)
}
sendProgress(60)
const transcriptSection = fitted.condensed ? `${CONDENSED_NOTE}${fitted.text}` : fitted.text
const result = await llmService.generate(
`다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n${transcript}${memoSection}`,
`${DOCUMENT_REQUEST_PREFIX}${transcriptSection}${memoSection}`,
{
systemPrompt,
temperature: 0.3,
@ -1092,13 +1249,33 @@ class MeetingModeService extends EventEmitter {
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
const systemPrompt = `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
// system 은 한도(8,000자)에서 앞부분만 남기고 잘린다 — 예전엔 긴 회의의 뒷부분이 조용히 빠져
// 그 부분 질문에 "확인되지 않습니다"라고 답했다. 한도를 넘으면 질문과 관련된 구간을 골라 넣고,
// 일부만 봤다는 사실을 모델이 답에 밝히게 한다.
const excerpt = selectTranscriptExcerpts(
transcript,
userMessage,
LLM_PROXY_CHAT_LIMITS.maxSystemChars - CHAT_SYSTEM_OVERHEAD_CHARS,
)
const systemPrompt = excerpt.complete
? `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
아래 회의 전사 내용을 참고하여 사용자의 질문에 정확하게 답변하세요.
전사 내용에 없는 것은 "전사 내용에서 확인되지 않습니다"라고 답하세요.
## 회의 전사
${transcript}`
${excerpt.text}`
: `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
회의가 길어 전체 ${excerpt.totalChunks}개 구간 중 질문과 관련된 ${excerpt.includedChunks}개 구간의 발췌만 아래에 있습니다.
발췌를 참고하여 사용자의 질문에 정확하게 답변하세요.
발췌에 없는 것은 "제공된 전사 발췌에서는 확인되지 않습니다(회의가 길어 일부 구간만 참고했습니다)"라고 답하세요.
## 회의 전사 (발췌)
${excerpt.text}`
if (!excerpt.complete) {
logger.info(
`회의 채팅: 전사 ${excerpt.totalChunks}개 구간 중 ${excerpt.includedChunks}개만 발췌해 보냈다 (sessionId=${sessionId})`,
)
}
// 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다
// (실패·취소 시 짝 없는 사용자 턴이 남지 않게).
@ -1109,16 +1286,19 @@ ${transcript}`
{ role: 'user' as const, content: userMessage },
]
// 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다
this._chatAbortController?.abort()
// 같은 회의에서 흐르던 채팅만 끊는다 — 다른 회의의 채팅은 건드리지 않는다
this._chatAbortControllers.get(sessionId)?.abort()
const controller = new AbortController()
this._chatAbortController = controller
this._chatAbortControllers.set(sessionId, controller)
const signal = controller.signal
let fullResponse = ''
try {
const llm = await this._resolveMeetingLlm()
const generator = llm.chatStream(messages, { temperature: 0.5 })
const { generator } = await getLlmGateway().openChatStream(
messages,
{ signal, local: { temperature: 0.5 } },
{ onFallback: (reason) => logger.warn(`Meeting chat fallback: ${reason}`) },
)
for await (const token of generator) {
if (signal.aborted) break
@ -1148,15 +1328,19 @@ ${transcript}`
})
}
} finally {
if (this._chatAbortController === controller) this._chatAbortController = null
if (this._chatAbortControllers.get(sessionId) === controller) this._chatAbortControllers.delete(sessionId)
}
}
cancelMeetingChat(): void {
if (this._chatAbortController) {
this._chatAbortController.abort()
this._chatAbortController = null
/** 회의 채팅을 멈춘다. sessionId 를 주면 그 회의만, 생략하면 전부. */
cancelMeetingChat(sessionId?: string): void {
if (sessionId) {
this._chatAbortControllers.get(sessionId)?.abort()
this._chatAbortControllers.delete(sessionId)
return
}
for (const controller of this._chatAbortControllers.values()) controller.abort()
this._chatAbortControllers.clear()
}
/** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */
@ -1168,8 +1352,8 @@ ${transcript}`
}
}
/** 테스트/진단용: 특정 회의의 채팅 히스토리 사본 */
getMeetingChatHistory(sessionId: string): Array<{ role: 'user' | 'assistant'; content: string }> {
/** 특정 회의의 채팅 히스토리 사본 — 채팅 패널을 다시 열 때 main 이 기억하는 대화를 보여 준다 */
getMeetingChatHistory(sessionId: string): MeetingChatHistoryTurn[] {
return [...(this._chatHistories.get(sessionId) ?? [])]
}
@ -1314,6 +1498,8 @@ export function getMeetingModeService(): MeetingModeService {
}
export function resetMeetingModeServiceForTests(): void {
if (instance) instance.removeAllListeners()
if (instance) instance.disposeForTests()
instance = null
}
export type { MeetingModeService }

View file

@ -8,8 +8,10 @@ import fs from 'fs'
import { app, dialog } from 'electron'
import { eq } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService'
import { getLlmGateway } from './llm/LlmGateway'
import { getCloudSyncService } from './CloudSyncService'
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
import { getDatabase } from '../db'
import { history } from '../db/schema'
import { getMainWindow } from '../windows/WindowManager'
@ -76,9 +78,22 @@ class MeetingSummaryService extends EventEmitter {
this._sendProgress(historyId, 'generating')
try {
// LLM 요약 생성
const llmService = getPremiumLLMService()
const result = await llmService.generate(transcript, {
// LLM 요약 생성 — 백엔드 선택은 게이트웨이가 한다(llmBackend='local' 이면 로컬 Ollama).
// 예전엔 설정과 무관하게 Premium 에 고정돼, 로컬을 고른 로그인 사용자의 자막 전사가 클라우드로 가고
// 쿼터를 썼으며, 로그인하지 않은 사용자는 요약을 전혀 받지 못했다.
const gateway = getLlmGateway()
const llm = {
generate: (text: string, options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }) =>
gateway.generate(text, options, {
onFallback: (reason) => logger.warn(`Meeting summary LLM fallback: ${reason}`),
}),
}
// 긴 전사는 한 메시지 한도(8,000자)를 넘어 거부된다 — 구간별로 정리한 뒤 요약한다.
const fitted = await condenseTranscriptToBudget(llm, transcript, LLM_PROXY_CHAT_LIMITS.maxMessageChars)
if (fitted.condensed) {
logger.info(`Summary input condensed in ${fitted.rounds} round(s): ${historyId}`)
}
const result = await llm.generate(fitted.text, {
systemPrompt: MEETING_SUMMARY_PROMPT,
temperature: 0.3,
})

View file

@ -10,7 +10,7 @@ import { memoTags, history } from '../db/schema'
import type { MemoTagRow } from '../db/schema'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync'
import { MEMO_TAG_MAX_LENGTH, memoTagKey, normalizeMemoTag, validateMemoTag } from '@d3ro/core/memo-tag-policy'
import { buildMemoExportFileName } from './memo/memo-export-file-name'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type {
@ -47,9 +47,18 @@ class MemoService {
addTag(historyId: string, tag: string): MemoTag {
const db = getDatabase()
const normalizedTag = normalizeMemoTag(tag)
if (!normalizedTag) {
const problem = validateMemoTag(tag)
if (problem === 'empty') {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Memo tag is empty')
}
if (problem === 'too-long') {
// 서버 CHECK(1~80자)를 넘는 태그는 저장하지 않는다 — 올라가지 못하고 outbox 에 영구 보관됐다.
throw new D3ROError(
ErrorCode.ConfigInvalidValue,
`Memo tag is too long (max ${MEMO_TAG_MAX_LENGTH} characters)`,
{ field: 'tag', max: MEMO_TAG_MAX_LENGTH }
)
}
// 중복 검사
const existing = db

View file

@ -50,9 +50,14 @@ class OnlineLLMService extends EventEmitter {
const token = this._ensureAuth()
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
// cancelGeneration() 이 실제로 이 요청을 끊도록 컨트롤러를 등록한다(예전엔 한 번도 할당되지 않아 no-op).
this._abortController?.abort()
const controller = new AbortController()
this._abortController = controller
try {
const response = await fetch(`${apiUrl}/api/llm/generate`, {
signal: controller.signal,
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -89,10 +94,15 @@ class OnlineLLMService extends EventEmitter {
return data.text.trim()
} catch (err) {
if (err instanceof D3ROError) throw err
if (controller.signal.aborted) {
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'Online LLM request cancelled')
}
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`온라인 API 서버 연결 실패: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
if (this._abortController === controller) this._abortController = null
}
}

View file

@ -19,6 +19,7 @@ import { parseAnthropicSSE, AnthropicStreamError } from '../utils/sse-parser'
import {
fitChatRequest,
LLM_PROXY_CHAT_LIMITS,
singleTurnLimitViolation,
toChatRequest,
type ChatRequest,
type ChatStreamOptions,
@ -70,6 +71,27 @@ interface PremiumCall {
close: () => void
}
/** 단일 턴 호출(processText/generate)의 호출 단위 옵션 — 취소·기한은 이 호출에만 적용된다. */
export interface PremiumCallOptions {
signal?: AbortSignal
timeoutMs?: number
}
/**
* 단일 턴 요청이 llm-proxy 한도를 넘으면 보내지 않고 LLMPromptTooLong 을 던진다.
* 서버는 400('Invalid content length')으로 거부하므로 보내 봐야 실패와 왕복 시간만 쓴다.
*/
export function assertSingleTurnWithinProxyLimits(input: { system?: string; message: string }): void {
const violation = singleTurnLimitViolation(input, LLM_PROXY_CHAT_LIMITS)
if (violation === null) return
const max = violation === 'system' ? LLM_PROXY_CHAT_LIMITS.maxSystemChars : LLM_PROXY_CHAT_LIMITS.maxMessageChars
throw new D3ROError(
ErrorCode.LLMPromptTooLong,
`Premium LLM ${violation} is too long (max ${max} characters)`,
{ field: violation, max },
)
}
/** llm-proxy 출력 토큰 상한 (llm-contract MAX_OUTPUT_TOKENS) */
const PROXY_MAX_OUTPUT_TOKENS = 4096
const DEFAULT_CHAT_MAX_TOKENS = 2048
@ -213,10 +235,12 @@ class PremiumLLMService extends EventEmitter {
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
options?: PremiumCallOptions,
): Promise<string> {
this._ensureAuth()
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
assertSingleTurnWithinProxyLimits({ system: systemPrompt, message: text })
const body: LlmProxyRequest = {
messages: [{ role: 'user', content: text }],
@ -226,7 +250,7 @@ class PremiumLLMService extends EventEmitter {
}
try {
const response = await this._invokeProxy(body)
const response = await this._invokeSingleTurn(body, options)
// Claude 응답 → text 추출
const firstBlock = response.content?.[0]
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
@ -323,15 +347,19 @@ class PremiumLLMService extends EventEmitter {
*/
async generate(
text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number } & PremiumCallOptions,
): Promise<{ text: string }> {
this._ensureAuth()
const response = await this._invokeProxy({
messages: [{ role: 'user', content: text }],
system: options?.systemPrompt,
max_tokens: options?.maxTokens ?? 2048,
stream: false,
})
assertSingleTurnWithinProxyLimits({ system: options?.systemPrompt, message: text })
const response = await this._invokeSingleTurn(
{
messages: [{ role: 'user', content: text }],
system: options?.systemPrompt,
max_tokens: options?.maxTokens ?? 2048,
stream: false,
},
options,
)
const firstBlock = response.content?.[0]
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM generate returned empty content')
@ -356,6 +384,24 @@ class PremiumLLMService extends EventEmitter {
// ── 내부: Edge Function 호출 ─────────────────────────────
/**
* 단일 턴 호출도 호출 단위 AbortController·기한을 갖는다 — 예전엔 _activeCalls 에 등록되지 않아
* cancelGeneration() 도 호출자의 signal 도 진행 중인 요청을 멈추지 못했다(응답 없는 프록시에서 체인이 멈춤).
*/
private async _invokeSingleTurn(body: LlmProxyRequest, options?: PremiumCallOptions): Promise<ClaudeMessageResponse> {
const call = this._beginCall(options?.signal, resolveTimeoutMs(options?.timeoutMs))
try {
if (call.controller.signal.aborted) {
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'Premium LLM generation cancelled')
}
return await this._invokeProxy(body, call.controller.signal)
} catch (err) {
throw this._toChatError(err, call)
} finally {
call.close()
}
}
private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> {
const cloud = getCloudSyncService()

View file

@ -4,7 +4,6 @@
import { EventEmitter } from 'events'
import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService'
import { getLocalSTTService } from './LocalSTTService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getTTSPlaybackService } from './TTSPlaybackService'
@ -37,6 +36,12 @@ class VoiceConversationService extends EventEmitter {
private _audioBuffers: Buffer[] = []
private _audioListenerBound = false
private _audioLevelListenerBound = false
/**
* 이 서비스가 AudioCaptureService.start() 로 잡은 마이크가 있는가.
* 캡처는 참조 카운트라 start/stop 짝이 맞아야 한다 — 예전엔 텍스트 턴이 멈추지 않은 채 다시 start 해
* 카운트가 쌓였고, 세션을 끝내도 마이크(SoX)가 계속 열려 있었다.
*/
private _micHeld = false
private _responseAbortController: AbortController | null = null
get state(): ConversationState {
@ -106,9 +111,10 @@ class VoiceConversationService extends EventEmitter {
if (!this._isActive) return
this._stopListening()
// 이 대화의 응답만 멈춘다 — signal 이 LLM 호출까지 전달된다(전역 cancelGeneration 은 다른 기능의
// 진행 중 호출까지 끊었다).
this._responseAbortController?.abort()
getTTSPlaybackService().stop()
getPremiumLLMService().cancelGeneration()
this._isActive = false
this._setState('idle')
@ -131,11 +137,9 @@ class VoiceConversationService extends EventEmitter {
*/
cancelResponse(): void {
this._responseAbortController?.abort()
getPremiumLLMService().cancelGeneration()
getTTSPlaybackService().stop()
if (this._isActive && this._state !== 'listening') {
this._setState('listening')
this._startListening()
this._resumeListening()
}
}
@ -168,10 +172,14 @@ class VoiceConversationService extends EventEmitter {
this._audioLevelListenerBound = true
}
audioService.start().catch((err) => {
logger.error('Failed to start audio capture for conversation:', err)
this._emitError('stt', 'Failed to start microphone')
})
if (!this._micHeld) {
this._micHeld = true
audioService.start().catch((err) => {
this._micHeld = false
logger.error('Failed to start audio capture for conversation:', err)
this._emitError('stt', 'Failed to start microphone')
})
}
// 녹음 시작 사운드 (fire-and-forget)
getSoundEffectService().play('recording-start')
@ -187,10 +195,20 @@ class VoiceConversationService extends EventEmitter {
audioService.off('audio-level', this._onAudioLevel)
this._audioLevelListenerBound = false
}
audioService.stop().catch(() => { /* ignore */ })
if (this._micHeld) {
this._micHeld = false
audioService.stop().catch(() => { /* ignore */ })
}
this._audioBuffers = []
}
/** 세션이 살아 있으면 listening 으로 돌아가 다시 듣는다. */
private _resumeListening(): void {
if (!this._isActive) return
this._setState('listening')
this._startListening()
}
private _onAudioData = (payload: { buffer: Buffer }): void => {
if (this._state !== 'listening') return
this._audioBuffers.push(payload.buffer)
@ -229,36 +247,37 @@ class VoiceConversationService extends EventEmitter {
// Bug 13: 너무 짧으면 조용히 listening 복귀 대신 에러 피드백.
const minBytes = 16000 * 2 * 0.5
if (audioBuffer.length < minBytes) {
// 먼저 듣기 상태로 돌린 뒤 알린다 — 알림이 실패해도 세션이 'thinking' 에 굳지 않게.
this._resumeListening()
this._emitError('stt', 'No speech detected. Please speak and try again.')
this._setState('listening')
this._startListening()
return
}
let result: { text: string }
try {
// STT
const sttService = getLocalSTTService()
const language = (configGet('sttLanguage') as string | undefined) ?? 'auto'
const result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
if (!this._isActive) return
if (!result.text || result.text.trim().length === 0) {
// Bug 13: VAD가 전체 오디오를 무음 판정한 경우에도 사용자 피드백.
this._resumeListening()
this._emitError('stt', 'No speech detected. Check microphone and try again.')
this._setState('listening')
this._startListening()
return
}
await this._processUserMessage(result.text.trim())
} catch (err) {
if (!this._isActive) return
logger.error('STT failed in conversation:', err)
this._resumeListening()
this._emitError('stt', err instanceof Error ? err.message : 'STT failed')
this._setState('listening')
this._startListening()
return
}
// LLM 단계의 실패는 _processUserMessage 가 'llm' 으로 알린다 — STT 오류로 잘못 다시 알리지 않게 try 밖에서 부른다.
await this._processUserMessage(result.text.trim())
}
private async _processUserMessage(text: string): Promise<void> {
@ -268,6 +287,8 @@ class VoiceConversationService extends EventEmitter {
const responseAbortController = new AbortController()
this._responseAbortController = responseAbortController
// 응답하는 동안은 듣지 않는다(텍스트 턴 포함) — 마이크를 놓아 캡처 참조 카운트를 맞춘다.
this._stopListening()
// 사용자 메시지 추가
const userMsg: ConversationMessage = {
@ -365,19 +386,13 @@ class VoiceConversationService extends EventEmitter {
}
// 재생 완료 → 다시 listening
if (this._isActive) {
this._setState('listening')
this._startListening()
}
this._resumeListening()
} catch (err) {
if (responseAbortController.signal.aborted) return
logger.error('LLM chat failed in conversation:', err)
this._resumeListening()
this._emitError('llm', err instanceof Error ? err.message : 'LLM failed')
if (this._isActive) {
this._setState('listening')
this._startListening()
}
} finally {
if (this._responseAbortController === responseAbortController) {
this._responseAbortController = null
@ -440,12 +455,21 @@ class VoiceConversationService extends EventEmitter {
this._messages = pairs
}
/**
* 오류를 렌더러에 알리고 에러음을 낸다. 'error' 가 아니라 'conversation-error' 로 내보낸다 —
* 리스너 없는 'error' 는 EventEmitter 가 던져서, 뒤따르는 상태 복구가 실행되지 않고 세션이
* 'thinking' 에 마이크가 꺼진 채 굳었다.
*/
private _emitError(phase: ConversationError['phase'], message: string): void {
const error: ConversationError = { message, phase }
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.ERROR, error)
this.emit('error', error)
this.emit('conversation-error', error)
// 에러 사운드 (fire-and-forget)
getSoundEffectService().play('error')
try {
getSoundEffectService().play('error')
} catch {
// 효과음 실패는 무시
}
}
private _sendToRenderer(channel: string, data: unknown): void {

View file

@ -0,0 +1,89 @@
// src/main/services/caption/CaptionAudioSources.ts
// 자막 세션의 오디오 소스(마이크·시스템 소리) 획득과 해제를 한 곳에서 맡는다.
//
// 예전엔 획득·해제가 CaptionService.start()/stop() 에 인라인으로 흩어져 있었고, stop() 의 flush 가
// 실패하면 해제 코드에 닿지 못해 마이크(SoX)가 열린 채 서비스가 'stopping' 에 굳었다.
// release() 는 멱등이고 어떤 단계가 실패해도 나머지를 계속 해제한다 — stop() 은 finally 에서 부른다.
/** 마이크 캡처(AudioCaptureService)에 대한 최소 계약 — 캡처는 참조 카운트라 start/stop 짝이 맞아야 한다 */
export interface MicCapturePort {
/** PCM 청크 구독 — 해지 함수를 돌려준다 */
onChunk(listener: (buffer: Buffer) => void): () => void
start(): Promise<void>
stop(): Promise<void>
}
/** 시스템 소리 캡처(렌더러가 수행) 요청 */
export interface SystemAudioPort {
requestStart(): void
requestStop(): void
}
export type CaptionAudioSource = 'mic' | 'system' | 'both'
export interface CaptionAudioSourcesDeps {
mic: () => MicCapturePort
system: SystemAudioPort
/** 해제 중 실패 — 해제는 계속된다 */
onReleaseError?: (stage: 'mic' | 'system', error: unknown) => void
}
export class CaptionAudioSources {
private _unsubscribeMic: (() => void) | null = null
private _mic: MicCapturePort | null = null
/** 마이크 start() 를 불렀는가 — stop() 은 이때만 부른다(참조 카운트 짝) */
private _micStarted = false
private _systemRequested = false
constructor(private readonly _deps: CaptionAudioSourcesDeps) {}
/**
* 소스를 잡는다. 마이크는 구독을 먼저 걸고 캡처를 시작한다(첫 청크를 놓치지 않게).
* 실패하면 throw 한다 — 호출자는 release() 로 잡은 만큼 되돌린다.
*/
async acquire(source: CaptionAudioSource, handlers: { onMicChunk: (buffer: Buffer) => void }): Promise<void> {
if (source === 'mic' || source === 'both') {
const mic = this._deps.mic()
this._mic = mic
this._unsubscribeMic = mic.onChunk(handlers.onMicChunk)
this._micStarted = true
await mic.start()
}
if (source === 'system' || source === 'both') {
this._systemRequested = true
this._deps.system.requestStart()
}
}
get holdsMic(): boolean {
return this._micStarted
}
/** 잡은 소스를 모두 놓는다. 여러 번 불러도 안전하고, 한 단계가 실패해도 나머지를 계속한다. */
async release(): Promise<void> {
this._unsubscribeMic?.()
this._unsubscribeMic = null
const mic = this._mic
const micStarted = this._micStarted
this._mic = null
this._micStarted = false
if (mic && micStarted) {
try {
await mic.stop()
} catch (err) {
this._deps.onReleaseError?.('mic', err)
}
}
// 시스템 소리 캡처 중지 요청은 렌더러에서 멱등이다 — 요청한 적이 없어도 보내 둔다(남은 캡처 정리).
this._systemRequested = false
try {
this._deps.system.requestStop()
} catch (err) {
this._deps.onReleaseError?.('system', err)
}
}
get systemRequested(): boolean {
return this._systemRequested
}
}

View file

@ -93,7 +93,12 @@ export class StreamingCaptionTrack {
try {
await job()
} catch (error) {
this._deps.onError(error)
// 보고 경로가 던져도 트랙(과 이를 기다리는 flush/stop)은 멈추지 않는다
try {
this._deps.onError(error)
} catch {
// ignore — onError 는 알림일 뿐이다
}
} finally {
this._busy = false
}

View file

@ -46,8 +46,18 @@ export type LlmBackendKind = 'local' | 'premium'
export interface LlmCallHooks {
/** Premium 을 쓰려 했으나 로컬로 넘어갔을 때 사유와 함께 호출된다. */
onFallback?: (reason: string) => void
/**
* 이 호출만 취소한다. 어댑터의 전역 cancelGeneration() 과 달리 다른 기능의 진행 중 호출은 건드리지 않는다.
* 취소·기한 초과는 로컬로 폴백하지 않고 그대로 전파된다(취소가 로컬 재실행으로 바뀌지 않게).
*/
signal?: AbortSignal
/** 이 호출의 기한(ms) — Premium 단일 턴 호출에 적용된다 */
timeoutMs?: number
}
/** 호출 단위 옵션(취소·기한·폴백 알림) — LlmCallHooks 와 같다 */
export type LlmCallOptions = LlmCallHooks
export interface LlmChatStreamHandle {
generator: AsyncGenerator<string, string>
backend: LlmBackendKind
@ -73,24 +83,42 @@ export interface LlmGateway {
options: { signal: AbortSignal; local?: LocalChatStreamOptions },
hooks?: LlmCallHooks,
): Promise<LlmChatStreamHandle>
/** 두 어댑터의 진행 중 생성을 모두 취소한다. */
/** 두 어댑터의 진행 중 생성을 모두 취소한다 — 앱 종료·전역 취소 전용. 기능 단위 취소는 signal 을 쓴다. */
cancel(): void
}
// ── 어댑터 계약 (기존 Premium/Local 서비스가 구조적으로 만족) ─────
/** Premium 단일 턴 호출의 호출 단위 옵션 */
export interface PremiumCallAdapterOptions {
signal?: AbortSignal
timeoutMs?: number
}
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>
processText(
text: string,
action: LLMAction,
targetLanguage?: string,
customPrompt?: string,
options?: PremiumCallAdapterOptions,
): Promise<string>
generate(text: string, options?: LlmGenerateOptions & PremiumCallAdapterOptions): Promise<{ text: string }>
chatStream(
messages: LlmChatMessage[],
options?: { temperature?: number; signal?: AbortSignal },
): 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 }>
generate(
text: string,
options?: LlmGenerateOptions & { signal?: AbortSignal; timeoutMs?: number },
): Promise<{ text: string; model?: string }>
chatStream(
messages: LlmChatMessage[],
options?: LocalChatStreamOptions & { signal?: AbortSignal },
@ -112,6 +140,30 @@ function describe(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/** 취소·기한 초과는 폴백하지 않는다 — 사용자가 멈춘 호출을 로컬에서 다시 돌리지 않게. */
function isCancellation(err: unknown, signal: AbortSignal | undefined): boolean {
if (signal?.aborted) return true
if (err instanceof D3ROError) {
return err.code === ErrorCode.LLMProcessingCancelled || err.code === ErrorCode.LLMProcessingTimeout
}
return err instanceof Error && err.name === 'AbortError'
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM call cancelled')
}
}
/** 호출 단위 옵션. 없으면 undefined — 어댑터 호출 모양(인자 수)을 예전과 같게 유지한다. */
function callOptions(hooks: LlmCallHooks | undefined): { signal?: AbortSignal; timeoutMs?: number } | undefined {
if (!hooks?.signal && hooks?.timeoutMs === undefined) return undefined
const options: { signal?: AbortSignal; timeoutMs?: number } = {}
if (hooks.signal) options.signal = hooks.signal
if (hooks.timeoutMs !== undefined) options.timeoutMs = hooks.timeoutMs
return options
}
// ── 라우팅 구현 ─────────────────────────────────────────────
class RoutedLlmGateway implements LlmGateway {
@ -148,27 +200,42 @@ class RoutedLlmGateway implements LlmGateway {
customPrompt?: string,
hooks?: LlmCallHooks,
): Promise<string> {
throwIfAborted(hooks?.signal)
const premium = await this._selectPremium(hooks)
if (premium) {
try {
return await premium.processText(text, action, targetLanguage, customPrompt)
const perCall = callOptions(hooks)
return perCall
? await premium.processText(text, action, targetLanguage, customPrompt, perCall)
: await premium.processText(text, action, targetLanguage, customPrompt)
} catch (err) {
if (isCancellation(err, hooks?.signal)) throw err
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
}
}
return this._deps.getLocal().processText(text, action, targetLanguage, customPrompt)
throwIfAborted(hooks?.signal)
// 로컬 processText 는 호출 단위 signal 을 받지 않는다 — 끝난 뒤 취소됐으면 결과를 버린다.
const result = await this._deps.getLocal().processText(text, action, targetLanguage, customPrompt)
throwIfAborted(hooks?.signal)
return result
}
async generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise<LlmGenerateResult> {
throwIfAborted(hooks?.signal)
const premium = await this._selectPremium(hooks)
if (premium) {
try {
return await premium.generate(text, options)
const perCall = callOptions(hooks)
return await premium.generate(text, perCall ? { ...options, ...perCall } : options)
} catch (err) {
if (isCancellation(err, hooks?.signal)) throw err
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
}
}
return this._deps.getLocal().generate(text, options)
throwIfAborted(hooks?.signal)
const perCall = callOptions(hooks)
const local = this._deps.getLocal()
return perCall ? local.generate(text, { ...options, ...perCall }) : local.generate(text, options)
}
async openChatStream(
@ -178,7 +245,9 @@ class RoutedLlmGateway implements LlmGateway {
): Promise<LlmChatStreamHandle> {
const premium = await this._selectPremium(hooks)
if (premium) {
return { generator: premium.chatStream(messages), backend: 'premium' }
// 호출자의 signal 을 넘긴다 — 예전엔 버려져 기능들이 전역 cancelGeneration() 으로 멈췄고,
// 그것이 다른 기능(회의 채팅 등)의 Premium 스트림까지 끊었다.
return { generator: premium.chatStream(messages, { signal: options.signal }), backend: 'premium' }
}
const local = this._deps.getLocal()

View file

@ -0,0 +1,185 @@
// src/main/services/meeting/MeetingRecordingSession.ts
// 회의 녹음 한 건의 상태(세그먼트·메모·오디오·구독)를 단독으로 소유하는 객체.
//
// 예전엔 이 상태가 MeetingModeService 싱글톤의 가변 필드였고, stopRecording 과 후처리가 await 뒤에
// 그 필드를 다시 읽었다. 정지를 두 번 누르거나(또는 로그아웃이 동시에 정지하면) 두 번째 호출이 먼저
// 세션을 초기화해 마지막 문장이 빠지고 durationMs=0 이 저장됐다. 이제:
// - stop() 은 메모이즈된다 — 몇 번을 불러도 정지·스냅샷은 한 번만 일어난다.
// - 후처리는 불변 스냅샷(RecordingSnapshot)만 받는다 — 서비스 필드를 읽지 않는다.
// - checkpoint() 는 멈추지 않고 지금까지의 스냅샷을 준다(주기 저장·앱 종료 시 저장용).
//
// Electron·DB 를 모른다. 자막/오디오는 포트로 받는다(테스트는 가짜 포트).
import type { CaptionSegment, MeetingMemo } from '@d3ro/core/types'
/** 자막 엔진(CaptionService)에 대한 최소 계약 */
export interface RecordingCaptionPort {
/** 확정 세그먼트 구독 — 해지 함수를 돌려준다 */
onSegment(listener: (segment: CaptionSegment) => void): () => void
/** 이 녹음이 소유한 자막 세션을 멈춘다(마지막 문장 flush 포함). 세그먼트 요약을 돌려줄 수 있다. */
stop(): Promise<{ segments: CaptionSegment[] } | null | false>
}
/** 마이크 PCM 청크(16kHz mono PCM16) 구독 */
export interface RecordingAudioPort {
onChunk(listener: (buffer: Buffer) => void): () => void
}
export interface MeetingRecordingSessionPorts {
caption: RecordingCaptionPort
audio: RecordingAudioPort
now?: () => number
/** 세그먼트가 이 녹음에 추가될 때(구독으로 받은 것만) */
onSegment?: (segment: CaptionSegment) => void
/** 자막 정지 실패 — 정지는 계속 진행된다(스냅샷은 그때까지의 세그먼트로 만든다) */
onStopError?: (error: unknown) => void
}
/** 정지 시점의 불변 스냅샷 — 후처리·저장은 이것만 읽는다 */
export interface RecordingSnapshot {
readonly id: string
readonly startedAt: number
readonly endedAt: number
readonly durationMs: number
readonly segments: readonly CaptionSegment[]
readonly memos: readonly MeetingMemo[]
/** 녹음 PCM (없으면 길이 0) */
readonly pcm: Buffer
}
export class MeetingRecordingSession {
private readonly _now: () => number
private _segments: CaptionSegment[] = []
private _memos: MeetingMemo[] = []
private _audio: Buffer[] = []
private _lastRms = 0
private _unsubscribeSegment: (() => void) | null = null
private _unsubscribeAudio: (() => void) | null = null
private _stopPromise: Promise<RecordingSnapshot> | null = null
private _snapshot: RecordingSnapshot | null = null
constructor(
readonly id: string,
readonly startedAt: number,
private readonly _ports: MeetingRecordingSessionPorts,
private readonly _measureLevel: (buffer: Buffer) => number = () => 0,
) {
this._now = _ports.now ?? Date.now
}
/** 세그먼트 구독을 건다 — 자막 세션 시작 전에 불러야 첫 문장을 놓치지 않는다. */
attachCaption(): void {
if (this._unsubscribeSegment || this._stopPromise) return
this._unsubscribeSegment = this._ports.caption.onSegment((segment) => {
this._segments.push(segment)
this._ports.onSegment?.(segment)
})
}
/** 오디오 구독을 건다(파일 저장·레벨 표시용). */
attachAudio(): void {
if (this._unsubscribeAudio || this._stopPromise) return
this._unsubscribeAudio = this._ports.audio.onChunk((buffer) => {
this._lastRms = this._measureLevel(buffer)
this._audio.push(Buffer.from(buffer))
})
}
/** 자막 세션을 시작하지 못했을 때 — 구독만 푼다(스냅샷 없음). */
detach(): void {
this._unsubscribeSegment?.()
this._unsubscribeSegment = null
this._unsubscribeAudio?.()
this._unsubscribeAudio = null
}
get isStopping(): boolean {
return this._stopPromise !== null
}
get lastRms(): number {
return this._lastRms
}
get segments(): readonly CaptionSegment[] {
return this._segments
}
get memos(): readonly MeetingMemo[] {
return this._memos
}
elapsedMs(): number {
return (this._snapshot?.endedAt ?? this._now()) - this.startedAt
}
/** 메모를 만든다(녹음 시작 기준 시각). 정지가 시작된 뒤에는 받지 않는다(null). */
addMemo(id: string, content: string): MeetingMemo | null {
if (this._stopPromise) return null
const now = this._now()
const memo: MeetingMemo = {
id,
sessionId: this.id,
content,
timestampMs: now - this.startedAt,
createdAt: now,
}
this._memos.push(memo)
return memo
}
/**
* 멈추지 않고 지금까지의 스냅샷(주기 저장·앱 종료 저장용). 정지가 끝났으면 그 스냅샷.
* 주기 저장은 전사만 필요하다 — includeAudio=false 면 PCM 을 합치지 않는다(긴 회의는 수백 MB).
*/
checkpoint(options: { includeAudio: boolean } = { includeAudio: true }): RecordingSnapshot {
return this._snapshot ?? this._freeze(this._now(), options.includeAudio)
}
/**
* 녹음을 멈추고 불변 스냅샷을 돌려준다. 메모이즈된다 — 동시에 여러 번 불러도 자막 정지와
* 스냅샷은 한 번만 일어나고 모두 같은 결과를 받는다.
*/
stop(): Promise<RecordingSnapshot> {
if (!this._stopPromise) this._stopPromise = this._stop()
return this._stopPromise
}
private async _stop(): Promise<RecordingSnapshot> {
// 오디오는 곧바로 끊는다(정지 이후 소리는 녹음이 아니다). 세그먼트 구독은 자막 flush 가 끝날 때까지 둔다 —
// 말하던 마지막 문장은 정지 중에 'segment' 로 온다.
this._unsubscribeAudio?.()
this._unsubscribeAudio = null
let summary: { segments: CaptionSegment[] } | null | false = null
try {
summary = await this._ports.caption.stop()
} catch (err) {
this._ports.onStopError?.(err)
} finally {
this._unsubscribeSegment?.()
this._unsubscribeSegment = null
}
// 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다
if (summary) {
const known = new Set(this._segments.map((segment) => segment.id))
for (const segment of summary.segments) {
if (!known.has(segment.id)) this._segments.push(segment)
}
this._segments.sort((a, b) => a.timestamp - b.timestamp)
}
this._snapshot = this._freeze(this._now())
return this._snapshot
}
private _freeze(endedAt: number, includeAudio = true): RecordingSnapshot {
return Object.freeze({
id: this.id,
startedAt: this.startedAt,
endedAt,
durationMs: Math.max(0, endedAt - this.startedAt),
segments: Object.freeze(this._segments.map((segment) => ({ ...segment }))),
memos: Object.freeze(this._memos.map((memo) => ({ ...memo }))),
pcm: includeAudio && this._audio.length > 0 ? Buffer.concat(this._audio) : Buffer.alloc(0),
})
}
}

View file

@ -0,0 +1,78 @@
// src/main/services/meeting/transcript-condenser.ts
// 긴 회의 전사를 LLM 한 번의 입력 한도 안으로 줄이는 map-reduce 단계.
//
// 문서 생성·요약은 전사 전체를 메시지 하나로 보냈고, 8,000자를 넘는 회의(대략 25분 이상)는 llm-proxy 가
// 400 으로 거부했다. 한도를 넘으면 전사를 줄 경계 조각으로 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을
// 이어 붙여(reduce 입력) 호출자가 한 번에 보낸다. 몇 번 줄여도 넘으면 조용히 자르지 않고 실패시킨다.
//
// LLM 은 포트(generate)로만 받는다 — 게이트웨이든 가짜든 된다.
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { joinCondensedSections, planTranscriptCondense } from '@d3ro/core/meeting-llm-input'
export interface CondenseLlm {
generate(
text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
): Promise<{ text: string }>
}
export interface CondenseResult {
text: string
/** 줄였는가 (false 면 원문 그대로) */
condensed: boolean
/** 줄인 횟수(라운드) */
rounds: number
}
/** LLM 지시문(사용자 UI 문자열 아님) */
export const TRANSCRIPT_CONDENSE_PROMPT = `다음은 긴 회의 전사의 한 구간입니다. 이 구간의 내용을 빠짐없이 간결하게 정리하세요.
- 결정사항, 할 일과 담당자, 날짜·수치, 주요 논의와 의견 차이를 반드시 보존하세요.
- 주요 발언 앞의 [MM:SS] 타임스탬프와 화자 표시는 유지하세요.
- 원문에 없는 내용은 추가하지 마세요. 정리한 내용만 출력하세요.`
const CONDENSE_MAX_TOKENS = 1024
const DEFAULT_MAX_ROUNDS = 3
export async function condenseTranscriptToBudget(
llm: CondenseLlm,
transcript: string,
budgetChars: number,
options: { maxRounds?: number } = {},
): Promise<CondenseResult> {
if (budgetChars <= 0) {
throw new D3ROError(ErrorCode.LLMPromptTooLong, 'No room left for the transcript in the LLM request', {
budgetChars,
})
}
const maxRounds = options.maxRounds ?? DEFAULT_MAX_ROUNDS
let text = transcript.trim()
let rounds = 0
while (rounds < maxRounds) {
const chunks = planTranscriptCondense(text, budgetChars)
if (chunks.length === 0) return { text, condensed: rounds > 0, rounds }
const sections: string[] = []
for (const chunk of chunks) {
const result = await llm.generate(chunk, {
systemPrompt: TRANSCRIPT_CONDENSE_PROMPT,
temperature: 0.2,
maxTokens: CONDENSE_MAX_TOKENS,
})
const section = result.text.trim()
if (section) sections.push(section)
}
if (sections.length === 0) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'LLM returned empty sections while condensing the transcript')
}
text = joinCondensedSections(sections)
rounds++
}
if (text.length > budgetChars) {
throw new D3ROError(
ErrorCode.LLMPromptTooLong,
`Transcript is still too long after ${rounds} condense round(s) (${text.length}/${budgetChars} chars)`,
{ length: text.length, budgetChars },
)
}
return { text, condensed: rounds > 0, rounds }
}

View file

@ -8,18 +8,14 @@ import { history, memoTags } from '../../db/schema'
import { isUuid, type PushContext } from './sync-adapters'
import { toSyncRemoteError, type PushOutcome, type RemoteCursor, type SyncOp, type SyncRemote } from './sync-types'
import { memoTagKey, normalizeMemoTag } from '@d3ro/core/memo-tag-policy'
// 정규화·자연키 규칙의 정본은 @d3ro/core/memo-tag-policy 다 — 기존 import 경로를 위해 다시 내보낸다.
export { memoTagKey, normalizeMemoTag }
const SEPARATOR = '|'
const PAGE_SIZE = 1000
/** 서버 정규화와 같다: trim → 연속 공백 1칸 → 소문자. */
export function normalizeMemoTag(tag: string): string {
return tag.trim().replace(/\s+/g, ' ').toLowerCase()
}
export function memoTagKey(historyId: string, tag: string): string {
return `${historyId}${SEPARATOR}${normalizeMemoTag(tag)}`
}
export function parseMemoTagKey(key: string): { historyId: string; tag: string } | null {
const index = key.indexOf(SEPARATOR)
if (index <= 0) return null

View file

@ -0,0 +1,47 @@
// src/main/services/sync/session-restore-policy.ts
// 시작 시 세션 복원 실패를 분류하는 순수 정책.
//
// 예전엔 refreshSession 이 돌려준 어떤 오류든 "세션 만료"로 보고 저장된 refresh token 을 지웠다.
// 오프라인으로 켜지거나(Windows 로그인 직후 자동 실행) 서버가 5xx 를 내면 auth-js 는 30초 재시도 뒤
// AuthRetryableFetchError 를 돌려주는데(세션은 일부러 지우지 않는다), 앱이 멀쩡한 토큰을 지워 사용자를
// 로그아웃시켰다. 서버가 토큰을 확실히 거부한 경우(4xx AuthApiError)만 토큰을 지운다.
export type SessionRestoreFailureKind =
/** 서버가 refresh token 을 거부했다 — 토큰을 지우고 재로그인을 요청한다 */
| 'invalid'
/** 네트워크·서버 일시 오류 — 토큰을 보관하고 나중에 다시 시도한다 */
| 'transient'
interface ErrorShape {
name?: unknown
status?: unknown
}
/** 재시도해도 결과가 같은 요청 거부(요청 시간 초과·요청 과다는 제외) */
function isDefinitiveRejection(status: number): boolean {
return status >= 400 && status < 500 && status !== 408 && status !== 429
}
/**
* 복원 실패를 분류한다.
* - AuthApiError(서버 응답) 중 4xx(408·429 제외) → invalid (refresh_token_not_found, invalid_grant 등)
* - 그 밖의 모든 경우(AuthRetryableFetchError, 5xx, 알 수 없는 예외) → transient
* 토큰을 모르는 이유로 지우는 것보다 보관하고 다시 시도하는 편이 안전하다.
*/
export function classifySessionRestoreFailure(error: unknown): SessionRestoreFailureKind {
if (!error || typeof error !== 'object') return 'transient'
const { name, status } = error as ErrorShape
if (name === 'AuthRetryableFetchError') return 'transient'
if (name === 'AuthApiError' && typeof status === 'number' && isDefinitiveRejection(status)) {
return 'invalid'
}
return 'transient'
}
/** 복원 재시도 간격 — 15s, 30s, 1m, 2m, 이후 5분마다 */
const RESTORE_RETRY_DELAYS_MS = [15_000, 30_000, 60_000, 120_000, 300_000] as const
export function sessionRestoreRetryDelayMs(attempt: number): number {
const index = Math.min(Math.max(0, Math.floor(attempt)), RESTORE_RETRY_DELAYS_MS.length - 1)
return RESTORE_RETRY_DELAYS_MS[index]
}

View file

@ -21,12 +21,42 @@ export interface SessionTokenStoreLogger {
warn(message: string): void
}
/** 저장된 세션 — refresh token 과 그 토큰의 계정 id(구버전 파일은 id 가 없다). */
export interface StoredSessionRecord {
refreshToken: string
userId: string | null
}
export interface SessionTokenStore {
load(): string | null
save(refreshToken: string): void
/** 토큰과 계정 id 를 함께 읽는다. 시작 시 복원 전에 그 계정 DB 를 열 수 있게 한다. */
loadRecord(): StoredSessionRecord | null
/** userId 를 주면 토큰과 함께 저장한다(생략하면 토큰만 — 구버전 형식). */
save(refreshToken: string, userId?: string | null): void
clear(): void
}
const RECORD_VERSION = 1
/** 파일 내용 → 레코드. 구버전(토큰 문자열만)도 읽는다. refresh token 은 '{' 로 시작하지 않는다. */
export function decodeSessionRecord(plain: string): StoredSessionRecord | null {
const text = plain.trim()
if (!text) return null
if (!text.startsWith('{')) return { refreshToken: text, userId: null }
try {
const parsed = JSON.parse(text) as { v?: unknown; rt?: unknown; uid?: unknown }
if (typeof parsed.rt !== 'string' || parsed.rt.length === 0) return null
return { refreshToken: parsed.rt, userId: typeof parsed.uid === 'string' && parsed.uid ? parsed.uid : null }
} catch {
return null
}
}
export function encodeSessionRecord(refreshToken: string, userId: string | null | undefined): string {
if (!userId) return refreshToken
return JSON.stringify({ v: RECORD_VERSION, rt: refreshToken, uid: userId })
}
export class EncryptedFileTokenStore implements SessionTokenStore {
constructor(
private readonly filePath: string,
@ -36,21 +66,25 @@ export class EncryptedFileTokenStore implements SessionTokenStore {
) {}
load(): string | null {
return this.loadRecord()?.refreshToken ?? null
}
loadRecord(): StoredSessionRecord | null {
try {
if (!this.fs.existsSync(this.filePath)) return null
if (!this.cipher.isEncryptionAvailable()) return null
return this.cipher.decryptString(this.fs.readFileSync(this.filePath))
return decodeSessionRecord(this.cipher.decryptString(this.fs.readFileSync(this.filePath)))
} catch (err) {
this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
return null
}
}
save(refreshToken: string): void {
save(refreshToken: string, userId?: string | null): void {
try {
if (!refreshToken) return
if (this.cipher.isEncryptionAvailable()) {
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken))
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(encodeSessionRecord(refreshToken, userId)))
}
} catch (err) {
this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
@ -81,20 +115,27 @@ export type AuthStateSubscribe<S extends AuthSessionLike> = (
/**
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
* - SIGNED_OUT: 저장소는 건드리지 않는다(로그아웃 경로가 직접 지운다). 로그인된 사용자가 있을 때
* onSignedOut 을 부른다 — 다른 기기의 전역 로그아웃·토큰 폐기로 auth 클라이언트가 세션을 버리면
* 서비스가 익명 키로 계속 도는 대신 로그아웃 정리를 하게 한다. (onSignedOut 이 없으면 무시)
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
*/
export function bindAuthEvents<S extends AuthSessionLike>(
subscribe: AuthStateSubscribe<S>,
store: SessionTokenStore,
activeUserId: () => string | null,
onSession: (session: S) => void
onSession: (session: S) => void,
onSignedOut?: () => void
): () => void {
const subscription = subscribe((event, session) => {
if (event === 'SIGNED_OUT') {
if (onSignedOut && activeUserId()) onSignedOut()
return
}
if (!session || !ROTATION_EVENTS.has(event)) return
const userId = activeUserId()
if (!userId || session.user.id !== userId) return
store.save(session.refresh_token)
store.save(session.refresh_token, userId)
onSession(session)
})
return () => subscription.unsubscribe()

View file

@ -1,9 +1,8 @@
// src/main/suggestion-overlay-policy.ts
//
// 제안 오버레이 표시 전략 (순수 함수) — bootstrap.ts 의 suggestion.on('updated', ...)
// 배선에서 쓴다. Electron/서비스 의존이 없는 별도 파일로 뺀 이유: bootstrap.ts 는
// 최상위에서 electron/서비스 모듈을 다수 import 하므로 그대로는 단위 테스트하기
// 어렵다 — 이 판단 로직만 독립적으로 테스트한다.
// 제안 오버레이 표시 전략 (순수 함수) — windows/suggestion-overlay-presenter.ts 가 쓴다
// (Esc 판단은 bootstrap 의 원본 keydown 배선이 쓴다). Electron/서비스 의존이 없어
// 이 판단 로직만 독립적으로 테스트한다.
/**
* 제안 오버레이를 이번 'updated' 이벤트에 어떻게 반영할지 결정한다.

View file

@ -0,0 +1,126 @@
// src/main/windows/suggestion-overlay-presenter.ts
// 입력 제안 오버레이 표시 정책의 단일 정본 (RecordingTipPresenter 와 같은 형태).
//
// 예전엔 bootstrap 의 이벤트 클로저 하나에 표시 여부 판단, 텔레메트리 표시 플래그 동기화, show/update/hide
// 선택, payload 매핑(show 와 update 가 같은 7개 필드를 따로 나열), 예외 삼키기가 섞여 있었다. bootstrap 은
// electron·서비스를 최상위에서 import 해 이 정책을 테스트할 수 없었고, 필드를 추가할 때마다 두 매핑을 따로
// 고쳐야 했다(누락돼도 컴파일러가 못 잡았다). 이제 매핑은 함수 하나이고, 창은 뷰 포트로 주입한다.
import type { AnchorKind, PhysicalRect, SuggestionState } from '@d3ro/core/input-intelligence'
import { decideSuggestionOverlayAction } from '../suggestion-overlay-policy'
import {
hideSuggestionOverlay,
isSuggestionOverlayVisible,
showSuggestionOverlay,
updateSuggestionOverlay,
} from './WindowManager'
/** 오버레이 내용 — show 와 update 가 같이 쓴다 */
export type SuggestionOverlayContentPayload = Pick<
SuggestionState,
'candidates' | 'activeIndex' | 'targetTotal' | 'generating' | 'warmingUp' | 'partialText' | 'provenance'
>
/** 새로 띄울 때만 필요한 배치 정보를 더한 payload */
export interface SuggestionOverlayShowPayload extends SuggestionOverlayContentPayload {
anchor: PhysicalRect | null
anchorKind: AnchorKind
appName: string | null
}
/** 상태 → 오버레이 내용 (단일 매핑) */
export function toSuggestionOverlayContent(state: SuggestionState): SuggestionOverlayContentPayload {
return {
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
provenance: state.provenance,
}
}
/** 상태 → 새로 띄울 때의 payload (내용 + 앵커·앱 이름) */
export function toSuggestionOverlayShowPayload(state: SuggestionState): SuggestionOverlayShowPayload {
return {
...toSuggestionOverlayContent(state),
anchor: state.anchor,
anchorKind: state.anchorKind,
appName: state.appName,
}
}
/** 보여 줄 것(후보·생성 중·준비 중·부분 텍스트)이 있고 붙을 앵커가 있으면 표시한다. */
export function shouldPresentSuggestion(state: SuggestionState): boolean {
const hasSomethingToShow =
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
return hasSomethingToShow && !!state.anchor
}
/** 오버레이 창 포트 — 테스트에서는 가짜로 바꾼다 */
export interface SuggestionOverlayView {
show(payload: SuggestionOverlayShowPayload): void
update(payload: SuggestionOverlayContentPayload): void
hide(): void
isVisible(): boolean
}
export interface SuggestionOverlayPresenterDeps {
view: SuggestionOverlayView
/** 텔레메트리에 "제안을 보여 주는 중" 을 알린다(입력 가로채기 판단용) */
setPresentationActive(active: boolean): void
warn(message: string): void
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
export class SuggestionOverlayPresenter {
constructor(private readonly _deps: SuggestionOverlayPresenterDeps) {}
/**
* 제안 상태가 바뀌었다. 오버레이·창 호출이 던져도 삼킨다 — UI 문제로 서비스의 진행 플래그가
* 굳어 기능 전체가 멈추면 안 된다. (텔레메트리 동기화는 오버레이 호출보다 먼저, 같은 보호 안에서)
*/
onUpdated(state: SuggestionState): void {
try {
const shouldPresent = shouldPresentSuggestion(state)
this._deps.setPresentationActive(shouldPresent)
const view = this._deps.view
const action = decideSuggestionOverlayAction(view.isVisible(), shouldPresent)
if (action === 'hide') {
view.hide()
} else if (action === 'update') {
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
view.update(toSuggestionOverlayContent(state))
} else {
view.show(toSuggestionOverlayShowPayload(state))
}
} catch (error) {
this._deps.warn(`제안 오버레이 표시 실패: ${describe(error)}`)
}
}
/** 제안이 닫혔다. 텔레메트리 플래그는 항상 내리고, 숨기기 실패만 삼킨다. */
onCleared(): void {
this._deps.setPresentationActive(false)
try {
this._deps.view.hide()
} catch (error) {
this._deps.warn(`제안 오버레이 숨기기 실패: ${describe(error)}`)
}
}
}
/** WindowManager 의 제안 오버레이 창을 뷰 포트로 감싼다 */
export function createWindowSuggestionOverlayView(): SuggestionOverlayView {
return {
show: (payload) => showSuggestionOverlay(payload),
update: (payload) => updateSuggestionOverlay(payload),
hide: () => hideSuggestionOverlay(),
isVisible: () => isSuggestionOverlayVisible(),
}
}

View file

@ -1,7 +1,7 @@
// src/preload/index.ts
// contextBridge로 렌더러에 노출할 API 정의
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import type {
AudioDevice,
@ -184,6 +184,12 @@ import type {
DiarizeSessionParams,
} from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
import {
MEETING_CHAT_GET_HISTORY_CHANNEL,
MEETING_MODE_GET_LIVE_SESSION_CHANNEL,
type MeetingChatHistoryTurn,
type MeetingLiveSession,
} from '@d3ro/core/meeting-live'
import type { IPCResult } from '@d3ro/core/errors'
/** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 상태 — RuntimeProvisioner가 제공한다 */
@ -581,6 +587,8 @@ const electronAPI = {
invoke<FileTranscriptionResult>(IPC_CHANNELS.FILE_TRANSCRIPTION.START, params),
cancel: () =>
invoke<void>(IPC_CHANNELS.FILE_TRANSCRIPTION.CANCEL),
/** 드롭한 File 의 실제 경로 (Electron 32+ 는 File.path 가 없다). 알 수 없으면 빈 문자열. */
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
getState: () =>
invoke<FileTranscriptionStateInfo>(IPC_CHANNELS.FILE_TRANSCRIPTION.GET_STATE),
onProgress: (cb: (data: FileTranscriptionProgress) => void): Unsubscribe =>
@ -725,6 +733,9 @@ const electronAPI = {
invoke<MeetingMemo>(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params),
getState: () =>
invoke<MeetingModeStateInfo>(IPC_CHANNELS.MEETING_MODE.GET_STATE),
/** 진행 중인 녹음의 실시간 전사·메모 (페이지를 다시 열 때 복원용). 녹음 중이 아니면 null */
getLiveSession: () =>
invoke<MeetingLiveSession | null>(MEETING_MODE_GET_LIVE_SESSION_CHANNEL),
getSessions: (params: MeetingGetSessionsParams) =>
invoke<MeetingSessionPage>(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params),
getSession: (params: MeetingGetSessionParams) =>
@ -782,10 +793,15 @@ const electronAPI = {
meetingChat: {
send: (params: MeetingChatSendParams) =>
invoke<void>(IPC_CHANNELS.MEETING_CHAT.SEND, params),
cancel: () =>
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CANCEL),
clear: () =>
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CLEAR),
/** sessionId 를 주면 그 회의의 채팅만 멈춘다 */
cancel: (params?: { sessionId: string }) =>
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CANCEL, params),
/** sessionId 를 주면 그 회의의 대화만 지운다(생략하면 전부) */
clear: (params?: { sessionId: string }) =>
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CLEAR, params),
/** main 이 기억하는 이 회의의 대화 — 패널을 다시 열 때 보여 준다 */
getHistory: (params: { sessionId: string }) =>
invoke<MeetingChatHistoryTurn[]>(MEETING_CHAT_GET_HISTORY_CHANNEL, params),
onDelta: (cb: (e: MeetingChatDelta) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_CHAT.DELTA, cb),
onMessage: (cb: (e: MeetingChatMessage) => void): Unsubscribe =>

View file

@ -12,6 +12,7 @@ import type {
FileTranscriptionResult,
FileTranscriptionState,
} from '@d3ro/core/types'
import { StartRequestTracker, classifyStartFailure, resolveDroppedFilePath } from './file-drop-model'
const SUPPORTED_EXTENSIONS = [
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
@ -52,6 +53,7 @@ export function FileDropZone(): React.ReactElement {
}, [])
const isBusyRef = useRef(false)
const requestsRef = useRef(new StartRequestTracker())
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault()
@ -68,34 +70,26 @@ export function FileDropZone(): React.ReactElement {
return
}
isBusyRef.current = true
setState('converting')
setError(null)
setResult(null)
try {
const filePath = (file as unknown as { path: string }).path
const resp = await window.electronAPI.fileTranscription.start({ filePath })
if (!resp.success) {
setError(resp.error.message)
setState('error')
}
} finally {
isBusyRef.current = false
// Electron 32+ 에서는 File.path 가 없다 — preload 의 webUtils.getPathForFile 로 얻는다.
// 경로를 못 얻으면 빈 경로로 시작하지 않는다(main 이 파일 선택 대화상자를 띄운다).
const filePath = resolveDroppedFilePath(file, window.electronAPI.fileTranscription.getPathForFile)
if (!filePath) {
setError(t('fileTranscription.error.unknown'))
setState('error')
return
}
}, [t, state])
const handleBrowse = useCallback(async () => {
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
isBusyRef.current = true
const request = requestsRef.current.begin()
setState('converting')
setError(null)
setResult(null)
try {
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
const resp = await window.electronAPI.fileTranscription.start({ filePath })
if (!requestsRef.current.isCurrent(request)) return
if (!resp.success) {
if (resp.error.message.includes('cancelled') || resp.error.message.includes('already active')) {
if (classifyStartFailure(resp.error) === 'cancelled') {
setState('idle')
} else {
setError(resp.error.message)
@ -103,11 +97,37 @@ export function FileDropZone(): React.ReactElement {
}
}
} finally {
isBusyRef.current = false
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
}
}, [t, state])
const handleBrowse = useCallback(async () => {
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
isBusyRef.current = true
const request = requestsRef.current.begin()
setState('converting')
setError(null)
setResult(null)
try {
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
if (!requestsRef.current.isCurrent(request)) return
if (!resp.success) {
if (classifyStartFailure(resp.error) === 'cancelled') {
setState('idle')
} else {
setError(resp.error.message)
setState('error')
}
}
} finally {
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
}
}, [state])
const handleCancel = useCallback(async () => {
// 취소된 요청의 늦은 응답은 무시한다(새로 드롭한 작업의 화면을 덮지 않게)
requestsRef.current.invalidate()
await window.electronAPI.fileTranscription.cancel()
setState('idle')
setProgress(null)

View file

@ -0,0 +1,48 @@
// src/renderer/components/file-drop-model.ts
// FileDropZone 의 순수 판단(드롭 경로 해석·시작 실패 분류·늦은 응답 무시) — React·Electron 없이 테스트한다.
import { ErrorCode } from '@d3ro/core/errors'
/**
* 드롭한 File 의 실제 경로. Electron 32+ 는 비표준 File.path 를 없앴으므로 preload 의
* webUtils.getPathForFile 을 쓴다. 경로를 얻지 못하면 null — 빈 경로로 시작하면 main 이
* 파일 선택 대화상자를 띄워, 드롭한 파일 대신 다른 파일을 고르게 만들었다.
*/
export function resolveDroppedFilePath(file: File, getPathForFile: (file: File) => string): string | null {
try {
const filePath = getPathForFile(file)
return filePath && filePath.trim().length > 0 ? filePath : null
} catch {
return null
}
}
export type StartFailureKind = 'cancelled' | 'error'
/** 시작 IPC 실패 분류 — 사용자가 취소했거나 대화상자를 닫은 것은 오류로 보이지 않는다. */
export function classifyStartFailure(error: { code: number; message: string }): StartFailureKind {
if (error.code === ErrorCode.FileTranscriptionCancelled) return 'cancelled'
if (error.message.includes('cancelled') || error.message.includes('already active')) return 'cancelled'
return 'error'
}
/**
* 시작 요청마다 번호를 매긴다. 취소하거나 새로 시작하면 이전 요청의 늦은 응답(예: 취소된 작업의
* 'Transcription cancelled')이 새 작업의 화면을 덮지 않도록 무시한다.
*/
export class StartRequestTracker {
private _current = 0
begin(): number {
this._current += 1
return this._current
}
invalidate(): void {
this._current += 1
}
isCurrent(token: number): boolean {
return token === this._current
}
}

View file

@ -10,6 +10,7 @@ import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { MeetingChatMessage } from '@d3ro/core/types'
import { withRestoredHistory } from './meeting-chat-history'
interface MeetingChatPanelProps {
sessionId: string
@ -85,17 +86,27 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
scrollToBottom()
}, [messages, streamingContent, scrollToBottom])
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버리고,
// main 이 이 회의에 대해 기억하는 대화를 불러와 보여 준다 — 예전엔 빈 화면인데 모델은 보이지 않는
// 이전 턴을 이어받아 답했다.
useEffect(() => {
let cancelled = false
setMessages([])
setStreaming(false)
setStreamingContent('')
setError(null)
isSendingChatRef.current = false
window.electronAPI.meetingChat
.getHistory({ sessionId })
.then((resp) => {
if (cancelled || !resp.success || resp.data.length === 0) return
setMessages((current) => withRestoredHistory(current, resp.data))
})
.catch(() => { /* 불러오지 못하면 빈 대화로 시작 */ })
return () => {
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
void window.electronAPI.meetingChat.cancel()
cancelled = true
// 떠나는 회의의 답변 스트림만 끊는다 — 다음 회의 패널에 섞이지 않게
void window.electronAPI.meetingChat.cancel({ sessionId })
}
}, [sessionId])
@ -167,7 +178,9 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
setMessages([])
setStreamingContent('')
setStreaming(false)
window.electronAPI.meetingChat.clear()
// 이 회의의 대화만 지운다 — 예전엔 인자 없이 불러 모든 회의의 대화가 지워졌다
void window.electronAPI.meetingChat.cancel({ sessionId })
void window.electronAPI.meetingChat.clear({ sessionId })
}, [sessionId])
const handleToggleCollapse = useCallback(() => {

View file

@ -0,0 +1,21 @@
// src/renderer/components/meeting/meeting-chat-history.ts
// main 이 회의마다 기억하는 채팅 대화를 패널 메시지로 바꾼다(패널을 다시 열 때 표시).
import type { MeetingChatMessage } from '@d3ro/core/types'
import type { MeetingChatHistoryTurn } from '@d3ro/core/meeting-live'
/**
* 보관된 턴에는 시각이 없다 — 목록 key 가 겹치지 않도록 순서 번호를 timestamp 자리에 쓴다
* (실제 메시지의 Date.now() 보다 항상 작아 순서도 유지된다).
*/
export function toPanelMessages(history: readonly MeetingChatHistoryTurn[]): MeetingChatMessage[] {
return history.map((turn, index) => ({ role: turn.role, content: turn.content, timestamp: index + 1 }))
}
/** 조회가 끝나기 전에 사용자가 새로 보낸 메시지가 있으면 그것을 지우지 않고 뒤에 둔다. */
export function withRestoredHistory(
current: readonly MeetingChatMessage[],
history: readonly MeetingChatHistoryTurn[],
): MeetingChatMessage[] {
return [...toPanelMessages(history), ...current]
}

View file

@ -38,6 +38,8 @@ import {
} from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { INSTRUCTION_LIMITS } from '@d3ro/core/instruction-policy'
import { commandSaveError } from './commands/command-form'
import { useI18n } from '@d3ro/i18n'
import { TemplateSection } from '../components/TemplateSection'
@ -63,6 +65,8 @@ export function CommandsPage(): React.ReactElement {
const [activeId, setActiveId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
/** 저장 거부 사유 — 대화상자 안에 보여 준다 */
const [formError, setFormError] = useState<string | null>(null)
const isSavingRef = useRef(false)
const isDeletingRef = useRef(false)
@ -123,6 +127,7 @@ export function CommandsPage(): React.ReactElement {
setFormName('')
setFormDesc('')
setFormPrompt(t('commands.defaultPrompt'))
setFormError(null)
setDialogOpen(true)
}
@ -132,30 +137,36 @@ export function CommandsPage(): React.ReactElement {
setFormName(inst.name)
setFormDesc(inst.description)
setFormPrompt(inst.prompt)
setFormError(null)
setDialogOpen(true)
}
// 저장이 거부되면(빈 값·길이 초과 등) 대화상자를 닫지 않고 이유를 보여 준다 — 예전엔 결과를 보지 않고
// 닫아 입력한 내용이 조용히 사라졌다.
const handleSave = async () => {
if (!formName.trim() || saving || isSavingRef.current) return
const draft = { name: formName.trim(), description: formDesc.trim(), prompt: formPrompt.trim() }
const invalid = commandSaveError({ kind: 'draft', draft })
if (invalid) {
setFormError(t(invalid.key))
return
}
isSavingRef.current = true
setSaving(true)
setFormError(null)
try {
if (editId) {
await window.electronAPI.instruction.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
} else {
await window.electronAPI.instruction.create({
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
const res = editId
? await window.electronAPI.instruction.update({ id: editId, ...draft })
: await window.electronAPI.instruction.create(draft)
if (!res.success) {
const failure = commandSaveError({ kind: 'ipc', error: res.error })
setFormError(failure ? t(failure.key) : res.error.message)
return
}
setDialogOpen(false)
loadData()
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : String(err))
} finally {
setSaving(false)
isSavingRef.current = false
@ -601,34 +612,42 @@ export function CommandsPage(): React.ReactElement {
<TextField
label={t('commands.name')}
value={formName}
onChange={(e) => setFormName(e.target.value)}
onChange={(e) => { setFormName(e.target.value); setFormError(null) }}
fullWidth
autoFocus
inputProps={{ maxLength: INSTRUCTION_LIMITS.name }}
sx={{ mt: 1.5 }}
/>
<TextField
label={t('commands.description')}
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
onChange={(e) => { setFormDesc(e.target.value); setFormError(null) }}
fullWidth
inputProps={{ maxLength: INSTRUCTION_LIMITS.description }}
sx={{ mt: 2.5 }}
/>
<TextField
label={t('commands.promptTemplate')}
value={formPrompt}
onChange={(e) => setFormPrompt(e.target.value)}
onChange={(e) => { setFormPrompt(e.target.value); setFormError(null) }}
fullWidth
multiline
rows={4}
inputProps={{ maxLength: INSTRUCTION_LIMITS.prompt }}
sx={{ mt: 2.5 }}
helperText={t('commands.promptHelp')}
helperText={`${t('commands.promptHelp')} · ${formPrompt.length}/${INSTRUCTION_LIMITS.prompt}`}
/>
{formError && (
<PhosphorText variant="body" sx={{ mt: 1.5, color: d3roPalette.status.danger, display: 'block' }}>
{formError}
</PhosphorText>
)}
</DialogContent>
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || saving}>
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || !formPrompt.trim() || saving}>
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
</PhysicalButton>
</DialogActions>

View file

@ -56,6 +56,7 @@ import {
replaceSegmentText,
rollbackSegmentText,
} from './meeting/live-session'
import { mergeRestoredItems } from './meeting/live-restore'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
@ -148,11 +149,13 @@ export function MeetingModePage(): React.ReactElement {
// ── 진행 중 세션 복원 ──
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
// 그동안의 실시간 전사·메모도 main 에서 받아 되살린다(예전엔 뷰만 복원돼 이전 전사가 사라져 보이고
// 편집할 수 없었다). 조회하는 사이 도착한 세그먼트와는 id 로 합친다.
useEffect(() => {
let cancelled = false
window.electronAPI.meetingMode
.getState()
.then((resp) => {
.then(async (resp) => {
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
const info = resp.data
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
@ -160,6 +163,11 @@ export function MeetingModePage(): React.ReactElement {
setLiveSessionId(info.sessionId)
setElapsedMs(info.elapsedMs)
setView('recording')
const live = await window.electronAPI.meetingMode.getLiveSession()
if (cancelled || !live.success || !live.data || live.data.sessionId !== info.sessionId) return
const restored = live.data
setSegments((arrived) => mergeRestoredItems(restored.segments, arrived, (s) => s.timestamp))
setMemos((arrived) => mergeRestoredItems(restored.memos, arrived, (m) => m.timestampMs))
})
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
return () => {
@ -342,9 +350,24 @@ export function MeetingModePage(): React.ReactElement {
}
}, [parseMeetingError])
// 정지는 마지막 문장 인식(flush)이 끝날 때까지 걸린다 — 그동안 다시 누를 수 없게 하고,
// 실패하면 알린다(예전엔 응답을 버려 화면이 계속 '녹음 중'이었다).
const [isStopping, setIsStopping] = useState(false)
const isStoppingRef = useRef(false)
const handleStopRecording = useCallback(async () => {
await window.electronAPI.meetingMode.stopRecording()
}, [])
if (isStoppingRef.current) return
isStoppingRef.current = true
setIsStopping(true)
try {
const resp = await window.electronAPI.meetingMode.stopRecording()
if (!resp.success) setSnackbarMsg(`${t('meeting.error')}: ${resp.error.message}`)
} catch (err: unknown) {
setSnackbarMsg(`${t('meeting.error')}: ${err instanceof Error ? err.message : String(err)}`)
} finally {
isStoppingRef.current = false
setIsStopping(false)
}
}, [t])
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
@ -792,7 +815,7 @@ export function MeetingModePage(): React.ReactElement {
</Box>
{!isProcessing && (
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording}>
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording} disabled={isStopping}>
<Square size={14} style={{ marginRight: 4 }} />
{t('meeting.stopRecording')}
</PhysicalButton>

View file

@ -0,0 +1,35 @@
// src/renderer/pages/commands/command-form.ts
// 명령어 편집 폼의 저장 판단(순수 함수) — 서버·main 과 같은 규칙(@d3ro/core/instruction-policy)을 쓴다.
import { ErrorCode } from '@d3ro/core/errors'
import { validateInstructionDraft } from '@d3ro/core/instruction-policy'
/** 사용자에게 보일 메시지의 i18n 키 */
export interface CommandSaveErrorMessage {
key: 'mobile.commands.syncError.validation'
}
/** "이름 1~80자, 설명 240자 이하, 프롬프트 1~4000자" — 서버 CHECK 와 같은 안내 */
const VALIDATION_MESSAGE: CommandSaveErrorMessage = { key: 'mobile.commands.syncError.validation' }
/** main 검증 거부 메시지(instruction-handlers 가 코드를 ConfigWriteFailed 로 감싸므로 문구로도 판별) */
const VALIDATION_REJECTION = /too long|is empty/i
export type CommandSaveCheck =
| { kind: 'draft'; draft: { name: string; description: string; prompt: string } }
| { kind: 'ipc'; error: { code: number; message: string } }
/**
* 저장 전 초안 검사 또는 저장 실패 응답을 사용자 메시지로 바꾼다.
* - draft: 빈 이름·빈 프롬프트·길이 초과면 검증 메시지, 문제 없으면 null
* - ipc: 검증 거부(ConfigInvalidValue 또는 검증 문구)면 검증 메시지, 그 밖의 실패는 null(호출자가 원문을 보여 준다)
*/
export function commandSaveError(check: CommandSaveCheck): CommandSaveErrorMessage | null {
if (check.kind === 'draft') {
return validateInstructionDraft(check.draft) === null ? null : VALIDATION_MESSAGE
}
if (check.error.code === ErrorCode.ConfigInvalidValue || VALIDATION_REJECTION.test(check.error.message)) {
return VALIDATION_MESSAGE
}
return null
}

View file

@ -0,0 +1,26 @@
// src/renderer/pages/meeting/live-restore.ts
// 녹음 화면을 다시 열 때(페이지 이동 후 복귀) main 이 보관한 실시간 전사·메모와,
// 조회하는 사이 이벤트로 먼저 도착한 항목을 합치는 순수 함수.
interface HasId {
id: string
}
/**
* restored(main 사본)를 기준으로 하고, arrived(조회 중 onSegment/onMemo 로 먼저 온 항목) 중 없는 것만 덧붙인다.
* id 로 중복을 없애고 order 기준으로 정렬한다.
*/
export function mergeRestoredItems<T extends HasId>(
restored: readonly T[],
arrived: readonly T[],
order: (item: T) => number,
): T[] {
const seen = new Set(restored.map((item) => item.id))
const merged = [...restored]
for (const item of arrived) {
if (seen.has(item.id)) continue
seen.add(item.id)
merged.push(item)
}
return merged.sort((a, b) => order(a) - order(b))
}