diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 07ef082..906d19c 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -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 { 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 { initConfigService() } -async function initLocalDatabase(): Promise { - const { created, dbPath } = openLocal() - logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`) +async function initStartupDatabase(): Promise { + 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 { + 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 { @@ -177,56 +205,19 @@ async function initInputIntelligence(): Promise { 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 { 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 { @@ -503,7 +502,13 @@ async function initMeetingSummaryWiring(): Promise { async function initMeetingMode(): Promise { // 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() diff --git a/apps/desktop/src/main/config-effects.ts b/apps/desktop/src/main/config-effects.ts new file mode 100644 index 0000000..9d8e0f3 --- /dev/null +++ b/apps/desktop/src/main/config-effects.ts @@ -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() + 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) + } + } + } +} diff --git a/apps/desktop/src/main/db/index.ts b/apps/desktop/src/main/db/index.ts index 7681e5b..c2a9b08 100644 --- a/apps/desktop/src/main/db/index.ts +++ b/apps/desktop/src/main/db/index.ts @@ -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 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) diff --git a/apps/desktop/src/main/db/like-pattern.ts b/apps/desktop/src/main/db/like-pattern.ts new file mode 100644 index 0000000..70aec3c --- /dev/null +++ b/apps/desktop/src/main/db/like-pattern.ts @@ -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}`, + } +} diff --git a/apps/desktop/src/main/db/startup-database.ts b/apps/desktop/src/main/db/startup-database.ts new file mode 100644 index 0000000..2c79e6a --- /dev/null +++ b/apps/desktop/src/main/db/startup-database.ts @@ -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 } +} diff --git a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts index 7bb494b..334b9c7 100644 --- a/apps/desktop/src/main/ipc/cloud-sync-handlers.ts +++ b/apps/desktop/src/main/ipc/cloud-sync-handlers.ts @@ -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) => { diff --git a/apps/desktop/src/main/ipc/config-handlers.ts b/apps/desktop/src/main/ipc/config-handlers.ts index 3c346f1..91202ea 100644 --- a/apps/desktop/src/main/ipc/config-handlers.ts +++ b/apps/desktop/src/main/ipc/config-handlers.ts @@ -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) }) diff --git a/apps/desktop/src/main/ipc/llm-handlers.ts b/apps/desktop/src/main/ipc/llm-handlers.ts index 68d84d3..5392a8a 100644 --- a/apps/desktop/src/main/ipc/llm-handlers.ts +++ b/apps/desktop/src/main/ipc/llm-handlers.ts @@ -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) diff --git a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts index 339eac7..dcdceef 100644 --- a/apps/desktop/src/main/ipc/meeting-mode-handlers.ts +++ b/apps/desktop/src/main/ipc/meeting-mode-handlers.ts @@ -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() } + +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) diff --git a/apps/desktop/src/main/services/CaptionService.ts b/apps/desktop/src/main/services/CaptionService.ts index 46a070f..f24d865 100644 --- a/apps/desktop/src/main/services/CaptionService.ts +++ b/apps/desktop/src/main/services/CaptionService.ts @@ -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, diff --git a/apps/desktop/src/main/services/ChainService.ts b/apps/desktop/src/main/services/ChainService.ts index 2bf2ef7..f360ceb 100644 --- a/apps/desktop/src/main/services/ChainService.ts +++ b/apps/desktop/src/main/services/ChainService.ts @@ -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') } } diff --git a/apps/desktop/src/main/services/CloudSyncService.ts b/apps/desktop/src/main/services/CloudSyncService.ts index bdc4fcb..eb28229 100644 --- a/apps/desktop/src/main/services/CloudSyncService.ts +++ b/apps/desktop/src/main/services/CloudSyncService.ts @@ -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 { + 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 { 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 { + if (this._signingOut) return + this._signingOut = true + try { + await this._runSignOut(options) + } finally { + this._signingOut = false + } + } + + private async _runSignOut(options: { unregister: boolean }): Promise { + // 오프라인 복원 대기도 끝낸다 — 로그아웃한 계정의 토큰으로 다시 복원하지 않게. + 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) diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index c601e4a..81ea0a1 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -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 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) { diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index 2fe3409..837ec6c 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -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, '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 = { ...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 diff --git a/apps/desktop/src/main/services/FileTranscriptionService.ts b/apps/desktop/src/main/services/FileTranscriptionService.ts index efce68c..66085d2 100644 --- a/apps/desktop/src/main/services/FileTranscriptionService.ts +++ b/apps/desktop/src/main/services/FileTranscriptionService.ts @@ -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 +} + +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 { + private _convertToWav(job: TranscriptionJob, ffmpegPath: string, inputPath: string, outputPath: string): Promise { 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 { + private _probeDuration(job: TranscriptionJob, ffmpegPath: string, wavPath: string): Promise { 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() } } diff --git a/apps/desktop/src/main/services/HistoryService.ts b/apps/desktop/src/main/services/HistoryService.ts index f2c4539..f258b0a 100644 --- a/apps/desktop/src/main/services/HistoryService.ts +++ b/apps/desktop/src/main/services/HistoryService.ts @@ -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단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.', diff --git a/apps/desktop/src/main/services/MeetingModeService.ts b/apps/desktop/src/main/services/MeetingModeService.ts index 00ab0ea..3c9d10a 100644 --- a/apps/desktop/src/main/services/MeetingModeService.ts +++ b/apps/desktop/src/main/services/MeetingModeService.ts @@ -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 } +/** 문서 생성 사용자 메시지의 머리말(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 { - 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 | null = null private _audioLevelTimer: ReturnType | null = null - private _lastRms = 0 + private _checkpointTimer: ReturnType | 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>() - private _chatAbortController: AbortController | null = null + /** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */ + private _chatAbortControllers = new Map() + 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 { 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 { - 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 { - 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 { + 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 { + 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 + 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 { + /** 불변 스냅샷만 읽는다 — 서비스 필드(다른 호출이 초기화할 수 있다)에 기대지 않는다. */ + private async _runPostProcessing(snapshot: RecordingSnapshot): Promise { 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 { - 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 } diff --git a/apps/desktop/src/main/services/MeetingSummaryService.ts b/apps/desktop/src/main/services/MeetingSummaryService.ts index 3ada7bc..23671bf 100644 --- a/apps/desktop/src/main/services/MeetingSummaryService.ts +++ b/apps/desktop/src/main/services/MeetingSummaryService.ts @@ -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, }) diff --git a/apps/desktop/src/main/services/MemoService.ts b/apps/desktop/src/main/services/MemoService.ts index 1c7aa69..768807a 100644 --- a/apps/desktop/src/main/services/MemoService.ts +++ b/apps/desktop/src/main/services/MemoService.ts @@ -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 diff --git a/apps/desktop/src/main/services/OnlineLLMService.ts b/apps/desktop/src/main/services/OnlineLLMService.ts index b2d68f8..911fb19 100644 --- a/apps/desktop/src/main/services/OnlineLLMService.ts +++ b/apps/desktop/src/main/services/OnlineLLMService.ts @@ -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 } } diff --git a/apps/desktop/src/main/services/PremiumLLMService.ts b/apps/desktop/src/main/services/PremiumLLMService.ts index 2d52e32..ae74f06 100644 --- a/apps/desktop/src/main/services/PremiumLLMService.ts +++ b/apps/desktop/src/main/services/PremiumLLMService.ts @@ -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 { 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 { + 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 { const cloud = getCloudSyncService() diff --git a/apps/desktop/src/main/services/VoiceConversationService.ts b/apps/desktop/src/main/services/VoiceConversationService.ts index c7f6629..882a045 100644 --- a/apps/desktop/src/main/services/VoiceConversationService.ts +++ b/apps/desktop/src/main/services/VoiceConversationService.ts @@ -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 { @@ -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 { diff --git a/apps/desktop/src/main/services/caption/CaptionAudioSources.ts b/apps/desktop/src/main/services/caption/CaptionAudioSources.ts new file mode 100644 index 0000000..d410731 --- /dev/null +++ b/apps/desktop/src/main/services/caption/CaptionAudioSources.ts @@ -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 + stop(): Promise +} + +/** 시스템 소리 캡처(렌더러가 수행) 요청 */ +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 { + 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 { + 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 + } +} diff --git a/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts b/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts index e25fd18..7ae530d 100644 --- a/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts +++ b/apps/desktop/src/main/services/caption/StreamingCaptionTrack.ts @@ -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 } diff --git a/apps/desktop/src/main/services/llm/LlmGateway.ts b/apps/desktop/src/main/services/llm/LlmGateway.ts index 007921f..51340a0 100644 --- a/apps/desktop/src/main/services/llm/LlmGateway.ts +++ b/apps/desktop/src/main/services/llm/LlmGateway.ts @@ -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 backend: LlmBackendKind @@ -73,24 +83,42 @@ export interface LlmGateway { options: { signal: AbortSignal; local?: LocalChatStreamOptions }, hooks?: LlmCallHooks, ): Promise - /** 두 어댑터의 진행 중 생성을 모두 취소한다. */ + /** 두 어댑터의 진행 중 생성을 모두 취소한다 — 앱 종료·전역 취소 전용. 기능 단위 취소는 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 - generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string }> - chatStream(messages: LlmChatMessage[], options?: { temperature?: number }): AsyncGenerator + processText( + text: string, + action: LLMAction, + targetLanguage?: string, + customPrompt?: string, + options?: PremiumCallAdapterOptions, + ): Promise + generate(text: string, options?: LlmGenerateOptions & PremiumCallAdapterOptions): Promise<{ text: string }> + chatStream( + messages: LlmChatMessage[], + options?: { temperature?: number; signal?: AbortSignal }, + ): AsyncGenerator cancelGeneration(): void } export interface LocalLlmAdapter { isAvailable(): boolean processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise - 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 { + 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 { + 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 { 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() diff --git a/apps/desktop/src/main/services/meeting/MeetingRecordingSession.ts b/apps/desktop/src/main/services/meeting/MeetingRecordingSession.ts new file mode 100644 index 0000000..2d6b865 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/MeetingRecordingSession.ts @@ -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 | 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 { + if (!this._stopPromise) this._stopPromise = this._stop() + return this._stopPromise + } + + private async _stop(): Promise { + // 오디오는 곧바로 끊는다(정지 이후 소리는 녹음이 아니다). 세그먼트 구독은 자막 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), + }) + } +} diff --git a/apps/desktop/src/main/services/meeting/transcript-condenser.ts b/apps/desktop/src/main/services/meeting/transcript-condenser.ts new file mode 100644 index 0000000..b2261b3 --- /dev/null +++ b/apps/desktop/src/main/services/meeting/transcript-condenser.ts @@ -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 { + 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 } +} diff --git a/apps/desktop/src/main/services/sync/memo-tag-sync.ts b/apps/desktop/src/main/services/sync/memo-tag-sync.ts index d3bedfa..ab4bc67 100644 --- a/apps/desktop/src/main/services/sync/memo-tag-sync.ts +++ b/apps/desktop/src/main/services/sync/memo-tag-sync.ts @@ -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 diff --git a/apps/desktop/src/main/services/sync/session-restore-policy.ts b/apps/desktop/src/main/services/sync/session-restore-policy.ts new file mode 100644 index 0000000..aed5670 --- /dev/null +++ b/apps/desktop/src/main/services/sync/session-restore-policy.ts @@ -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] +} diff --git a/apps/desktop/src/main/services/sync/session-token-store.ts b/apps/desktop/src/main/services/sync/session-token-store.ts index f2e9a5b..3a0d136 100644 --- a/apps/desktop/src/main/services/sync/session-token-store.ts +++ b/apps/desktop/src/main/services/sync/session-token-store.ts @@ -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 = ( /** * auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다. * - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시). - * - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다. + * - SIGNED_OUT: 저장소는 건드리지 않는다(로그아웃 경로가 직접 지운다). 로그인된 사용자가 있을 때 + * onSignedOut 을 부른다 — 다른 기기의 전역 로그아웃·토큰 폐기로 auth 클라이언트가 세션을 버리면 + * 서비스가 익명 키로 계속 도는 대신 로그아웃 정리를 하게 한다. (onSignedOut 이 없으면 무시) * onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다. */ export function bindAuthEvents( subscribe: AuthStateSubscribe, 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() diff --git a/apps/desktop/src/main/suggestion-overlay-policy.ts b/apps/desktop/src/main/suggestion-overlay-policy.ts index 454a562..832729c 100644 --- a/apps/desktop/src/main/suggestion-overlay-policy.ts +++ b/apps/desktop/src/main/suggestion-overlay-policy.ts @@ -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' 이벤트에 어떻게 반영할지 결정한다. diff --git a/apps/desktop/src/main/windows/suggestion-overlay-presenter.ts b/apps/desktop/src/main/windows/suggestion-overlay-presenter.ts new file mode 100644 index 0000000..54ced22 --- /dev/null +++ b/apps/desktop/src/main/windows/suggestion-overlay-presenter.ts @@ -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(), + } +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 946133d..6b68beb 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -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(IPC_CHANNELS.FILE_TRANSCRIPTION.START, params), cancel: () => invoke(IPC_CHANNELS.FILE_TRANSCRIPTION.CANCEL), + /** 드롭한 File 의 실제 경로 (Electron 32+ 는 File.path 가 없다). 알 수 없으면 빈 문자열. */ + getPathForFile: (file: File): string => webUtils.getPathForFile(file), getState: () => invoke(IPC_CHANNELS.FILE_TRANSCRIPTION.GET_STATE), onProgress: (cb: (data: FileTranscriptionProgress) => void): Unsubscribe => @@ -725,6 +733,9 @@ const electronAPI = { invoke(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params), getState: () => invoke(IPC_CHANNELS.MEETING_MODE.GET_STATE), + /** 진행 중인 녹음의 실시간 전사·메모 (페이지를 다시 열 때 복원용). 녹음 중이 아니면 null */ + getLiveSession: () => + invoke(MEETING_MODE_GET_LIVE_SESSION_CHANNEL), getSessions: (params: MeetingGetSessionsParams) => invoke(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params), getSession: (params: MeetingGetSessionParams) => @@ -782,10 +793,15 @@ const electronAPI = { meetingChat: { send: (params: MeetingChatSendParams) => invoke(IPC_CHANNELS.MEETING_CHAT.SEND, params), - cancel: () => - invoke(IPC_CHANNELS.MEETING_CHAT.CANCEL), - clear: () => - invoke(IPC_CHANNELS.MEETING_CHAT.CLEAR), + /** sessionId 를 주면 그 회의의 채팅만 멈춘다 */ + cancel: (params?: { sessionId: string }) => + invoke(IPC_CHANNELS.MEETING_CHAT.CANCEL, params), + /** sessionId 를 주면 그 회의의 대화만 지운다(생략하면 전부) */ + clear: (params?: { sessionId: string }) => + invoke(IPC_CHANNELS.MEETING_CHAT.CLEAR, params), + /** main 이 기억하는 이 회의의 대화 — 패널을 다시 열 때 보여 준다 */ + getHistory: (params: { sessionId: string }) => + invoke(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 => diff --git a/apps/desktop/src/renderer/components/FileDropZone.tsx b/apps/desktop/src/renderer/components/FileDropZone.tsx index 3436989..ce32a0c 100644 --- a/apps/desktop/src/renderer/components/FileDropZone.tsx +++ b/apps/desktop/src/renderer/components/FileDropZone.tsx @@ -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) diff --git a/apps/desktop/src/renderer/components/file-drop-model.ts b/apps/desktop/src/renderer/components/file-drop-model.ts new file mode 100644 index 0000000..cf2b22a --- /dev/null +++ b/apps/desktop/src/renderer/components/file-drop-model.ts @@ -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 + } +} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx index 9231eaa..f3c1e64 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingChatPanel.tsx @@ -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(() => { diff --git a/apps/desktop/src/renderer/components/meeting/meeting-chat-history.ts b/apps/desktop/src/renderer/components/meeting/meeting-chat-history.ts new file mode 100644 index 0000000..217801d --- /dev/null +++ b/apps/desktop/src/renderer/components/meeting/meeting-chat-history.ts @@ -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] +} diff --git a/apps/desktop/src/renderer/pages/CommandsPage.tsx b/apps/desktop/src/renderer/pages/CommandsPage.tsx index 12a4d12..ff5aa46 100644 --- a/apps/desktop/src/renderer/pages/CommandsPage.tsx +++ b/apps/desktop/src/renderer/pages/CommandsPage.tsx @@ -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(null) const [saving, setSaving] = useState(false) + /** 저장 거부 사유 — 대화상자 안에 보여 준다 */ + const [formError, setFormError] = useState(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 { setFormName(e.target.value)} + onChange={(e) => { setFormName(e.target.value); setFormError(null) }} fullWidth autoFocus + inputProps={{ maxLength: INSTRUCTION_LIMITS.name }} sx={{ mt: 1.5 }} /> setFormDesc(e.target.value)} + onChange={(e) => { setFormDesc(e.target.value); setFormError(null) }} fullWidth + inputProps={{ maxLength: INSTRUCTION_LIMITS.description }} sx={{ mt: 2.5 }} /> 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 && ( + + {formError} + + )} setDialogOpen(false)}> {t('common.cancel')} - + {saving ? t('common.saving') || '저장 중...' : t('common.save')} diff --git a/apps/desktop/src/renderer/pages/MeetingModePage.tsx b/apps/desktop/src/renderer/pages/MeetingModePage.tsx index 081ed61..06eacd8 100644 --- a/apps/desktop/src/renderer/pages/MeetingModePage.tsx +++ b/apps/desktop/src/renderer/pages/MeetingModePage.tsx @@ -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 { {!isProcessing && ( - + {t('meeting.stopRecording')} diff --git a/apps/desktop/src/renderer/pages/commands/command-form.ts b/apps/desktop/src/renderer/pages/commands/command-form.ts new file mode 100644 index 0000000..48a7e12 --- /dev/null +++ b/apps/desktop/src/renderer/pages/commands/command-form.ts @@ -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 +} diff --git a/apps/desktop/src/renderer/pages/meeting/live-restore.ts b/apps/desktop/src/renderer/pages/meeting/live-restore.ts new file mode 100644 index 0000000..833849c --- /dev/null +++ b/apps/desktop/src/renderer/pages/meeting/live-restore.ts @@ -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( + 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)) +} diff --git a/apps/desktop/tests/main/config-effects-overlay-redteam-r2-1.test.ts b/apps/desktop/tests/main/config-effects-overlay-redteam-r2-1.test.ts new file mode 100644 index 0000000..d776e44 --- /dev/null +++ b/apps/desktop/tests/main/config-effects-overlay-redteam-r2-1.test.ts @@ -0,0 +1,194 @@ +// tests/main/config-effects-overlay-redteam-r2-1.test.ts +// - 설정 키별 부수효과는 어느 경로로 바뀌든(범용 config:set 포함) 한 번씩 적용된다 +// - 제안 오버레이 표시 정책(프레젠터): show→update→hide 전이, 앵커 없음, 예외 격리, 매핑 단일화 + +import { describe, expect, it, vi } from 'vitest' +import type { SuggestionState } from '@d3ro/core/input-intelligence' +import { ConfigEffects, isEffectiveChange, type ConfigChange } from '../../src/main/config-effects' +import { + SuggestionOverlayPresenter, + toSuggestionOverlayContent, + toSuggestionOverlayShowPayload, + type SuggestionOverlayView, +} from '../../src/main/windows/suggestion-overlay-presenter' + +vi.mock('../../src/main/windows/WindowManager', () => ({ + showSuggestionOverlay: vi.fn(), + updateSuggestionOverlay: vi.fn(), + hideSuggestionOverlay: vi.fn(), + isSuggestionOverlayVisible: vi.fn(() => false), +})) + +function bus() { + const listeners = new Set<(change: ConfigChange) => void>() + return { + subscribe: (listener: (change: ConfigChange) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + emit: (change: ConfigChange) => { + for (const listener of [...listeners]) listener(change) + }, + size: () => listeners.size, + } +} + +describe('ConfigEffects', () => { + it('범용 config:set 경로의 변경도 효과를 한 번 적용한다(자동 실행·테마)', () => { + const b = bus() + const effects = new ConfigEffects({ subscribe: b.subscribe }) + const setLoginItem = vi.fn() + const repaint = vi.fn() + effects.register('autoLaunch', (value) => setLoginItem(value === true)) + effects.register('theme', repaint) + effects.start() + + b.emit({ key: 'autoLaunch', value: true, previousValue: false }) + b.emit({ key: 'theme', value: 'nord', previousValue: 'dark' }) + b.emit({ key: 'language', value: 'en', previousValue: 'ko' }) + + expect(setLoginItem).toHaveBeenCalledTimes(1) + expect(setLoginItem).toHaveBeenCalledWith(true) + expect(repaint).toHaveBeenCalledTimes(1) + }) + + it('효과 안에서 같은 값을 다시 저장해도 재귀하지 않는다(AutoLaunchService.setEnabled 는 적용 후 configSet)', () => { + const b = bus() + const effects = new ConfigEffects({ subscribe: b.subscribe }) + const apply = vi.fn((value: unknown) => { + b.emit({ key: 'autoLaunch', value, previousValue: value }) + }) + effects.register('autoLaunch', apply) + effects.start() + + b.emit({ key: 'autoLaunch', value: false, previousValue: true }) + + expect(apply).toHaveBeenCalledTimes(1) + }) + + it('효과 하나가 던져도 다른 효과는 실행되고 오류를 알린다', () => { + const b = bus() + const onError = vi.fn() + const effects = new ConfigEffects({ subscribe: b.subscribe, onError }) + const second = vi.fn() + effects.register('theme', () => { + throw new Error('popup gone') + }) + effects.register('theme', second) + effects.start() + effects.start() // 멱등 + + b.emit({ key: 'theme', value: 'light', previousValue: 'dark' }) + + expect(second).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith('theme', expect.any(Error)) + expect(b.size()).toBe(1) + effects.dispose() + expect(b.size()).toBe(0) + }) + + it('isEffectiveChange: 원시값은 같으면 건너뛰고, 객체는 항상 적용한다', () => { + expect(isEffectiveChange({ key: 'k', value: 1, previousValue: 1 })).toBe(false) + expect(isEffectiveChange({ key: 'k', value: 'a', previousValue: 'b' })).toBe(true) + expect(isEffectiveChange({ key: 'k', value: [1], previousValue: [1] })).toBe(true) + }) +}) + +function state(overrides: Partial = {}): SuggestionState { + return { + enabled: true, + modelId: 'm', + modelAvailable: true, + visible: true, + generating: false, + warmingUp: false, + partialText: null, + candidates: [{ text: '안녕하세요', rank: 0 }], + activeIndex: 0, + targetTotal: 3, + anchor: { x: 1, y: 2, width: 3, height: 4 }, + anchorKind: 'caret', + appName: 'Notepad', + updatedAt: 0, + lastSkipReason: null, + requestsToday: 0, + dailyBudget: 100, + lastLatencyMs: null, + triggerDelayMs: 600, + minPrefixChars: 8, + requestTimeoutMs: 1000, + coolingDown: false, + learnTypedText: false, + telemetryEnabled: true, + overlayInteractive: true, + provenance: null, + ...overrides, + } as SuggestionState +} + +function fakeView(visible = false) { + const view = { + visible, + show: vi.fn(() => { + view.visible = true + }), + update: vi.fn(), + hide: vi.fn(() => { + view.visible = false + }), + isVisible: () => view.visible, + } + return view +} + +describe('SuggestionOverlayPresenter', () => { + it('show → update → hide 로 전이하고, 텔레메트리 표시 플래그를 맞춘다', () => { + const view = fakeView() + const setActive = vi.fn() + const presenter = new SuggestionOverlayPresenter({ view: view as SuggestionOverlayView, setPresentationActive: setActive, warn: vi.fn() }) + + presenter.onUpdated(state()) + presenter.onUpdated(state({ generating: true })) + presenter.onUpdated(state({ candidates: [] })) + + expect(view.show).toHaveBeenCalledTimes(1) + expect(view.show).toHaveBeenCalledWith(expect.objectContaining({ anchorKind: 'caret', appName: 'Notepad' })) + expect(view.update).toHaveBeenCalledTimes(1) + expect(view.update.mock.calls[0][0]).not.toHaveProperty('appName') + expect(view.hide).toHaveBeenCalledTimes(1) + expect(setActive.mock.calls.map((c) => c[0])).toEqual([true, true, false]) + }) + + it('앵커가 없으면 보여 줄 것이 있어도 숨긴다', () => { + const view = fakeView(true) + const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: vi.fn(), warn: vi.fn() }) + presenter.onUpdated(state({ anchor: null })) + expect(view.hide).toHaveBeenCalled() + expect(view.show).not.toHaveBeenCalled() + }) + + it('창 호출이 던져도 삼키고 경고만 남긴다 — cleared 는 플래그를 항상 내린다', () => { + const view = fakeView() + view.show.mockImplementation(() => { + throw new Error('window destroyed') + }) + view.hide.mockImplementation(() => { + throw new Error('window destroyed') + }) + const warn = vi.fn() + const setActive = vi.fn() + const presenter = new SuggestionOverlayPresenter({ view, setPresentationActive: setActive, warn }) + + expect(() => presenter.onUpdated(state())).not.toThrow() + expect(() => presenter.onCleared()).not.toThrow() + expect(warn).toHaveBeenCalledTimes(2) + expect(setActive).toHaveBeenLastCalledWith(false) + }) + + it('show payload 는 update payload(단일 매핑)에 배치 정보만 더한다', () => { + const s = state({ partialText: '부분', warmingUp: true }) + const { anchor, anchorKind, appName, ...content } = toSuggestionOverlayShowPayload(s) + expect(content).toEqual(toSuggestionOverlayContent(s)) + expect({ anchor, anchorKind, appName }).toEqual({ anchor: s.anchor, anchorKind: 'caret', appName: 'Notepad' }) + }) +}) diff --git a/apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts b/apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts new file mode 100644 index 0000000..f107009 --- /dev/null +++ b/apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts @@ -0,0 +1,52 @@ +// tests/main/db-open-atomic-redteam-r2-1.test.ts +// 계정 DB 가 손상돼 열리지 않아도 앱이 DB 없이 남지 않는다 — openForUser 는 새 DB 를 연 뒤에야 +// 이전 DB 를 닫는다(원자적 교체). + +import { afterEach, describe, expect, it } from 'vitest' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' +import { ErrorCode } from '@d3ro/core/errors' +import { sql } from 'drizzle-orm' +import { + closeCurrent, + getCurrentUserId, + getDatabase, + isDatabaseOpen, + openForUser, + openLocal, +} from '../../src/main/db' + +const CORRUPT_USER = `corrupt-${process.pid}-${Date.now()}` +const GOOD_USER = `good-${process.pid}-${Date.now()}` + +function userDir(userId: string): string { + return path.join(app.getPath('userData'), 'users', userId) +} + +afterEach(() => { + closeCurrent() + for (const id of [CORRUPT_USER, GOOD_USER]) fs.rmSync(userDir(id), { recursive: true, force: true }) +}) + +describe('openForUser 원자적 교체', () => { + it('손상된 계정 DB 를 열지 못하면 이전(로컬) DB 가 그대로 열려 있다', () => { + openLocal() + fs.mkdirSync(userDir(CORRUPT_USER), { recursive: true }) + fs.writeFileSync(path.join(userDir(CORRUPT_USER), 'd3ro.db'), 'this is not a sqlite database'.repeat(64)) + + expect(() => openForUser(CORRUPT_USER)).toThrow(expect.objectContaining({ code: ErrorCode.DBOpenFailed })) + + expect(isDatabaseOpen()).toBe(true) + expect(getCurrentUserId()).toBe('_local') + expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow() + }) + + it('정상 계정 DB 는 열고 이전 DB 와 교체한다', () => { + openLocal() + const result = openForUser(GOOD_USER) + expect(result.dbPath).toContain(GOOD_USER) + expect(getCurrentUserId()).toBe(GOOD_USER) + expect(() => getDatabase().run(sql`SELECT 1`)).not.toThrow() + }) +}) diff --git a/apps/desktop/tests/main/services/caption-stop-failure-redteam-r2-1.test.ts b/apps/desktop/tests/main/services/caption-stop-failure-redteam-r2-1.test.ts new file mode 100644 index 0000000..17d4c78 --- /dev/null +++ b/apps/desktop/tests/main/services/caption-stop-failure-redteam-r2-1.test.ts @@ -0,0 +1,158 @@ +// tests/main/services/caption-stop-failure-redteam-r2-1.test.ts +// 정지 중 마지막 문장 인식이 실패해도 CaptionService 가 'stopping' 에 굳지 않는다: +// - stop() 은 성공하고 상태는 'inactive', 마이크 캡처는 정확히 한 번 멈춘다 +// - 다시 start() 할 수 있다 +// - 실패는 던지지 않는 'failure' 이벤트로 알린다 +// + CaptionAudioSources 의 멱등 해제 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CaptionAudioSources } from '../../../src/main/services/caption/CaptionAudioSources' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +const config = vi.hoisted(() => ({ values: {} as Record })) +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: vi.fn((key: string) => config.values[key]), + configSet: vi.fn(), +})) + +vi.mock('../../../src/main/services/LocalSTTService', () => ({ + getLocalSTTService: () => ({ + initialize: vi.fn(async () => undefined), + ensureAuxModel: vi.fn(async () => undefined), + transcribe: vi.fn(async () => { + throw new Error('sidecar HTTP 500') + }), + }), +})) + +const audio = vi.hoisted(() => ({ start: vi.fn(async () => undefined), stop: vi.fn(async () => undefined) })) +vi.mock('../../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => ({ ...audio, on: vi.fn(), off: vi.fn() }), + calculateRMS: () => 0.1, +})) + +vi.mock('../../../src/main/services/SoundEffectService', () => ({ + getSoundEffectService: () => ({ play: vi.fn() }), +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: () => false, generate: vi.fn() }), +})) + +vi.mock('../../../src/main/services/HistoryService', () => ({ + getHistoryService: () => ({ create: vi.fn(() => ({ id: 'h' })) }), +})) + +// 트랙: flush 가 마지막 문장을 인식하려다 실패한다 — 실제 트랙처럼 deps.onError 로 보고한다 +const trackMode = vi.hoisted(() => ({ mode: 'report' as 'report' | 'reject' })) +vi.mock('../../../src/main/services/caption/StreamingCaptionTrack', () => ({ + StreamingCaptionTrack: class { + constructor(private readonly deps: { onError: (err: unknown) => void }) {} + push(): void {} + async tick(): Promise {} + async flush(): Promise { + if (trackMode.mode === 'reject') throw new Error('flush exploded') + this.deps.onError(new Error('sidecar HTTP 500')) + } + }, +})) + +type CaptionModule = typeof import('../../../src/main/services/CaptionService') +let mod: CaptionModule + +beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + config.values = {} + trackMode.mode = 'report' + mod = await import('../../../src/main/services/CaptionService') + mod.resetCaptionServiceForTests() +}) + +describe('CaptionService.stop — flush 실패', () => { + it('인식 실패(onError)가 있어도 정지·해제·초기화를 끝내고 다시 시작할 수 있다', async () => { + const caption = mod.getCaptionService() + const failures: string[] = [] + caption.on('failure', (err) => failures.push(err.message)) + await caption.start({ owner: 'meeting', audioSource: 'mic', showOverlay: false, persistHistory: false }) + + await expect(caption.stop('meeting')).resolves.toBeNull() + + expect(caption.getState()).toBe('inactive') + expect(audio.stop).toHaveBeenCalledTimes(1) + expect(failures).toHaveLength(1) + + await expect(caption.start({ audioSource: 'mic', showOverlay: false })).resolves.toBeUndefined() + expect(caption.getState()).toBe('active') + }) + + it('failure 리스너가 없어도 던지지 않는다(예전엔 리스너 없는 error 가 stop 을 튕겼다)', async () => { + const caption = mod.getCaptionService() + await caption.start({ audioSource: 'mic', showOverlay: false }) + await expect(caption.stop()).resolves.toBeNull() + expect(caption.getState()).toBe('inactive') + expect(audio.stop).toHaveBeenCalledTimes(1) + }) + + it('flush 자체가 reject 해도 해제하고 inactive 가 된다', async () => { + trackMode.mode = 'reject' + const caption = mod.getCaptionService() + await caption.start({ audioSource: 'mic', showOverlay: false }) + await expect(caption.stop()).resolves.toBeNull() + expect(caption.getState()).toBe('inactive') + expect(audio.stop).toHaveBeenCalledTimes(1) + // 두 번째 정지는 아무것도 하지 않는다 + await expect(caption.stop()).resolves.toBeNull() + expect(audio.stop).toHaveBeenCalledTimes(1) + }) +}) + +describe('CaptionAudioSources', () => { + function sources(options: { stopFails?: boolean } = {}) { + const mic = { + onChunk: vi.fn(() => unsubscribe), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => { + if (options.stopFails) throw new Error('sox gone') + }), + } + const unsubscribe = vi.fn() + const system = { requestStart: vi.fn(), requestStop: vi.fn() } + const onReleaseError = vi.fn() + const audioSources = new CaptionAudioSources({ mic: () => mic, system, onReleaseError }) + return { audioSources, mic, unsubscribe, system, onReleaseError } + } + + it('release 는 멱등이다 — 마이크 stop 은 start 한 만큼만(한 번) 부른다', async () => { + const h = sources() + await h.audioSources.acquire('both', { onMicChunk: vi.fn() }) + expect(h.mic.start).toHaveBeenCalledTimes(1) + expect(h.system.requestStart).toHaveBeenCalledTimes(1) + + await h.audioSources.release() + await h.audioSources.release() + + expect(h.mic.stop).toHaveBeenCalledTimes(1) + expect(h.unsubscribe).toHaveBeenCalledTimes(1) + expect(h.audioSources.holdsMic).toBe(false) + }) + + it('마이크 해제가 실패해도 시스템 소리 중지 요청은 보낸다', async () => { + const h = sources({ stopFails: true }) + await h.audioSources.acquire('both', { onMicChunk: vi.fn() }) + await expect(h.audioSources.release()).resolves.toBeUndefined() + expect(h.onReleaseError).toHaveBeenCalledWith('mic', expect.any(Error)) + expect(h.system.requestStop).toHaveBeenCalled() + }) + + it('system 소스만 잡으면 마이크는 건드리지 않는다', async () => { + const h = sources() + await h.audioSources.acquire('system', { onMicChunk: vi.fn() }) + await h.audioSources.release() + expect(h.mic.start).not.toHaveBeenCalled() + expect(h.mic.stop).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts b/apps/desktop/tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts new file mode 100644 index 0000000..09c6898 --- /dev/null +++ b/apps/desktop/tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts @@ -0,0 +1,165 @@ +// tests/main/services/file-transcription-cancel-redteam-r2-1.test.ts +// 파일 전사 취소가 실행 중인 ffmpeg 를 곧바로 종료하고 서비스를 idle 로 돌린다. +// 취소된 작업의 늦은 정리(상태 되돌리기·임시 폴더 삭제)가 다음 작업을 덮지 않는다. +// + 드롭 경로·시작 실패 분류·늦은 응답 무시(렌더러 순수 모델) + +import { EventEmitter } from 'events' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' +import { + StartRequestTracker, + classifyStartFailure, + resolveDroppedFilePath, +} from '../../../src/renderer/components/file-drop-model' + +type FakeProcess = EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + exitCode: number | null + killed: boolean + kill: () => boolean + args: string[] +} + +const hoisted = vi.hoisted(() => ({ + spawn: vi.fn(), + procs: [] as unknown[], + /** true 면 WAV 변환(ffmpeg -i ... out.wav)이 끝나지 않고 매달린다 */ + hangConvert: true, +})) + +vi.mock('child_process', () => ({ spawn: hoisted.spawn })) +vi.mock('../../../src/main/utils/paths', () => ({ getFfmpegPath: () => '/bundled/ffmpeg.exe' })) +vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ ensure: vi.fn() }) })) +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) +vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn(() => undefined) })) +vi.mock('../../../src/main/services/stt/STTManager', () => ({ + getSTTManager: () => ({ transcribe: vi.fn(async () => ({ text: 'hello', segments: [] })) }), +})) +vi.mock('../../../src/main/services/HistoryService', () => ({ getHistoryService: () => ({ create: vi.fn() }) })) +vi.mock('../../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }), +})) +vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null })) + +function fakeSpawn(_command: string, args: string[]): FakeProcess { + const proc = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + exitCode: null as number | null, + killed: false, + args, + kill(): boolean { + proc.killed = true + queueMicrotask(() => proc.emit('close', null)) + return true + }, + }) + hoisted.procs.push(proc) + const isConvert = args[args.length - 1].endsWith('.wav') && !args.includes('null') + queueMicrotask(() => { + if (isConvert && hoisted.hangConvert) return + if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:10.00')) + if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm')) + proc.exitCode = 0 + proc.emit('close', 0) + }) + return proc +} + +describe('FileTranscriptionService 취소', () => { + let inputFile: string + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + hoisted.procs = [] + hoisted.hangConvert = true + hoisted.spawn.mockImplementation(fakeSpawn) + inputFile = path.join(os.tmpdir(), `d3ro-ft-cancel-${Date.now()}.mp4`) + fs.writeFileSync(inputFile, 'x') + }) + + afterEach(() => { + fs.rmSync(inputFile, { force: true }) + }) + + it('변환 중 취소하면 ffmpeg 를 종료하고, 곧바로 idle 이 되어 새 작업을 받는다', async () => { + const mod = await import('../../../src/main/services/FileTranscriptionService') + mod.resetFileTranscriptionServiceForTests() + const service = mod.getFileTranscriptionService() + + const first = service.startTranscription(inputFile, 'en') + await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1)) + const convert = hoisted.procs[0] as FakeProcess + const firstTempDir = path.dirname(convert.args[convert.args.length - 1]) + + service.cancel() + + expect(convert.killed).toBe(true) + expect(service.state).toBe('idle') + await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled }) + expect(fs.existsSync(firstTempDir)).toBe(false) + + // 새 작업은 거부되지 않고 끝까지 간다 + hoisted.hangConvert = false + const result = await service.startTranscription(inputFile, 'en') + expect(result.fullText).toBe('hello') + }) + + it('취소된 작업의 늦은 정리가 다음 작업의 상태·jobId 를 지우지 않는다', async () => { + const mod = await import('../../../src/main/services/FileTranscriptionService') + mod.resetFileTranscriptionServiceForTests() + const service = mod.getFileTranscriptionService() + + const first = service.startTranscription(inputFile, 'en') + await vi.waitFor(() => expect(hoisted.procs).toHaveLength(1)) + service.cancel() + + // 이전 작업이 정리되기 전에 새 작업을 시작한다(여전히 변환 중에 매달림) + const second = service.startTranscription(inputFile, 'en') + await expect(first).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled }) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const info = service.getStateInfo() + expect(info.state).toBe('converting') + expect(info.jobId).not.toBeNull() + + service.cancel() + await expect(second).rejects.toMatchObject({ code: ErrorCode.FileTranscriptionCancelled }) + expect(service.getStateInfo()).toMatchObject({ state: 'idle', jobId: null }) + }) +}) + +describe('FileDropZone 모델', () => { + it('드롭한 파일 경로는 getPathForFile 로 얻고, 못 얻으면 null(대화상자로 떨어지지 않는다)', () => { + const file = { name: 'meeting.mp3' } as File + expect(resolveDroppedFilePath(file, () => 'C:\\rec\\meeting.mp3')).toBe('C:\\rec\\meeting.mp3') + expect(resolveDroppedFilePath(file, () => '')).toBeNull() + expect( + resolveDroppedFilePath(file, () => { + throw new Error('not a real file') + }), + ).toBeNull() + }) + + it('취소·대화상자 닫기는 오류로 보이지 않는다', () => { + expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'Transcription cancelled' })).toBe('cancelled') + expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionCancelled, message: 'File selection cancelled' })).toBe('cancelled') + expect(classifyStartFailure({ code: ErrorCode.FileTranscriptionFFmpegFailed, message: 'ffmpeg exited' })).toBe('error') + }) + + it('취소·새 시작 뒤에는 이전 요청의 늦은 응답을 무시한다', () => { + const tracker = new StartRequestTracker() + const first = tracker.begin() + tracker.invalidate() + const second = tracker.begin() + expect(tracker.isCurrent(first)).toBe(false) + expect(tracker.isCurrent(second)).toBe(true) + }) +}) diff --git a/apps/desktop/tests/main/services/llm-limits-routing-redteam-r2-1.test.ts b/apps/desktop/tests/main/services/llm-limits-routing-redteam-r2-1.test.ts new file mode 100644 index 0000000..f86ab5c --- /dev/null +++ b/apps/desktop/tests/main/services/llm-limits-routing-redteam-r2-1.test.ts @@ -0,0 +1,154 @@ +// tests/main/services/llm-limits-routing-redteam-r2-1.test.ts +// - Premium 단일 턴(generate/processText)은 프록시 한도를 넘으면 보내지 않고 LLMPromptTooLong +// - 단일 턴도 호출 단위 signal 로 취소된다(전역 cancelGeneration 없이) +// - 게이트웨이: 취소는 로컬로 폴백하지 않는다, 채팅 스트림에 signal 을 넘긴다 + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' +import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat' +import { + createRoutedLlmGateway, + type LocalLlmAdapter, + type PremiumLlmAdapter, +} from '../../../src/main/services/llm/LlmGateway' + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +const cloud = vi.hoisted(() => ({ + isEnabled: (): boolean => true, + isAuthenticated: (): boolean => true, + invokeFunctionStream: vi.fn(), + invokeFunction: vi.fn(), +})) + +vi.mock('../../../src/main/services/CloudSyncService', () => ({ + getCloudSyncService: () => cloud, +})) + +import { getPremiumLLMService, resetPremiumLLMServiceForTests } from '../../../src/main/services/PremiumLLMService' + +const okResponse = { + data: { + id: 'm', model: 'claude', role: 'assistant', + content: [{ type: 'text', text: '결과' }], + stop_reason: 'end_turn', usage: { input_tokens: 1, output_tokens: 1 }, + }, + error: null, +} + +describe('PremiumLLMService 단일 턴 한도', () => { + beforeEach(() => { + resetPremiumLLMServiceForTests() + cloud.invokeFunction.mockReset() + cloud.invokeFunction.mockResolvedValue(okResponse) + }) + + it('8,000자를 넘는 generate 입력은 보내지 않고 LLMPromptTooLong 이다', async () => { + const text = 'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1) + await expect(getPremiumLLMService().generate(text)).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) + expect(cloud.invokeFunction).not.toHaveBeenCalled() + }) + + it('system 이 한도를 넘어도 보내지 않는다', async () => { + await expect( + getPremiumLLMService().generate('q', { systemPrompt: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) }), + ).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) + await expect( + getPremiumLLMService().processText('y'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars + 1), 'refine'), + ).rejects.toMatchObject({ code: ErrorCode.LLMPromptTooLong }) + expect(cloud.invokeFunction).not.toHaveBeenCalled() + }) + + it('경계값(정확히 8,000자, 앞뒤 공백 제외)은 보낸다', async () => { + const text = ` ${'x'.repeat(LLM_PROXY_CHAT_LIMITS.maxMessageChars)} ` + await expect(getPremiumLLMService().generate(text)).resolves.toEqual({ text: '결과' }) + expect(cloud.invokeFunction).toHaveBeenCalledTimes(1) + }) + + it('단일 턴도 호출 단위 signal 로 취소된다(cancelGeneration 없이)', async () => { + cloud.invokeFunction.mockImplementation( + (_n: string, _b: unknown, options?: { signal?: AbortSignal }) => + new Promise((resolve) => { + options?.signal?.addEventListener('abort', () => + resolve({ data: null, error: { message: 'FunctionsFetchError: aborted' } }), { once: true }) + }), + ) + const controller = new AbortController() + const pending = getPremiumLLMService().generate('q', { signal: controller.signal }) + controller.abort() + await expect(pending).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled }) + }) +}) + +async function* tokens(...values: string[]): AsyncGenerator { + for (const v of values) yield v + return values.join('') +} + +function premium(overrides: Partial = {}): PremiumLlmAdapter { + return { + isAvailable: vi.fn(() => true), + processText: vi.fn(async () => 'premium'), + generate: vi.fn(async () => ({ text: 'premium' })), + chatStream: vi.fn(() => tokens('p')), + cancelGeneration: vi.fn(), + ...overrides, + } +} + +function local(overrides: Partial = {}): LocalLlmAdapter { + return { + isAvailable: vi.fn(() => true), + processText: vi.fn(async () => 'local'), + generate: vi.fn(async () => ({ text: 'local' })), + chatStream: vi.fn(() => tokens('l')), + cancelGeneration: vi.fn(), + ...overrides, + } +} + +describe('LlmGateway 호출 단위 취소', () => { + it('Premium 호출이 취소되면 로컬로 폴백하지 않고 취소를 전파한다', async () => { + const controller = new AbortController() + const l = local() + const p = premium({ + generate: vi.fn(async () => { + controller.abort() + throw Object.assign(new Error('cancelled'), { code: ErrorCode.LLMProcessingCancelled }) + }), + }) + const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => l }) + + await expect(gw.generate('q', undefined, { signal: controller.signal })).rejects.toBeTruthy() + expect(l.generate).not.toHaveBeenCalled() + }) + + it('signal 을 Premium 단일 턴·채팅 스트림에 넘기고, 다른 호출은 건드리지 않는다', async () => { + const p = premium() + const gw = createRoutedLlmGateway({ getBackend: () => 'online', loadPremium: async () => p, getLocal: () => local() }) + const a = new AbortController() + const b = new AbortController() + + await gw.processText('t', 'refine', undefined, undefined, { signal: a.signal }) + await gw.openChatStream([{ role: 'user', content: 'hi' }], { signal: b.signal }) + a.abort() + + expect(p.processText).toHaveBeenCalledWith('t', 'refine', undefined, undefined, { signal: a.signal }) + expect(p.chatStream).toHaveBeenCalledWith([{ role: 'user', content: 'hi' }], { signal: b.signal }) + expect(b.signal.aborted).toBe(false) + expect(p.cancelGeneration).not.toHaveBeenCalled() + }) + + it('이미 취소된 signal 이면 어떤 백엔드도 부르지 않는다', async () => { + const controller = new AbortController() + controller.abort() + const l = local() + const gw = createRoutedLlmGateway({ getBackend: () => 'local', loadPremium: async () => premium(), getLocal: () => l }) + await expect(gw.processText('t', 'refine', undefined, undefined, { signal: controller.signal })).rejects.toMatchObject({ + code: ErrorCode.LLMProcessingCancelled, + }) + expect(l.processText).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/main/services/meeting-session-redteam-r2-1.test.ts b/apps/desktop/tests/main/services/meeting-session-redteam-r2-1.test.ts new file mode 100644 index 0000000..8a72109 --- /dev/null +++ b/apps/desktop/tests/main/services/meeting-session-redteam-r2-1.test.ts @@ -0,0 +1,151 @@ +// tests/main/services/meeting-session-redteam-r2-1.test.ts +// MeetingRecordingSession(가짜 포트)·전사 줄이기(map-reduce) 단위 테스트 + +import { describe, expect, it, vi } from 'vitest' +import type { CaptionSegment } from '@d3ro/core/types' +import { ErrorCode } from '@d3ro/core/errors' +import { MeetingRecordingSession } from '../../../src/main/services/meeting/MeetingRecordingSession' +import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser' + +function fakePorts() { + let segmentListener: ((segment: CaptionSegment) => void) | null = null + let chunkListener: ((buffer: Buffer) => void) | null = null + let releaseStop: () => void = () => undefined + const stop = vi.fn(async () => { + await new Promise((resolve) => { + releaseStop = resolve + }) + const last: CaptionSegment = { id: 'last', text: '마지막', timestamp: 3_000, isFinal: true } + segmentListener?.(last) + return { segments: [last] } + }) + const unsubscribeSegment = vi.fn(() => { + segmentListener = null + }) + const ports = { + caption: { + onSegment: (listener: (segment: CaptionSegment) => void) => { + segmentListener = listener + return unsubscribeSegment + }, + stop, + }, + audio: { + onChunk: (listener: (buffer: Buffer) => void) => { + chunkListener = listener + return () => { + chunkListener = null + } + }, + }, + now: () => 10_000, + } + return { + ports, + stop, + unsubscribeSegment, + emitSegment: (segment: CaptionSegment) => segmentListener?.(segment), + emitChunk: (buffer: Buffer) => chunkListener?.(buffer), + releaseStop: () => releaseStop(), + } +} + +describe('MeetingRecordingSession', () => { + it('동시에 두 번 멈춰도 자막 정지와 스냅샷은 한 번 — 같은 불변 스냅샷을 받는다', async () => { + const h = fakePorts() + const session = new MeetingRecordingSession('s1', 1_000, h.ports) + session.attachCaption() + session.attachAudio() + h.emitSegment({ id: 'a', text: '첫 문장', timestamp: 2_000, isFinal: true }) + h.emitChunk(Buffer.from([1, 2])) + + const first = session.stop() + const second = session.stop() + expect(session.isStopping).toBe(true) + // 정지가 시작되면 메모·오디오는 더 받지 않는다 + expect(session.addMemo('m', '늦은 메모')).toBeNull() + h.emitChunk(Buffer.from([3])) + h.releaseStop() + const [a, b] = await Promise.all([first, second]) + + expect(h.stop).toHaveBeenCalledTimes(1) + expect(a).toBe(b) + expect(a.segments.map((s) => s.text)).toEqual(['첫 문장', '마지막']) + expect(a.durationMs).toBe(9_000) + expect([...a.pcm]).toEqual([1, 2]) + expect(Object.isFrozen(a)).toBe(true) + expect(h.unsubscribeSegment).toHaveBeenCalledTimes(1) + }) + + it('자막 정지가 실패해도 그때까지의 세그먼트로 스냅샷을 만든다', async () => { + const h = fakePorts() + const onStopError = vi.fn() + h.ports.caption.stop = vi.fn(async () => { + throw new Error('stt 500') + }) + const session = new MeetingRecordingSession('s2', 1_000, { ...h.ports, onStopError }) + session.attachCaption() + h.emitSegment({ id: 'a', text: '남은 문장', timestamp: 2_000, isFinal: true }) + + const snapshot = await session.stop() + + expect(onStopError).toHaveBeenCalled() + expect(snapshot.segments.map((s) => s.text)).toEqual(['남은 문장']) + }) + + it('checkpoint 는 멈추지 않고 전사만(오디오 없이) 준다', () => { + const h = fakePorts() + const session = new MeetingRecordingSession('s3', 1_000, h.ports) + session.attachCaption() + session.attachAudio() + h.emitChunk(Buffer.from([9])) + h.emitSegment({ id: 'a', text: '중간', timestamp: 2_000, isFinal: true }) + + const snapshot = session.checkpoint({ includeAudio: false }) + + expect(snapshot.segments).toHaveLength(1) + expect(snapshot.pcm.length).toBe(0) + expect(session.isStopping).toBe(false) + }) +}) + +describe('condenseTranscriptToBudget (map-reduce)', () => { + const transcript = Array.from({ length: 300 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} `.padEnd(60, '가')).join('\n') + + it('한도 안이면 LLM 을 부르지 않고 원문을 돌려준다', async () => { + const llm = { generate: vi.fn() } + const result = await condenseTranscriptToBudget(llm, '[00:01] 짧은 회의', 8_000) + expect(result).toEqual({ text: '[00:01] 짧은 회의', condensed: false, rounds: 0 }) + expect(llm.generate).not.toHaveBeenCalled() + }) + + it('한도를 넘으면 조각마다 줄이고, 각 조각 입력과 결과 모두 한도 안이다', async () => { + const inputs: string[] = [] + const llm = { + generate: vi.fn(async (text: string) => { + inputs.push(text) + return { text: `요약 ${inputs.length}` } + }), + } + const result = await condenseTranscriptToBudget(llm, transcript, 8_000) + expect(transcript.length).toBeGreaterThan(8_000) + expect(result.condensed).toBe(true) + expect(result.text.length).toBeLessThanOrEqual(8_000) + expect(inputs.length).toBeGreaterThan(1) + for (const input of inputs) expect(input.length).toBeLessThanOrEqual(8_000) + expect(result.text).toContain(`1/${inputs.length}`) + }) + + it('여러 번 줄여도 넘으면 조용히 자르지 않고 LLMPromptTooLong', async () => { + const llm = { generate: vi.fn(async (text: string) => ({ text })) } + await expect(condenseTranscriptToBudget(llm, transcript, 8_000, { maxRounds: 1 })).rejects.toMatchObject({ + code: ErrorCode.LLMPromptTooLong, + }) + }) + + it('남은 자리가 없으면(메모가 한도를 다 쓰면) LLMPromptTooLong', async () => { + await expect(condenseTranscriptToBudget({ generate: vi.fn() }, 'x', 0)).rejects.toMatchObject({ + code: ErrorCode.LLMPromptTooLong, + }) + }) +}) diff --git a/apps/desktop/tests/main/services/voice-conversation-redteam-r2-1.test.ts b/apps/desktop/tests/main/services/voice-conversation-redteam-r2-1.test.ts new file mode 100644 index 0000000..30fcdaa --- /dev/null +++ b/apps/desktop/tests/main/services/voice-conversation-redteam-r2-1.test.ts @@ -0,0 +1,210 @@ +// tests/main/services/voice-conversation-redteam-r2-1.test.ts +// VoiceConversationService 회귀 테스트: +// - 오류 알림이 리스너 없는 'error' 로 던져져 세션이 'thinking' 에 마이크가 꺼진 채 굳던 버그 +// - 텍스트 턴이 마이크를 멈추지 않고 다시 start 해 캡처 참조 카운트가 쌓이고 세션을 끝내도 마이크가 열려 있던 버그 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { + getVoiceConversationService, + resetVoiceConversationServiceForTests, +} from '../../../src/main/services/VoiceConversationService' + +const mocks = vi.hoisted(() => ({ + audioHandlers: new Map void>(), + refCount: 0, + audioStart: vi.fn(), + audioStop: vi.fn(), + transcribe: vi.fn(), + chatStream: vi.fn(), + speakSentences: vi.fn(async () => undefined), + send: vi.fn(), + play: vi.fn(), +})) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +// AudioCaptureService 의 참조 카운트 의미를 흉내 낸다: start 는 +1, stop 은 -1 +vi.mock('../../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => ({ + start: mocks.audioStart, + stop: mocks.audioStop, + on: (event: string, handler: (payload: { buffer: Buffer }) => void) => { + mocks.audioHandlers.set(event, handler) + }, + off: (event: string) => { + mocks.audioHandlers.delete(event) + }, + }), +})) + +vi.mock('../../../src/main/services/LocalSTTService', () => ({ + getLocalSTTService: () => ({ + initialize: vi.fn(async () => undefined), + transcribe: mocks.transcribe, + }), +})) + +vi.mock('../../../src/main/services/PremiumLLMService', () => ({ + getPremiumLLMService: () => ({ cancelGeneration: vi.fn(), isAvailable: () => false }), +})) + +vi.mock('../../../src/main/services/LocalLLMService', () => ({ + getLocalLLMService: () => ({ isAvailable: () => true, chatStream: mocks.chatStream }), +})) + +vi.mock('../../../src/main/services/TTSPlaybackService', () => ({ + getTTSPlaybackService: () => ({ speakSentences: mocks.speakSentences, stop: vi.fn() }), +})) + +vi.mock('../../../src/main/services/SoundEffectService', () => ({ + getSoundEffectService: () => ({ play: mocks.play }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: () => undefined, +})) + +vi.mock('../../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }), +})) + +vi.mock('../../../src/main/windows/WindowManager', () => ({ + getMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }), +})) + +async function* answer(text: string): AsyncGenerator { + yield text + return text +} + +async function* failing(): AsyncGenerator { + throw new Error('ollama down') +} + +function feedAudio(bytes: number): void { + const handler = mocks.audioHandlers.get('audio-data') + if (!handler) throw new Error('audio-data handler not bound') + handler({ buffer: Buffer.alloc(bytes) }) +} + +function conversationErrors(): unknown[] { + return mocks.send.mock.calls + .filter(([channel]) => channel === IPC_CHANNELS.VOICE_CONVERSATION.ERROR) + .map(([, payload]) => payload) +} + +describe('VoiceConversationService — 오류 경로가 세션을 굳히지 않는다', () => { + beforeEach(() => { + resetVoiceConversationServiceForTests() + vi.clearAllMocks() + mocks.audioHandlers.clear() + mocks.refCount = 0 + mocks.audioStart.mockImplementation(async () => { + mocks.refCount++ + }) + mocks.audioStop.mockImplementation(async () => { + mocks.refCount = Math.max(0, mocks.refCount - 1) + }) + mocks.chatStream.mockImplementation(() => answer('네.')) + }) + + afterEach(() => { + resetVoiceConversationServiceForTests() + }) + + it('0.5초보다 짧게 말하고 멈추면 던지지 않고 listening 으로 돌아가 다시 듣는다', async () => { + const service = getVoiceConversationService() + await service.startSession() + feedAudio(100) + + await expect(service.finishListening()).resolves.toBeUndefined() + + expect(service.state).toBe('listening') + expect(mocks.refCount).toBe(1) + expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'stt' })]) + expect(mocks.play).toHaveBeenCalledWith('error') + }) + + it('음성이 감지되지 않으면(VAD 빈 결과) listening 으로 돌아가고 오류는 한 번만 알린다', async () => { + mocks.transcribe.mockResolvedValue({ text: ' ' }) + const service = getVoiceConversationService() + await service.startSession() + feedAudio(32_000) + + await service.finishListening() + + expect(service.state).toBe('listening') + expect(conversationErrors()).toHaveLength(1) + }) + + it('LLM 이 실패하면 listening 으로 돌아가고 llm 오류 하나만 알린다(stt 로 다시 알리지 않는다)', async () => { + mocks.transcribe.mockResolvedValue({ text: '안녕' }) + mocks.chatStream.mockImplementation(() => failing()) + const service = getVoiceConversationService() + await service.startSession() + feedAudio(32_000) + + await expect(service.finishListening()).resolves.toBeUndefined() + + expect(service.state).toBe('listening') + expect(conversationErrors()).toEqual([expect.objectContaining({ phase: 'llm' })]) + expect(mocks.refCount).toBe(1) + }) +}) + +describe('VoiceConversationService — 마이크 참조 카운트 짝', () => { + beforeEach(() => { + resetVoiceConversationServiceForTests() + vi.clearAllMocks() + mocks.audioHandlers.clear() + mocks.refCount = 0 + mocks.audioStart.mockImplementation(async () => { + mocks.refCount++ + }) + mocks.audioStop.mockImplementation(async () => { + mocks.refCount = Math.max(0, mocks.refCount - 1) + }) + mocks.chatStream.mockImplementation(() => answer('네.')) + }) + + it('startSession → 텍스트 두 번 → stopSession 뒤 캡처 참조가 0 이다(마이크가 닫힌다)', async () => { + const service = getVoiceConversationService() + await service.startSession() + await service.sendTextMessage('첫 번째') + await service.sendTextMessage('두 번째') + expect(service.state).toBe('listening') + expect(mocks.refCount).toBe(1) + + service.stopSession() + await Promise.resolve() + expect(mocks.refCount).toBe(0) + expect(mocks.audioStart.mock.calls.length).toBe(mocks.audioStop.mock.calls.length) + }) + + it('텍스트 턴 도중 취소해도 start/stop 짝이 맞는다', async () => { + let release: () => void = () => undefined + mocks.chatStream.mockImplementation(async function* (_m: unknown, options?: { signal?: AbortSignal }) { + await new Promise((resolve) => { + release = resolve + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + return '' + }) + const service = getVoiceConversationService() + await service.startSession() + const turn = service.sendTextMessage('질문') + await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalled()) + service.cancelResponse() + release() + await turn + + expect(service.state).toBe('listening') + expect(mocks.refCount).toBe(1) + service.stopSession() + await Promise.resolve() + expect(mocks.refCount).toBe(0) + }) +}) diff --git a/apps/desktop/tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts b/apps/desktop/tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts new file mode 100644 index 0000000..3d74b04 --- /dev/null +++ b/apps/desktop/tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts @@ -0,0 +1,438 @@ +// tests/main/sync/cloud-sync-restore-redteam-r2-1.test.ts +// CloudSyncService 세션 수명주기 회귀 테스트 (Supabase·DB·엔진은 가짜): +// - 오프라인·5xx 로 복원이 실패하면 토큰을 지우지 않고 저장된 계정 DB 에서 오프라인으로 동작하다 다시 시도한다 +// - 서버가 토큰을 확실히 거부(4xx AuthApiError)할 때만 토큰을 지우고 로컬 DB 로 되돌린다 +// - 엔진이 아직 없어도(로그인 직후 티어 조회 중·오프라인) 변경을 outbox 에 기록한다 +// - 로그아웃은 이 기기 세션만(scope 'local'), 외부 SIGNED_OUT 은 로그아웃 정리로 이어진다 +// + 순수 정책: 복원 실패 분류, 토큰 레코드, SIGNED_OUT 훅, 시작 DB 선택 + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { + EncryptedFileTokenStore, + bindAuthEvents, + decodeSessionRecord, + type AuthSessionLike, + type TokenCipher, + type TokenFileSystem, +} from '../../../src/main/services/sync/session-token-store' +import { + classifySessionRestoreFailure, + sessionRestoreRetryDelayMs, +} from '../../../src/main/services/sync/session-restore-policy' +import { openStartupDatabase } from '../../../src/main/db/startup-database' + +const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`) +const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token') + +const h = vi.hoisted(() => { + type AuthCallback = (event: string, session: unknown) => void + const state = { + currentUserId: '_local' as string | null, + authCallback: null as AuthCallback | null, + tierGate: null as Promise | null, + } + return { + state, + db: { + openForUser: (userId: string) => { + state.currentUserId = userId + return { created: false, dbPath: `/db/${userId}` } + }, + openLocal: () => { + state.currentUserId = '_local' + return { created: false, dbPath: '/db/_local' } + }, + }, + enqueueChange: (..._args: unknown[]) => undefined, + } +}) + +const spies = vi.hoisted(() => ({ + refreshSession: null as null | ((...args: unknown[]) => Promise), + signOut: null as null | ((...args: unknown[]) => Promise), +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => path.join(os.tmpdir(), `d3ro-cloudsync-r2-1-${process.pid}`), + getVersion: () => '1.0.0', + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (plain: string) => Buffer.from(plain, 'utf-8'), + decryptString: (data: Buffer) => data.toString('utf-8'), + }, +})) + +vi.mock('../../../src/main/services/LoggerService', () => ({ + getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})) + +vi.mock('../../../src/main/services/ConfigService', () => ({ + configGet: () => undefined, + configSet: vi.fn(), + onConfigChanged: () => () => undefined, +})) + +vi.mock('../../../src/main/db', () => ({ + LOCAL_USER_ID: '_local', + openForUser: vi.fn((userId: string) => h.db.openForUser(userId)), + openLocal: vi.fn(() => h.db.openLocal()), + closeCurrent: vi.fn(() => { + h.state.currentUserId = null + }), + getCurrentUserId: () => h.state.currentUserId, + importLocalModeData: vi.fn(() => null), +})) + +vi.mock('../../../src/main/services/sync/sync-outbox', () => ({ + enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)), + getSyncState: () => null, + setSyncState: vi.fn(), +})) + +vi.mock('../../../src/main/services/sync/SyncEngine', () => ({ + SyncEngine: class { + on(): void {} + dispose(): void {} + async whenIdle(): Promise { + return true + } + async runFullSync() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + async flush() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + async pull() { + return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] } + } + getStatus() { + return { pending: 0, parked: 0 } + } + }, +})) + +vi.mock('../../../src/main/services/sync/sync-adapters', () => ({ + SyncAbortedError: class SyncAbortedError extends Error {}, +})) +vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} })) +vi.mock('../../../src/main/services/sync/device-registration', () => ({ + checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })), + currentDeviceInfo: () => ({}), + unregisterDesktopDevice: vi.fn(async () => undefined), +})) +vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] })) +vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} })) +vi.mock('../../../src/main/services/sync/settings-sync', () => ({ + SETTINGS_ROW_ID: 'settings', + SYNCED_CONFIG_KEYS: [], + isApplyingRemoteSettings: () => false, +})) +vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] })) +vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) })) +vi.mock('../../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }), +})) +vi.mock('../../../src/main/services/VoiceModeService', () => ({ + getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }), +})) +vi.mock('../../../src/main/services/MeetingModeService', () => ({ + getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }), +})) +vi.mock('../../../src/main/services/CaptionService', () => ({ + getCaptionService: () => ({ stop: vi.fn(async () => null) }), +})) + +vi.mock('@supabase/supabase-js', () => { + const query = (): Record => { + const builder: Record = {} + for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder + builder.maybeSingle = async () => { + if (h.state.tierGate) await h.state.tierGate + return { data: null, error: null } + } + return builder + } + const channel = { + on() { + return channel + }, + subscribe() { + return channel + }, + unsubscribe: async () => undefined, + state: 'joined', + } + return { + createClient: () => ({ + auth: { + refreshSession: (...args: unknown[]) => spies.refreshSession!(...args), + signOut: (...args: unknown[]) => spies.signOut!(...args), + onAuthStateChange: (cb: (event: string, session: unknown) => void) => { + h.state.authCallback = cb + return { data: { subscription: { unsubscribe: () => undefined } } } + }, + getSession: async () => ({ data: { session: null } }), + stopAutoRefresh: async () => undefined, + }, + from: () => query(), + channel: () => channel, + realtime: { setAuth: async () => undefined }, + }), + } +}) + +import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService' +import * as db from '../../../src/main/db' +import * as outbox from '../../../src/main/services/sync/sync-outbox' + +function session(refreshToken = 'rt-new') { + return { access_token: 'at', refresh_token: refreshToken, user: { id: 'user-1', email: 'u@example.test' } } +} + +function writeStoredToken(record: { rt: string; uid?: string }): void { + fs.mkdirSync(USER_DATA, { recursive: true }) + fs.writeFileSync(TOKEN_FILE, record.uid ? JSON.stringify({ v: 1, rt: record.rt, uid: record.uid }) : record.rt) +} + +describe('CloudSyncService 세션 복원', () => { + beforeEach(() => { + resetCloudSyncServiceForTests() + vi.clearAllMocks() + fs.rmSync(USER_DATA, { recursive: true, force: true }) + h.state.currentUserId = 'user-1' // bootstrap 이 저장된 계정 DB 를 먼저 열었다 + h.state.authCallback = null + h.state.tierGate = null + spies.signOut = vi.fn(async () => ({ error: null })) + }) + + afterEach(() => { + resetCloudSyncServiceForTests() + vi.useRealTimers() + fs.rmSync(USER_DATA, { recursive: true, force: true }) + }) + + it('오프라인(AuthRetryableFetchError)이면 토큰을 지우지 않고 계정 DB 에서 오프라인으로 동작하며 변경을 기록한다', async () => { + writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) + spies.refreshSession = vi.fn(async () => ({ + data: { session: null }, + error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' }, + })) + const sync = getCloudSyncService() + const errors: string[] = [] + sync.on('sync-error', ({ error }) => errors.push(error)) + + await sync.init() + + expect(fs.existsSync(TOKEN_FILE)).toBe(true) + expect(sync.isOfflineAccount()).toBe(true) + expect(h.state.currentUserId).toBe('user-1') + expect(db.openLocal).not.toHaveBeenCalled() + expect(errors).toHaveLength(1) + expect(errors[0]).not.toMatch(/expired/i) + + sync.pushOne('history', 'h-offline') + expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-offline', 'upsert') + }) + + it('나중에 다시 시도해 복원되면 로그인 상태가 된다', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) + writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) + spies.refreshSession = vi.fn(async () => ({ + data: { session: null }, + error: { name: 'AuthApiError', status: 503, message: 'upstream unavailable' }, + })) + const sync = getCloudSyncService() + await sync.init() + expect(sync.isAuthenticated()).toBe(false) + + spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) + await vi.advanceTimersByTimeAsync(sessionRestoreRetryDelayMs(0)) + + expect(sync.isAuthenticated()).toBe(true) + expect(sync.isOfflineAccount()).toBe(false) + }) + + it('서버가 토큰을 거부(400 AuthApiError)하면 토큰을 지우고 로컬 DB 로 되돌린다', async () => { + writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) + spies.refreshSession = vi.fn(async () => ({ + data: { session: null }, + error: { name: 'AuthApiError', status: 400, code: 'refresh_token_not_found', message: 'Invalid Refresh Token' }, + })) + const sync = getCloudSyncService() + await sync.init() + + expect(fs.existsSync(TOKEN_FILE)).toBe(false) + expect(h.state.currentUserId).toBe('_local') + expect(sync.isOfflineAccount()).toBe(false) + sync.pushOne('history', 'h-local') + expect(outbox.enqueueChange).not.toHaveBeenCalled() + }) + + it('로그인 직후 티어 조회 중(엔진 생성 전)의 변경도 outbox 에 기록한다', async () => { + writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) + let releaseTier: () => void = () => undefined + h.state.tierGate = new Promise((resolve) => { + releaseTier = resolve + }) + spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) + const sync = getCloudSyncService() + const initDone = sync.init() + await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(true)) + + sync.pushOne('history', 'h-gap') + expect(outbox.enqueueChange).toHaveBeenCalledWith('history', 'h-gap', 'upsert') + + releaseTier() + await initDone + }) + + it('복원·로그인 때 토큰과 함께 계정 id 를 저장한다', async () => { + writeStoredToken({ rt: 'rt-legacy' }) // 구버전: 토큰만 + spies.refreshSession = vi.fn(async () => ({ data: { session: session('rt-rotated') }, error: null })) + await getCloudSyncService().init() + expect(decodeSessionRecord(fs.readFileSync(TOKEN_FILE, 'utf-8'))).toEqual({ + refreshToken: 'rt-rotated', + userId: 'user-1', + }) + expect(getCloudSyncService().peekStoredAccountUserId()).toBe('user-1') + }) +}) + +describe('CloudSyncService 로그아웃 범위', () => { + beforeEach(() => { + resetCloudSyncServiceForTests() + vi.clearAllMocks() + fs.rmSync(USER_DATA, { recursive: true, force: true }) + h.state.currentUserId = 'user-1' + h.state.tierGate = null + spies.signOut = vi.fn(async () => { + // 실제 auth-js 처럼 자기 signOut 도 SIGNED_OUT 을 알린다 — 다시 로그아웃 정리를 돌지 않아야 한다 + h.state.authCallback?.('SIGNED_OUT', null) + return { error: null } + }) + spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null })) + writeStoredToken({ rt: 'rt-1', uid: 'user-1' }) + }) + + afterEach(() => { + resetCloudSyncServiceForTests() + fs.rmSync(USER_DATA, { recursive: true, force: true }) + }) + + it("사용자 로그아웃은 이 기기 세션만 폐기한다(scope 'local')", async () => { + const sync = getCloudSyncService() + await sync.init() + await sync.signOut() + + expect(spies.signOut).toHaveBeenCalledTimes(1) + expect(spies.signOut).toHaveBeenCalledWith({ scope: 'local' }) + expect(sync.isAuthenticated()).toBe(false) + expect(h.state.currentUserId).toBe('_local') + }) + + it('다른 곳에서 세션이 끝나면(SIGNED_OUT) 익명 키로 계속 돌지 않고 로그아웃 정리를 한다', async () => { + const sync = getCloudSyncService() + await sync.init() + const authChanges: Array = [] + const errors: string[] = [] + sync.on('auth-changed', ({ user }) => authChanges.push(user)) + sync.on('sync-error', ({ error }) => errors.push(error)) + + h.state.authCallback?.('SIGNED_OUT', null) + + await vi.waitFor(() => expect(sync.isAuthenticated()).toBe(false)) + await vi.waitFor(() => expect(errors).toHaveLength(1)) + expect(authChanges).toContain(null) + expect(errors[0]).not.toMatch(/disconnected from your account on another device/) + expect(fs.existsSync(TOKEN_FILE)).toBe(false) + }) +}) + +describe('순수 정책', () => { + it('복원 실패 분류: 서버의 4xx 거부만 invalid', () => { + expect(classifySessionRestoreFailure({ name: 'AuthRetryableFetchError', status: 0 })).toBe('transient') + expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 400 })).toBe('invalid') + expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 401 })).toBe('invalid') + expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 500 })).toBe('transient') + expect(classifySessionRestoreFailure({ name: 'AuthApiError', status: 429 })).toBe('transient') + expect(classifySessionRestoreFailure(new TypeError('fetch failed'))).toBe('transient') + expect(classifySessionRestoreFailure(null)).toBe('transient') + expect(sessionRestoreRetryDelayMs(0)).toBe(15_000) + expect(sessionRestoreRetryDelayMs(99)).toBe(300_000) + }) + + it('토큰 레코드: 구버전 토큰 문자열과 새 레코드를 모두 읽는다', () => { + const files = new Map() + const memoryFs: TokenFileSystem = { + existsSync: (p) => files.has(p), + readFileSync: (p) => files.get(p) ?? Buffer.alloc(0), + writeFileSync: (p, data) => { + files.set(p, Buffer.from(data)) + }, + unlinkSync: (p) => { + files.delete(p) + }, + } + const cipher: TokenCipher = { + isEncryptionAvailable: () => true, + encryptString: (s) => Buffer.from(s), + decryptString: (b) => b.toString(), + } + const store = new EncryptedFileTokenStore('/t', cipher, memoryFs, { warn: vi.fn() }) + store.save('rt-only') + expect(store.loadRecord()).toEqual({ refreshToken: 'rt-only', userId: null }) + store.save('rt-2', 'user-9') + expect(store.loadRecord()).toEqual({ refreshToken: 'rt-2', userId: 'user-9' }) + expect(store.load()).toBe('rt-2') + }) + + it('bindAuthEvents: 로그인된 동안의 SIGNED_OUT 은 onSignedOut 으로, 로그아웃 뒤에는 무시', () => { + type S = AuthSessionLike + let emit: (event: string, s: S | null) => void = () => undefined + const onSignedOut = vi.fn() + let active: string | null = 'u1' + const store = { load: () => null, loadRecord: () => null, save: vi.fn(), clear: vi.fn() } + bindAuthEvents( + (cb) => { + emit = cb + return { unsubscribe: vi.fn() } + }, + store, + () => active, + vi.fn(), + onSignedOut, + ) + emit('TOKEN_REFRESHED', { refresh_token: 'rt', user: { id: 'u1' } }) + expect(store.save).toHaveBeenCalledWith('rt', 'u1') + emit('SIGNED_OUT', null) + expect(onSignedOut).toHaveBeenCalledTimes(1) + expect(store.clear).not.toHaveBeenCalled() + active = null + emit('SIGNED_OUT', null) + expect(onSignedOut).toHaveBeenCalledTimes(1) + }) + + it('시작 DB: 저장된 계정이 있으면 그 계정 DB, 열지 못하면 로컬, 없으면 로컬', () => { + const openForUser = vi.fn((id: string) => ({ created: false, dbPath: `/u/${id}` })) + const openLocal = vi.fn(() => ({ created: false, dbPath: '/u/_local' })) + const warn = vi.fn() + expect(openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser, openLocal, warn }).accountUserId).toBe('acc') + expect(openLocal).not.toHaveBeenCalled() + + const failing = vi.fn(() => { + throw new Error('corrupt') + }) + expect( + openStartupDatabase({ storedAccountUserId: () => 'acc', openForUser: failing, openLocal, warn }).accountUserId, + ).toBeNull() + expect(openLocal).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalled() + + expect(openStartupDatabase({ storedAccountUserId: () => null, openForUser, openLocal, warn }).dbPath).toBe('/u/_local') + }) +}) diff --git a/apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts b/apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts new file mode 100644 index 0000000..2a91e1a --- /dev/null +++ b/apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts @@ -0,0 +1,114 @@ +// tests/red/data-policy-redteam-r2-1.test.ts +// 로컬이 서버 쓰기 제약과 같은 규칙으로 입력을 거절한다(실제 서비스 + in-memory DB): +// - 사전: 대소문자만 다른 단어는 같은 단어(서버 lower(btrim(word)) 유일성) — 추가·수정·가져오기 +// - 명령어: 편집으로 이름·프롬프트를 비울 수 없다(생성과 같은 규칙) +// - 메모 태그: 80자를 넘으면 저장하지 않는다 +// - 검색: 기록은 다듬은 문장·제목·요약으로도 찾고, %·_ 는 글자 그대로 찾는다(사전 검색 포함) + +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { ErrorCode } from '@d3ro/core/errors' +import { useRedHarness, historyInput } from './harness' + +useRedHarness() + +import { getDictionaryService } from '../../src/main/services/DictionaryService' +import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService' +import { getMemoService } from '../../src/main/services/MemoService' +import { getHistoryService } from '../../src/main/services/HistoryService' + +describe('사전 — 대소문자 무시 식별', () => { + it("'API' 가 있으면 'api' 추가는 DictionaryDuplicate 다(발음이 동기화에서 조용히 사라지지 않게)", () => { + const dict = getDictionaryService() + dict.add({ word: 'API' }) + expect(() => dict.add({ word: ' api ', pronunciation: '에이피아이' })).toThrow( + expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }), + ) + }) + + it('다른 카테고리는 같은 단어를 허용한다', () => { + const dict = getDictionaryService() + dict.add({ word: 'API', category: 'user' }) + expect(() => dict.add({ word: 'api', category: 'technical' })).not.toThrow() + }) + + it('수정으로 다른 항목과 대소문자만 다른 단어가 되면 거절하고, 자기 자신의 대소문자 변경은 허용한다', () => { + const dict = getDictionaryService() + dict.add({ word: 'Kubernetes' }) + const other = dict.add({ word: 'Docker' }) + expect(() => dict.update({ id: other.id, word: 'KUBERNETES' })).toThrow( + expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }), + ) + expect(dict.update({ id: other.id, word: 'DOCKER' })?.word).toBe('DOCKER') + }) + + it('가져오기는 대소문자만 다른 단어를 건너뛴다', () => { + const dict = getDictionaryService() + dict.add({ word: 'GraphQL' }) + const file = path.join(os.tmpdir(), `d3ro-dict-r2-1-${Date.now()}.json`) + fs.writeFileSync(file, JSON.stringify([{ word: 'graphql' }, { word: 'gRPC' }, { word: 'GRPC' }])) + try { + const result = dict.importDictionary({ filePath: file, format: 'json' }) + expect(result).toEqual({ imported: 1, skipped: 2, errors: 0 }) + } finally { + fs.rmSync(file, { force: true }) + } + }) + + it('사전 검색의 %·_ 는 와일드카드가 아니다', () => { + const dict = getDictionaryService() + dict.add({ word: 'alpha' }) + dict.add({ word: 'snake_case' }) + expect(dict.search({ query: '_', page: 0, pageSize: 20 }).entries.map((e) => e.word)).toEqual(['snake_case']) + expect(dict.search({ query: '%', page: 0, pageSize: 20 }).total).toBe(0) + }) +}) + +describe('명령어 — 빈 값 검증', () => { + it('편집으로 프롬프트나 이름을 비울 수 없다', () => { + const svc = getCustomInstructionService() + svc.initialize() + const created = svc.create({ name: '요약해', description: '', prompt: '요약해 주세요' }) + expect(() => svc.update(created.id, { prompt: ' ' })).toThrow(/prompt is empty/) + expect(() => svc.update(created.id, { name: '' })).toThrow(/name is empty/) + expect(svc.getById(created.id)?.prompt).toBe('요약해 주세요') + }) +}) + +describe('메모 태그 — 서버 길이 제한', () => { + it('80자를 넘는 태그는 저장하지 않는다(80자는 허용)', () => { + const entry = getHistoryService().create(historyInput()) + const memo = getMemoService() + expect(() => memo.addTag(entry.id, 't'.repeat(81))).toThrow(/too long/) + expect(memo.getTagsForEntry(entry.id)).toEqual([]) + expect(memo.addTag(entry.id, 't'.repeat(80)).tag).toHaveLength(80) + }) +}) + +describe('기록 검색', () => { + it('카드에 보이는 다듬은 문장·제목·요약으로도 찾는다', () => { + const history = getHistoryService() + history.create(historyInput({ originalText: '한국어 원문', polishedText: 'Quarterly roadmap review' })) + history.create(historyInput({ originalText: '다른 원문', title: '출시 회의록' })) + history.create(historyInput({ originalText: '세 번째', summaryText: '결정: 예산 증액' })) + + const search = (query: string) => history.search({ query, page: 0, pageSize: 20 }).entries.length + expect(search('roadmap')).toBe(1) + expect(search('출시 회의')).toBe(1) + expect(search('예산 증액')).toBe(1) + }) + + it('%·_ 는 글자 그대로 찾는다', () => { + const history = getHistoryService() + history.create(historyInput({ originalText: '매출이 100% 늘었다' })) + history.create(historyInput({ originalText: '매출이 1005 건이다' })) + history.create(historyInput({ originalText: 'file_name 확인' })) + + const texts = (query: string) => + history.search({ query, page: 0, pageSize: 20 }).entries.map((e) => e.originalText) + expect(texts('100%')).toEqual(['매출이 100% 늘었다']) + expect(texts('_')).toEqual(['file_name 확인']) + }) +}) diff --git a/apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts b/apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts new file mode 100644 index 0000000..8c8ebd2 --- /dev/null +++ b/apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts @@ -0,0 +1,132 @@ +// tests/red/llm-privacy-routing-redteam-r2-1.test.ts +// LLM 백엔드 선택이 게이트웨이 한 곳을 따른다(실제 서비스 + in-memory DB, LLM 어댑터만 가짜): +// - llmBackend='local' 이면 자막 자동 요약·받아쓰기 자동 제목이 Premium(클라우드·쿼터)을 부르지 않는다 +// - llm:process 는 online + Supabase 로그인(레거시 authToken 없음)이면 Premium 을 탄다(.NET 경로 아님) + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { invokeIpc, useRedHarness, historyInput } from './harness' + +const llm = vi.hoisted(() => ({ + localGenerate: vi.fn(), + localProcess: vi.fn(), + premiumGenerate: vi.fn(), + premiumProcess: vi.fn(), + premiumAvailable: true, + onlineProcess: vi.fn(), +})) + +vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLocalLLMService: () => ({ + isAvailable: () => true, + generate: (...args: unknown[]) => llm.localGenerate(...args), + processText: (...args: unknown[]) => llm.localProcess(...args), + chatStream: async function* () { + yield 'x' + return 'x' + }, + cancelGeneration: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getStatus: () => ({}), + }), + } +}) + +vi.mock('../../src/main/services/PremiumLLMService', () => ({ + getPremiumLLMService: () => ({ + isAvailable: () => llm.premiumAvailable, + generate: (...args: unknown[]) => llm.premiumGenerate(...args), + processText: (...args: unknown[]) => llm.premiumProcess(...args), + chatStream: async function* () { + yield 'p' + return 'p' + }, + cancelGeneration: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getLastQuota: () => null, + }), + resetPremiumLLMServiceForTests: () => undefined, +})) + +vi.mock('../../src/main/services/OnlineLLMService', () => ({ + getOnlineLLMService: () => ({ + processText: (...args: unknown[]) => llm.onlineProcess(...args), + cancelGeneration: vi.fn(), + }), + resetOnlineLLMServiceForTests: () => undefined, +})) + +useRedHarness() + +import { configSet } from '../../src/main/services/ConfigService' +import { getHistoryService } from '../../src/main/services/HistoryService' +import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService' +import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers' +import { setLlmGatewayForTests } from '../../src/main/services/llm/LlmGateway' + +beforeEach(() => { + setLlmGatewayForTests(null) + llm.premiumAvailable = true + llm.localGenerate.mockReset().mockResolvedValue({ text: '## 요약\n로컬 요약', model: 'local' }) + llm.localProcess.mockReset().mockResolvedValue('local-result') + llm.premiumGenerate.mockReset().mockResolvedValue({ text: 'premium' }) + llm.premiumProcess.mockReset().mockResolvedValue('premium-result') + llm.onlineProcess.mockReset().mockResolvedValue('dotnet-result') +}) + +describe('llmBackend=local 은 텍스트를 클라우드로 보내지 않는다', () => { + it('자막 요약은 로컬 LLM 을 쓰고 Premium 을 부르지 않는다', async () => { + configSet('llmBackend', 'local') + const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' })) + await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled()) + llm.localGenerate.mockClear() + + const result = await getMeetingSummaryService().summarize(entry.id) + + expect(result.summary).toContain('로컬 요약') + expect(llm.localGenerate).toHaveBeenCalled() + expect(llm.premiumGenerate).not.toHaveBeenCalled() + }) + + it('받아쓰기 자동 제목도 Premium 을 부르지 않는다', async () => { + configSet('llmBackend', 'local') + const entry = getHistoryService().create(historyInput({ originalText: '다음 주 출시 일정을 정리해야 합니다' })) + await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled()) + expect(llm.premiumGenerate).not.toHaveBeenCalled() + await vi.waitFor(() => expect(getHistoryService().getById(entry.id)?.title).toBeTruthy()) + }) + + it('online 을 고른 사용자는 요약이 Premium 을 탄다(명시적 선택)', async () => { + configSet('llmBackend', 'online') + const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' })) + await getMeetingSummaryService().summarize(entry.id) + expect(llm.premiumGenerate).toHaveBeenCalled() + }) +}) + +describe('llm:process 는 실제 받아쓰기와 같은 경로를 탄다', () => { + it('online + Supabase 로그인(레거시 authToken 없음) → Premium, .NET 경로 아님', async () => { + configSet('llmBackend', 'online') + configSet('authToken', null) + registerLLMHandlers() + const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' }) + expect(res.success).toBe(true) + if (res.success) expect(res.data.processedText).toBe('premium-result') + expect(llm.onlineProcess).not.toHaveBeenCalled() + }) + + it('online 인데 Premium 을 쓸 수 없으면 받아쓰기처럼 로컬로 폴백한다', async () => { + configSet('llmBackend', 'online') + configSet('authToken', null) + llm.premiumAvailable = false + registerLLMHandlers() + const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' }) + expect(res.success).toBe(true) + if (res.success) expect(res.data.processedText).toBe('local-result') + }) +}) diff --git a/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts b/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts new file mode 100644 index 0000000..b57d657 --- /dev/null +++ b/apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts @@ -0,0 +1,237 @@ +// tests/red/meeting-recording-redteam-r2-1.test.ts +// MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM): +// - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회 +// - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다 +// - 시작 시 중단된 'recording'/'processing' 행을 닫는다 +// - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다 +// - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다 +// - 회의 채팅 지우기·취소는 그 회의만 + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { EventEmitter } from 'events' +import { eq } from 'drizzle-orm' +import type { CaptionSegment } from '@d3ro/core/types' +import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat' +import { useRedHarness } from './harness' + +const caption = vi.hoisted(() => ({ + bus: null as EventEmitter | null, + state: 'inactive' as string, + stopCalls: 0, + /** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */ + gate: null as Promise | null, +})) + +vi.mock('../../src/main/services/CaptionService', async () => { + const { EventEmitter: Emitter } = await import('events') + const bus = new Emitter() + caption.bus = bus + const service = { + getState: () => caption.state, + on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn), + off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn), + start: async () => { + caption.state = 'active' + }, + stop: async () => { + caption.stopCalls++ + if (caption.state !== 'active') return null + caption.state = 'stopping' + if (caption.gate) await caption.gate + const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), isFinal: true } + bus.emit('segment', segment) + caption.state = 'inactive' + return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 } + }, + } + return { getCaptionService: () => service, resetCaptionServiceForTests: () => undefined } +}) + +vi.mock('../../src/main/services/AudioCaptureService', () => ({ + getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }), + calculateRMS: () => 0, +})) + +const llm = vi.hoisted(() => ({ + generateInputs: [] as string[], + chatCalls: [] as Array>, +})) + +vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getLocalLLMService: () => ({ + isAvailable: () => true, + async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator { + llm.chatCalls.push(messages) + yield '답변' + return '답변' + }, + generate: async (text: string) => { + llm.generateInputs.push(text) + return { text: `요약(${text.length})`, model: 'fx-local' } + }, + cancelGeneration: vi.fn(), + }), + } +}) + +useRedHarness() + +import { getMeetingModeService } from '../../src/main/services/MeetingModeService' +import { configSet } from '../../src/main/services/ConfigService' +import { getDatabase } from '../../src/main/db' +import { meetingSessions } from '../../src/main/db/schema' + +function row(id: string) { + return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get() +} + +function insertSession(id: string, fields: Partial = {}): void { + const now = Date.now() + getDatabase() + .insert(meetingSessions) + .values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields }) + .run() +} + +function longTranscript(lines: number): string { + return Array.from({ length: lines }, (_, i) => { + const mm = String(Math.floor(i / 60)).padStart(2, '0') + const ss = String(i % 60).padStart(2, '0') + return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}` + }).join('\n') +} + +beforeEach(() => { + caption.state = 'inactive' + caption.stopCalls = 0 + caption.gate = null + llm.generateInputs = [] + llm.chatCalls = [] + configSet('llmBackend', 'local') +}) + +describe('회의 정지 — 동시에 두 번', () => { + it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => { + const meeting = getMeetingModeService() + const { sessionId } = await meeting.startRecording() + caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) + await new Promise((resolve) => setTimeout(resolve, 5)) + + let release: () => void = () => undefined + caption.gate = new Promise((resolve) => { + release = resolve + }) + const completed = vi.fn() + meeting.on('session-completed', completed) + + const first = meeting.stopRecording() + const second = meeting.stopRecording() + // 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다 + expect(meeting.getState()).toBe('processing') + release() + await Promise.all([first, second]) + + expect(caption.stopCalls).toBe(1) + expect(completed).toHaveBeenCalledTimes(1) + const saved = row(sessionId) + expect(saved?.status).toBe('completed') + expect(saved?.durationMs ?? 0).toBeGreaterThan(0) + expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2) + expect(saved?.rawTranscript).toContain('마지막 문장') + expect(meeting.getState()).toBe('idle') + await expect(meeting.stopRecording()).rejects.toBeTruthy() + }) +}) + +describe('앱 종료·중단 복구', () => { + it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => { + const meeting = getMeetingModeService() + const { sessionId } = await meeting.startRecording() + caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) + + await meeting.shutdown() + + const saved = row(sessionId) + expect(saved?.status).toBe('completed') + expect(saved?.rawTranscript).toContain('종료 전 문장') + expect(saved?.endedAt).not.toBeNull() + expect(meeting.getState()).toBe('idle') + }) + + it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => { + insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' }) + insertSession('stuck-empty', { status: 'processing' }) + insertSession('done', { status: 'completed', rawTranscript: 'x' }) + + expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2) + + expect(row('stuck-with-text')?.status).toBe('completed') + expect(row('stuck-with-text')?.endedAt).not.toBeNull() + expect(row('stuck-empty')?.status).toBe('error') + expect(row('done')?.status).toBe('completed') + }) +}) + +describe('녹음 화면 복원', () => { + it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => { + const meeting = getMeetingModeService() + const { sessionId } = await meeting.startRecording() + caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment) + await meeting.addMemo('중요') + + const live = meeting.getLiveSession() + expect(live?.sessionId).toBe(sessionId) + expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언']) + expect(live?.memos.map((m) => m.content)).toEqual(['중요']) + + await meeting.stopRecording() + expect(meeting.getLiveSession()).toBeNull() + }) +}) + +describe('긴 회의의 LLM 입력', () => { + it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => { + const transcript = longTranscript(400) + expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars) + insertSession('long-doc', { rawTranscript: transcript }) + + const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' }) + + expect(doc.content).toBeTruthy() + expect(llm.generateInputs.length).toBeGreaterThan(2) + for (const input of llm.generateInputs) { + expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars) + } + }) + + it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => { + const lines = longTranscript(300).split('\n') + lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다' + insertSession('long-chat', { rawTranscript: lines.join('\n') }) + + await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?') + + const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? '' + expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars) + expect(system).toContain('김민수') + expect(system).toContain('발췌') + }) +}) + +describe('회의 채팅 범위', () => { + it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => { + insertSession('chat-a', { rawTranscript: '[00:01] A' }) + insertSession('chat-b', { rawTranscript: '[00:01] B' }) + const meeting = getMeetingModeService() + await meeting.chatWithMeeting('chat-a', 'A?') + await meeting.chatWithMeeting('chat-b', 'B?') + + meeting.clearMeetingChatHistory('chat-a') + + expect(meeting.getMeetingChatHistory('chat-a')).toEqual([]) + expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2) + }) +}) diff --git a/apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts b/apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts new file mode 100644 index 0000000..9475dd4 --- /dev/null +++ b/apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts @@ -0,0 +1,64 @@ +// tests/unit/renderer-models-redteam-r2-1.test.ts +// 렌더러 순수 모델: +// - 녹음 화면 복원: main 사본과 먼저 도착한 이벤트를 id 로 합친다 +// - 회의 채팅 패널: main 이 기억하는 대화를 표시 메시지로 바꾼다(키 충돌 없음) +// - 명령어 폼: 저장 거부 사유를 검증 메시지로 바꾼다(빈 프롬프트·길이 초과) + +import { describe, expect, it } from 'vitest' +import { ErrorCode } from '@d3ro/core/errors' +import { mergeRestoredItems } from '../../src/renderer/pages/meeting/live-restore' +import { toPanelMessages, withRestoredHistory } from '../../src/renderer/components/meeting/meeting-chat-history' +import { commandSaveError } from '../../src/renderer/pages/commands/command-form' + +describe('mergeRestoredItems', () => { + it('main 사본을 기준으로, 조회 중 먼저 온 항목은 중복 없이 합치고 시간 순으로 둔다', () => { + const restored = [ + { id: 'a', t: 1 }, + { id: 'b', t: 2 }, + ] + const arrived = [ + { id: 'b', t: 2 }, + { id: 'c', t: 3 }, + ] + expect(mergeRestoredItems(restored, arrived, (x) => x.t).map((x) => x.id)).toEqual(['a', 'b', 'c']) + }) +}) + +describe('meeting chat history', () => { + it('보관된 턴을 순서 번호 timestamp 로 바꿔 목록 키가 겹치지 않는다', () => { + const messages = toPanelMessages([ + { role: 'user', content: 'q1' }, + { role: 'assistant', content: 'a1' }, + { role: 'user', content: 'q2' }, + ]) + const keys = messages.map((m) => `${m.role}-${m.timestamp}`) + expect(new Set(keys).size).toBe(3) + expect(messages.map((m) => m.content)).toEqual(['q1', 'a1', 'q2']) + }) + + it('조회가 끝나기 전에 보낸 새 메시지는 지우지 않고 뒤에 둔다', () => { + const merged = withRestoredHistory( + [{ role: 'user', content: '새 질문', timestamp: 1_700_000_000_000 }], + [{ role: 'user', content: '이전 질문' }, { role: 'assistant', content: '이전 답' }], + ) + expect(merged.map((m) => m.content)).toEqual(['이전 질문', '이전 답', '새 질문']) + }) +}) + +describe('commandSaveError', () => { + it('빈 프롬프트·길이 초과는 저장 전에 검증 메시지다', () => { + expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: ' ' } })).toEqual({ + key: 'mobile.commands.syncError.validation', + }) + expect(commandSaveError({ kind: 'draft', draft: { name: 'n'.repeat(81), description: '', prompt: 'p' } })).not.toBeNull() + expect(commandSaveError({ kind: 'draft', draft: { name: 'n', description: '', prompt: 'p' } })).toBeNull() + }) + + it('main 의 검증 거부는 검증 메시지, 그 밖의 실패는 호출자가 원문을 보여 준다(null)', () => { + expect( + commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'Failed: Instruction prompt is too long (max 4000 characters)' } }), + ).not.toBeNull() + expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigInvalidValue, message: 'x' } })).not.toBeNull() + expect(commandSaveError({ kind: 'ipc', error: { code: ErrorCode.ConfigWriteFailed, message: 'disk full' } })).toBeNull() + }) +}) diff --git a/apps/mobile-rn/__tests__/logout-scope-redteam-r2-1.test.ts b/apps/mobile-rn/__tests__/logout-scope-redteam-r2-1.test.ts new file mode 100644 index 0000000..7c8cf6a --- /dev/null +++ b/apps/mobile-rn/__tests__/logout-scope-redteam-r2-1.test.ts @@ -0,0 +1,24 @@ +// 로그아웃은 이 기기의 세션만 폐기한다(scope 'local'). +// 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 데스크톱까지 로그아웃시켰다. +const mockSignOut = jest.fn() + +jest.mock('../src/lib/supabase', () => ({ + supabase: { + auth: { + signOut: (...args: unknown[]) => mockSignOut(...args), + }, + }, +})) + +import { signOutWithLocalFallback } from '../src/lib/logout' + +describe('per-device logout scope', () => { + beforeEach(() => jest.clearAllMocks()) + + test('revokes only this device session, never the whole account', async () => { + mockSignOut.mockResolvedValue({ error: null }) + await signOutWithLocalFallback(jest.fn(async () => undefined)) + expect(mockSignOut).toHaveBeenCalledTimes(1) + expect(mockSignOut).toHaveBeenCalledWith({ scope: 'local' }) + }) +}) diff --git a/apps/mobile-rn/src/lib/logout.ts b/apps/mobile-rn/src/lib/logout.ts index e9a1cc4..0a0f49c 100644 --- a/apps/mobile-rn/src/lib/logout.ts +++ b/apps/mobile-rn/src/lib/logout.ts @@ -8,13 +8,17 @@ export interface LocalLogoutResult { * Attempts server-side refresh-token revocation first, then always performs * the caller's local secure purge. A network outage must not trap a user in a * signed-in device or leave a reusable refresh token in local storage. + * + * Only this device's session is revoked (scope 'local'). The auth client's + * default scope is 'global', which revoked every refresh token of the account + * and silently signed the user out of their desktops as well. */ export async function signOutWithLocalFallback( purgeLocalSession: () => Promise, ): Promise { let remoteRevocationConfirmed = false try { - const { error } = await supabase.auth.signOut() + const { error } = await supabase.auth.signOut({ scope: 'local' }) remoteRevocationConfirmed = error === null } catch { remoteRevocationConfirmed = false diff --git a/packages/core/__tests__/write-policies-redteam-r2-1.test.ts b/packages/core/__tests__/write-policies-redteam-r2-1.test.ts new file mode 100644 index 0000000..5670ebb --- /dev/null +++ b/packages/core/__tests__/write-policies-redteam-r2-1.test.ts @@ -0,0 +1,86 @@ +// 서버 쓰기 제약·LLM 입력 한도 정책(순수 함수) — 경계값은 서버 migration·llm-contract 와 같다. +import { describe, expect, it } from 'vitest' +import { INSTRUCTION_LIMITS, instructionLimitViolation, validateInstructionDraft } from '../src/instruction-policy' +import { MEMO_TAG_MAX_LENGTH, memoTagKey, normalizeMemoTag, validateMemoTag } from '../src/memo-tag-policy' +import { dictionaryIdentityKey, findDictionaryIdentityConflict } from '../src/dictionary-policy' +import { LLM_PROXY_CHAT_LIMITS, singleTurnLimitViolation } from '../src/llm-chat' +import { + joinCondensedSections, + planTranscriptCondense, + selectTranscriptExcerpts, +} from '../src/meeting-llm-input' + +describe('instruction-policy', () => { + it('빈 이름·빈 프롬프트는 거절한다(서버 btrim 1자 이상)', () => { + expect(validateInstructionDraft({ name: ' ', prompt: 'p' })).toEqual({ field: 'name', reason: 'empty' }) + expect(validateInstructionDraft({ name: 'n', prompt: '\n' })).toEqual({ field: 'prompt', reason: 'empty' }) + expect(validateInstructionDraft({ description: '' })).toBeNull() + }) + + it('길이 경계값 0/1/max/max+1 (코드 포인트)', () => { + expect(validateInstructionDraft({ name: 'n', prompt: 'p' })).toBeNull() + expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name) })).toBeNull() + expect(validateInstructionDraft({ name: 'n'.repeat(INSTRUCTION_LIMITS.name + 1) })).toMatchObject({ field: 'name', reason: 'too-long' }) + expect(instructionLimitViolation({ prompt: 'p'.repeat(INSTRUCTION_LIMITS.prompt + 1) })).toBe('prompt') + expect(instructionLimitViolation({ description: '😀'.repeat(INSTRUCTION_LIMITS.description) })).toBeNull() + }) +}) + +describe('memo-tag-policy', () => { + it('정규화·자연키는 서버와 같다', () => { + expect(normalizeMemoTag(' Work Notes ')).toBe('work notes') + expect(memoTagKey('h1', ' A B')).toBe('h1|a b') + }) + + it('1~80자 경계', () => { + expect(validateMemoTag(' ')).toBe('empty') + expect(validateMemoTag('t')).toBeNull() + expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH))).toBeNull() + expect(validateMemoTag('t'.repeat(MEMO_TAG_MAX_LENGTH + 1))).toBe('too-long') + }) +}) + +describe('dictionary identity', () => { + it('대소문자·앞뒤 공백만 다른 단어는 같은 단어다(자기 자신 제외)', () => { + expect(dictionaryIdentityKey(' API ')).toBe('api') + const rows = [{ id: '1', word: 'API' }, { id: '2', word: 'Ärger' }] + expect(findDictionaryIdentityConflict('api', rows)?.id).toBe('1') + expect(findDictionaryIdentityConflict('ärger', rows)?.id).toBe('2') + expect(findDictionaryIdentityConflict('API', rows, '1')).toBeNull() + }) +}) + +describe('singleTurnLimitViolation', () => { + it('서버처럼 앞뒤 공백을 빼고 세며, 경계는 통과', () => { + const max = LLM_PROXY_CHAT_LIMITS.maxMessageChars + expect(singleTurnLimitViolation({ message: ` ${'x'.repeat(max)} ` })).toBeNull() + expect(singleTurnLimitViolation({ message: 'x'.repeat(max + 1) })).toBe('message') + expect(singleTurnLimitViolation({ message: 'q', system: 's'.repeat(LLM_PROXY_CHAT_LIMITS.maxSystemChars + 1) })).toBe('system') + }) +}) + +describe('meeting-llm-input', () => { + const lines = Array.from({ length: 200 }, (_, i) => `[00:${String(i % 60).padStart(2, '0')}] 발언 ${i} 내용입니다`) + const transcript = lines.join('\n') + + it('한도 안이면 나누지 않고, 넘으면 줄 경계 조각으로 나눈다', () => { + expect(planTranscriptCondense('짧은 전사', 100)).toEqual([]) + const chunks = planTranscriptCondense(transcript, 1_000, 1_000) + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('\n')).toBe(transcript) + for (const chunk of chunks) expect(chunk.length).toBeLessThanOrEqual(1_000) + expect(joinCondensedSections(['a', 'b'])).toBe('### 1/2\na\n\n### 2/2\nb') + }) + + it('채팅 발췌: 전체가 들어가면 그대로, 아니면 질문과 관련된 구간을 시간 순으로 한도 안에', () => { + expect(selectTranscriptExcerpts('짧다', '질문', 100)).toMatchObject({ text: '짧다', complete: true }) + + const withAnswer = [...lines] + withAnswer[190] = '[03:10] 출시 담당자는 김민수로 결정했습니다' + const excerpt = selectTranscriptExcerpts(withAnswer.join('\n'), '출시 담당자는 누구로 결정했나요?', 800, 200) + expect(excerpt.complete).toBe(false) + expect(excerpt.text.length).toBeLessThanOrEqual(800) + expect(excerpt.text).toContain('김민수') + expect(excerpt.includedChunks).toBeLessThan(excerpt.totalChunks) + }) +}) diff --git a/packages/core/src/dictionary-policy.ts b/packages/core/src/dictionary-policy.ts index 6cd1a93..1f921a8 100644 --- a/packages/core/src/dictionary-policy.ts +++ b/packages/core/src/dictionary-policy.ts @@ -13,6 +13,26 @@ export function normalizeDictionaryWord(raw: string): string | null { return word.length > 0 ? word : null } +/** + * 단어의 식별 키 — 같은 카테고리에서 이 키가 같으면 같은 단어다. + * 서버 유일 인덱스 `(user_id, lower(btrim(word)), category)` 와 같은 규칙(대소문자 무시). + * 로컬이 'API' 와 'api' 를 따로 받아들이면, 동기화 때 서버 충돌(23505)로 로컬 행이 서버 행으로 + * 대체되며 사용자가 넣은 표기와 발음이 조용히 사라진다. + */ +export function dictionaryIdentityKey(word: string): string { + return word.trim().toLowerCase() +} + +/** 같은 카테고리의 기존 단어 중 식별 키가 같은 첫 항목 (excludeId 는 자기 자신 제외용) */ +export function findDictionaryIdentityConflict( + word: string, + candidates: readonly T[], + excludeId?: string +): T | null { + const key = dictionaryIdentityKey(word) + return candidates.find((c) => c.id !== excludeId && dictionaryIdentityKey(c.word) === key) ?? null +} + /** * 발음 입력을 정규화한다. * - undefined → undefined: "변경 없음" diff --git a/packages/core/src/instruction-policy.ts b/packages/core/src/instruction-policy.ts new file mode 100644 index 0000000..e905446 --- /dev/null +++ b/packages/core/src/instruction-policy.ts @@ -0,0 +1,49 @@ +// packages/core/src/instruction-policy.ts +// 사용자 LLM 명령어의 서버 쓰기 제약 — 서비스·동기화 어댑터·편집 폼이 같은 정본을 쓴다. +// 값은 서버 custom_instructions CHECK 와 같다(마이그레이션 20260821000005): +// char_length(btrim(name)) BETWEEN 1 AND 80, description ≤ 240, char_length(btrim(prompt)) BETWEEN 1 AND 4000 + +export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const + +export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS + +/** Postgres char_length 와 같은 코드 포인트 수 */ +export 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 +} + +/** 저장할 수 없는 이유 */ +export type InstructionDraftProblem = + | { field: 'name' | 'prompt'; reason: 'empty' } + | { field: InstructionLimitField; reason: 'too-long'; max: number } + +/** + * 명령어 초안(생성 또는 부분 수정)을 서버 제약으로 검사한다 (순수 함수). + * 수정(부분 패치)에서는 준 필드만 검사한다 — 빈 이름·빈 프롬프트로 바꾸는 것도 막는다 + * (예전엔 생성만 빈 값을 막아, 편집으로 비운 프롬프트가 서버 CHECK 에 계속 거부돼 동기화가 멈췄다). + */ +export function validateInstructionDraft(input: { + name?: string + description?: string + prompt?: string +}): InstructionDraftProblem | null { + if (input.name !== undefined && input.name.trim().length === 0) return { field: 'name', reason: 'empty' } + if (input.prompt !== undefined && input.prompt.trim().length === 0) return { field: 'prompt', reason: 'empty' } + const field = instructionLimitViolation(input) + if (field !== null) return { field, reason: 'too-long', max: INSTRUCTION_LIMITS[field] } + return null +} diff --git a/packages/core/src/llm-chat.ts b/packages/core/src/llm-chat.ts index 23c2aad..ff584ad 100644 --- a/packages/core/src/llm-chat.ts +++ b/packages/core/src/llm-chat.ts @@ -142,3 +142,21 @@ export function fitChatRequest(request: ChatRequest, limits: Readonly = LLM_PROXY_CHAT_LIMITS, +): SingleTurnLimitViolation | null { + const system = input.system?.trim() + if (system && system.length > limits.maxSystemChars) return 'system' + if (input.message.trim().length > limits.maxMessageChars) return 'message' + return null +} diff --git a/packages/core/src/meeting-live.ts b/packages/core/src/meeting-live.ts new file mode 100644 index 0000000..9a28740 --- /dev/null +++ b/packages/core/src/meeting-live.ts @@ -0,0 +1,26 @@ +// packages/core/src/meeting-live.ts +// 진행 중인 회의 녹음·회의 채팅을 렌더러가 다시 불러올 때 쓰는 계약 타입. +// (IPC: 'meetingMode:getLiveSession', 'meetingChat:getHistory') + +import type { CaptionSegment, MeetingMemo, MeetingModeState } from './types' + +/** 진행 중(녹음·처리 중)인 회의의 실시간 전사와 메모 */ +export interface MeetingLiveSession { + sessionId: string + state: MeetingModeState + startedAt: number + elapsedMs: number + segments: CaptionSegment[] + memos: MeetingMemo[] +} + +/** 회의 채팅 한 턴 (main 이 회의마다 보관하는 대화) */ +export interface MeetingChatHistoryTurn { + role: 'user' | 'assistant' + content: string +} + +/** 회의 채팅 IPC 채널 — 회의 하나의 대화를 조회한다 */ +export const MEETING_CHAT_GET_HISTORY_CHANNEL = 'meetingChat:getHistory' +/** 진행 중인 회의 녹음의 세그먼트·메모를 조회한다 */ +export const MEETING_MODE_GET_LIVE_SESSION_CHANNEL = 'meetingMode:getLiveSession' diff --git a/packages/core/src/meeting-llm-input.ts b/packages/core/src/meeting-llm-input.ts new file mode 100644 index 0000000..40d4df5 --- /dev/null +++ b/packages/core/src/meeting-llm-input.ts @@ -0,0 +1,99 @@ +// packages/core/src/meeting-llm-input.ts +// 긴 회의 전사를 LLM 입력 한도 안에 넣는 순수 정책. +// +// llm-proxy 는 메시지·system 하나당 8,000자를 넘으면 400 으로 거부하고, 채팅의 system 은 앞부분만 +// 남기고 잘린다. 30분 넘는 회의 전사는 쉽게 이 한도를 넘어, 문서 생성·요약이 실패하고 채팅은 회의 +// 앞부분만 보고 "확인되지 않습니다"라고 답했다. 여기서는 +// - 문서·요약: 구간별로 줄인 뒤(map) 합쳐 한 번에 보낸다(reduce) — 조각 계획만 정한다. +// - 채팅: 질문과 관련 있는 구간만 골라 한도 안에 넣고, 잘렸다는 사실을 함께 돌려준다. + +import { chunkTranscriptByLines } from './meeting-transcript' + +/** 구간 요약(map) 한 번에 보낼 전사 조각 크기 — 프록시 메시지 한도(8,000자) 안쪽 */ +export const MEETING_MAP_CHUNK_CHARS = 6_000 + +/** 채팅 발췌를 고를 때의 조각 크기 */ +export const MEETING_EXCERPT_CHUNK_CHARS = 1_200 + +const EXCERPT_SEPARATOR = '\n…\n' + +/** + * 전사가 budgetChars 를 넘으면 구간 요약용 조각으로 나눈다. 넘지 않으면 빈 배열(나눌 필요 없음). + * 한 줄이 조각 크기보다 길면 그 줄 하나가 한 조각이 된다(줄 중간은 자르지 않는다). + */ +export function planTranscriptCondense( + transcript: string, + budgetChars: number, + chunkChars: number = MEETING_MAP_CHUNK_CHARS, +): string[] { + if (transcript.trim().length <= budgetChars) return [] + return chunkTranscriptByLines(transcript, Math.max(1, Math.min(chunkChars, budgetChars))) +} + +/** 구간 요약들을 순서 표시와 함께 이어 붙인다(reduce 입력). */ +export function joinCondensedSections(sections: readonly string[]): string { + const total = sections.length + return sections.map((section, i) => `### ${i + 1}/${total}\n${section.trim()}`).join('\n\n') +} + +/** 문자·숫자만 남긴 소문자 문자 bigram — 조사가 붙은 한국어("결정은"/"결정을")도 겹치게 한다. */ +function bigrams(text: string): Set { + const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ') + const grams = new Set() + for (const word of normalized.split(' ')) { + const chars = [...word] + if (chars.length === 1) grams.add(chars[0]) + for (let i = 0; i + 1 < chars.length; i++) grams.add(chars[i] + chars[i + 1]) + } + return grams +} + +export interface TranscriptExcerpt { + text: string + /** 전사를 통째로 넣었는가 (false 면 일부 구간만 넣었다) */ + complete: boolean + includedChunks: number + totalChunks: number +} + +/** + * 채팅 질문에 쓸 전사를 budgetChars 안으로 고른다. + * - 전사 전체가 들어가면 그대로 쓴다. + * - 아니면 질문과 겹치는 bigram 이 많은 구간부터 넣고(동점이면 앞 구간), 시간 순으로 되돌려 이어 붙인다. + */ +export function selectTranscriptExcerpts( + transcript: string, + question: string, + budgetChars: number, + chunkChars: number = MEETING_EXCERPT_CHUNK_CHARS, +): TranscriptExcerpt { + const text = transcript.trim() + if (text.length <= budgetChars) { + return { text, complete: true, includedChunks: text ? 1 : 0, totalChunks: text ? 1 : 0 } + } + const chunks = chunkTranscriptByLines(text, Math.max(1, Math.min(chunkChars, budgetChars))) + const query = bigrams(question) + const scored = chunks.map((chunk, index) => { + const grams = bigrams(chunk) + let score = 0 + for (const gram of query) if (grams.has(gram)) score++ + return { index, chunk, score } + }) + scored.sort((a, b) => b.score - a.score || a.index - b.index) + + const chosen: Array<{ index: number; chunk: string }> = [] + let used = 0 + for (const candidate of scored) { + const cost = candidate.chunk.length + (chosen.length > 0 ? EXCERPT_SEPARATOR.length : 0) + if (used + cost > budgetChars) continue + chosen.push(candidate) + used += cost + } + chosen.sort((a, b) => a.index - b.index) + return { + text: chosen.map((c) => c.chunk).join(EXCERPT_SEPARATOR), + complete: chosen.length === chunks.length, + includedChunks: chosen.length, + totalChunks: chunks.length, + } +} diff --git a/packages/core/src/memo-tag-policy.ts b/packages/core/src/memo-tag-policy.ts new file mode 100644 index 0000000..e473b3f --- /dev/null +++ b/packages/core/src/memo-tag-policy.ts @@ -0,0 +1,28 @@ +// packages/core/src/memo-tag-policy.ts +// 메모 태그 정규화·식별 규칙 — 도메인 서비스(MemoService)와 동기화 어댑터가 함께 쓰는 정본. +// (예전엔 동기화 모듈에 있어 도메인 서비스가 sync 인프라에 의존했다) + +/** 서버 memo_tags CHECK 와 같은 태그 최대 길이(코드 포인트) — 마이그레이션 20260821000024 */ +export const MEMO_TAG_MAX_LENGTH = 80 + +const MEMO_TAG_KEY_SEPARATOR = '|' + +/** 서버 정규화와 같다: trim → 연속 공백 1칸 → 소문자. */ +export function normalizeMemoTag(tag: string): string { + return tag.trim().replace(/\s+/g, ' ').toLowerCase() +} + +/** 기록 하나에서 태그를 식별하는 자연키 `${historyId}|${정규화 태그}` */ +export function memoTagKey(historyId: string, tag: string): string { + return `${historyId}${MEMO_TAG_KEY_SEPARATOR}${normalizeMemoTag(tag)}` +} + +export type MemoTagProblem = 'empty' | 'too-long' + +/** 정규화한 태그를 서버 제약으로 검사한다. 문제 없으면 null. */ +export function validateMemoTag(tag: string): MemoTagProblem | null { + const normalized = normalizeMemoTag(tag) + if (normalized.length === 0) return 'empty' + if ([...normalized].length > MEMO_TAG_MAX_LENGTH) return 'too-long' + return null +}