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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getLocalLLMService } from '../services/LocalLLMService' import { getLocalLLMService } from '../services/LocalLLMService'
import { getPremiumLLMService } from '../services/PremiumLLMService' import { getPremiumLLMService } from '../services/PremiumLLMService'
import { getOnlineLLMService } from '../services/OnlineLLMService' import { getOnlineLLMService } from '../services/OnlineLLMService'
import { getLlmGateway } from '../services/llm/LlmGateway'
import { configGet, configSet } from '../services/ConfigService' import { configGet, configSet } from '../services/ConfigService'
import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts' import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts'
import { normalizeLoopbackUrl } from '../utils/loopback' import { normalizeLoopbackUrl } from '../utils/loopback'
@ -83,9 +84,6 @@ export function registerLLMHandlers(): void {
ipcMain.handle(IPC_CHANNELS.LLM.PROCESS, async (_event, params: LLMProcessParams) => { ipcMain.handle(IPC_CHANNELS.LLM.PROCESS, async (_event, params: LLMProcessParams) => {
try { try {
const backend = configGet('llmBackend')
const service =
backend === 'online' ? getOnlineLLMService() : getLocalLLMService()
const start = performance.now() const start = performance.now()
// 지시문 프롬프트를 시스템 프롬프트 자리로 보내고 플레이스홀더를 치환하는 판단은 // 지시문 프롬프트를 시스템 프롬프트 자리로 보내고 플레이스홀더를 치환하는 판단은
@ -100,7 +98,20 @@ export function registerLLMHandlers(): void {
) )
: { text: params.text, systemPrompt: params.customPrompt } : { text: params.text, systemPrompt: params.customPrompt }
const processedText = await service.processText( // 실제 받아쓰기(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, invocation.text,
params.action, params.action,
params.targetLanguage, params.targetLanguage,
@ -121,7 +132,8 @@ export function registerLLMHandlers(): void {
}) })
ipcMain.handle(IPC_CHANNELS.LLM.CANCEL_PROCESS, async () => { ipcMain.handle(IPC_CHANNELS.LLM.CANCEL_PROCESS, async () => {
getLocalLLMService().cancelGeneration() // 전역 취소 IPC — 게이트웨이(로컬 + 로드된 Premium)와, 아직 게이트웨이가 로드하지 않은 Premium·레거시 경로까지
getLlmGateway().cancel()
getPremiumLLMService().cancelGeneration() getPremiumLLMService().cancelGeneration()
getOnlineLLMService().cancelGeneration() getOnlineLLMService().cancelGeneration()
return ipcSuccess(undefined) return ipcSuccess(undefined)

View file

@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors' import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
import { getMeetingModeService } from '../services/MeetingModeService' import { getMeetingModeService } from '../services/MeetingModeService'
import { getLogger } from '../services/LoggerService' 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 { LiveSegmentEditResult } from '../services/meeting/live-segment-edits'
import type { import type {
CaptionSegment, CaptionSegment,
@ -32,6 +36,28 @@ interface MeetingEditSegmentParams {
let liveSegmentEditor: LiveSegmentEditor | null = null let liveSegmentEditor: LiveSegmentEditor | null = null
/**
* 녹음 중 받아들인 세그먼트 수정(표시용 사본). 편집기는 세션 완료 때 전사에 반영하고, 이 사본은
* 녹음 화면을 다시 열었을 때 고친 문장을 그대로 보여 주는 데 쓴다.
*/
const acceptedLiveEdits = { sessionId: null as string | null, texts: new Map<string, string>() }
function rememberLiveEdit(sessionId: string, segmentId: string, text: string): void {
if (acceptedLiveEdits.sessionId !== sessionId) {
acceptedLiveEdits.sessionId = sessionId
acceptedLiveEdits.texts.clear()
}
acceptedLiveEdits.texts.set(segmentId, normalizeSegmentText(text))
}
function withLiveEdits(sessionId: string, segments: CaptionSegment[]): CaptionSegment[] {
if (acceptedLiveEdits.sessionId !== sessionId) return segments
return segments.map((segment) => {
const edited = acceptedLiveEdits.texts.get(segment.id)
return edited === undefined ? segment : { ...segment, text: edited }
})
}
/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */ /** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */
function attachLiveSegmentEditor(): LiveSegmentEditor { function attachLiveSegmentEditor(): LiveSegmentEditor {
liveSegmentEditor?.dispose() 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) => { ipcMain.handle(ch.GET_SESSIONS, (_event, params: MeetingGetSessionsParams) => {
try { try {
return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize)) return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize))
@ -190,7 +228,10 @@ export function registerMeetingModeHandlers(): void {
ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => { ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => {
try { try {
const result = liveEditor.edit(params.sessionId, params.segmentId, params.text) 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] const failure = EDIT_SEGMENT_FAILURES[result]
logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`) logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`)
return ipcError(failure.code, failure.message) 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 { try {
getMeetingModeService().cancelMeetingChat() getMeetingModeService().cancelMeetingChat(params?.sessionId)
return ipcSuccess(undefined) return ipcSuccess(undefined)
} catch (err) { } catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(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 { try {
getMeetingModeService().clearMeetingChatHistory() getMeetingModeService().clearMeetingChatHistory(params?.sessionId)
return ipcSuccess(undefined) return ipcSuccess(undefined)
} catch (err) { } catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(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 }) => { ipcMain.handle(ch.DIARIZE, async (_event, params: { sessionId: string; numSpeakers?: number }) => {
try { try {
await getMeetingModeService().diarizeSession(params.sessionId, params.numSpeakers) await getMeetingModeService().diarizeSession(params.sessionId, params.numSpeakers)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,8 +8,10 @@ import fs from 'fs'
import { app, dialog } from 'electron' import { app, dialog } from 'electron'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getPremiumLLMService } from './PremiumLLMService' import { getLlmGateway } from './llm/LlmGateway'
import { getCloudSyncService } from './CloudSyncService' 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 { getDatabase } from '../db'
import { history } from '../db/schema' import { history } from '../db/schema'
import { getMainWindow } from '../windows/WindowManager' import { getMainWindow } from '../windows/WindowManager'
@ -76,9 +78,22 @@ class MeetingSummaryService extends EventEmitter {
this._sendProgress(historyId, 'generating') this._sendProgress(historyId, 'generating')
try { try {
// LLM 요약 생성 // LLM 요약 생성 — 백엔드 선택은 게이트웨이가 한다(llmBackend='local' 이면 로컬 Ollama).
const llmService = getPremiumLLMService() // 예전엔 설정과 무관하게 Premium 에 고정돼, 로컬을 고른 로그인 사용자의 자막 전사가 클라우드로 가고
const result = await llmService.generate(transcript, { // 쿼터를 썼으며, 로그인하지 않은 사용자는 요약을 전혀 받지 못했다.
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, systemPrompt: MEETING_SUMMARY_PROMPT,
temperature: 0.3, temperature: 0.3,
}) })

View file

@ -10,7 +10,7 @@ import { memoTags, history } from '../db/schema'
import type { MemoTagRow } from '../db/schema' import type { MemoTagRow } from '../db/schema'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService' 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 { buildMemoExportFileName } from './memo/memo-export-file-name'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
@ -47,9 +47,18 @@ class MemoService {
addTag(historyId: string, tag: string): MemoTag { addTag(historyId: string, tag: string): MemoTag {
const db = getDatabase() const db = getDatabase()
const normalizedTag = normalizeMemoTag(tag) const normalizedTag = normalizeMemoTag(tag)
if (!normalizedTag) { const problem = validateMemoTag(tag)
if (problem === 'empty') {
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Memo tag is 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 const existing = db

View file

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

View file

@ -19,6 +19,7 @@ import { parseAnthropicSSE, AnthropicStreamError } from '../utils/sse-parser'
import { import {
fitChatRequest, fitChatRequest,
LLM_PROXY_CHAT_LIMITS, LLM_PROXY_CHAT_LIMITS,
singleTurnLimitViolation,
toChatRequest, toChatRequest,
type ChatRequest, type ChatRequest,
type ChatStreamOptions, type ChatStreamOptions,
@ -70,6 +71,27 @@ interface PremiumCall {
close: () => void 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) */ /** llm-proxy 출력 토큰 상한 (llm-contract MAX_OUTPUT_TOKENS) */
const PROXY_MAX_OUTPUT_TOKENS = 4096 const PROXY_MAX_OUTPUT_TOKENS = 4096
const DEFAULT_CHAT_MAX_TOKENS = 2048 const DEFAULT_CHAT_MAX_TOKENS = 2048
@ -213,10 +235,12 @@ class PremiumLLMService extends EventEmitter {
action: LLMAction, action: LLMAction,
targetLanguage?: string, targetLanguage?: string,
customPrompt?: string, customPrompt?: string,
options?: PremiumCallOptions,
): Promise<string> { ): Promise<string> {
this._ensureAuth() this._ensureAuth()
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt) const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
assertSingleTurnWithinProxyLimits({ system: systemPrompt, message: text })
const body: LlmProxyRequest = { const body: LlmProxyRequest = {
messages: [{ role: 'user', content: text }], messages: [{ role: 'user', content: text }],
@ -226,7 +250,7 @@ class PremiumLLMService extends EventEmitter {
} }
try { try {
const response = await this._invokeProxy(body) const response = await this._invokeSingleTurn(body, options)
// Claude 응답 → text 추출 // Claude 응답 → text 추출
const firstBlock = response.content?.[0] const firstBlock = response.content?.[0]
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) { if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
@ -323,15 +347,19 @@ class PremiumLLMService extends EventEmitter {
*/ */
async generate( async generate(
text: string, text: string,
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }, options?: { systemPrompt?: string; temperature?: number; maxTokens?: number } & PremiumCallOptions,
): Promise<{ text: string }> { ): Promise<{ text: string }> {
this._ensureAuth() this._ensureAuth()
const response = await this._invokeProxy({ assertSingleTurnWithinProxyLimits({ system: options?.systemPrompt, message: text })
const response = await this._invokeSingleTurn(
{
messages: [{ role: 'user', content: text }], messages: [{ role: 'user', content: text }],
system: options?.systemPrompt, system: options?.systemPrompt,
max_tokens: options?.maxTokens ?? 2048, max_tokens: options?.maxTokens ?? 2048,
stream: false, stream: false,
}) },
options,
)
const firstBlock = response.content?.[0] const firstBlock = response.content?.[0]
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) { if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM generate returned empty content') throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM generate returned empty content')
@ -356,6 +384,24 @@ class PremiumLLMService extends EventEmitter {
// ── 내부: Edge Function 호출 ───────────────────────────── // ── 내부: Edge Function 호출 ─────────────────────────────
/**
* 단일 턴 호출도 호출 단위 AbortController·기한을 갖는다 — 예전엔 _activeCalls 에 등록되지 않아
* cancelGeneration() 도 호출자의 signal 도 진행 중인 요청을 멈추지 못했다(응답 없는 프록시에서 체인이 멈춤).
*/
private async _invokeSingleTurn(body: LlmProxyRequest, options?: PremiumCallOptions): Promise<ClaudeMessageResponse> {
const call = this._beginCall(options?.signal, resolveTimeoutMs(options?.timeoutMs))
try {
if (call.controller.signal.aborted) {
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'Premium LLM generation cancelled')
}
return await this._invokeProxy(body, call.controller.signal)
} catch (err) {
throw this._toChatError(err, call)
} finally {
call.close()
}
}
private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> { private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> {
const cloud = getCloudSyncService() const cloud = getCloudSyncService()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,18 +8,14 @@ import { history, memoTags } from '../../db/schema'
import { isUuid, type PushContext } from './sync-adapters' import { isUuid, type PushContext } from './sync-adapters'
import { toSyncRemoteError, type PushOutcome, type RemoteCursor, type SyncOp, type SyncRemote } from './sync-types' 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 SEPARATOR = '|'
const PAGE_SIZE = 1000 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 { export function parseMemoTagKey(key: string): { historyId: string; tag: string } | null {
const index = key.indexOf(SEPARATOR) const index = key.indexOf(SEPARATOR)
if (index <= 0) return null if (index <= 0) return null

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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> = {}): 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' })
})
})

View file

@ -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()
})
})

View file

@ -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<string, unknown> }))
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<void> {}
async flush(): Promise<void> {
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()
})
})

View file

@ -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)
})
})

View file

@ -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<string, string> {
for (const v of values) yield v
return values.join('')
}
function premium(overrides: Partial<PremiumLlmAdapter> = {}): PremiumLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'premium'),
generate: vi.fn(async () => ({ text: 'premium' })),
chatStream: vi.fn(() => tokens('p')),
cancelGeneration: vi.fn(),
...overrides,
}
}
function local(overrides: Partial<LocalLlmAdapter> = {}): LocalLlmAdapter {
return {
isAvailable: vi.fn(() => true),
processText: vi.fn(async () => 'local'),
generate: vi.fn(async () => ({ text: 'local' })),
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()
})
})

View file

@ -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<void>((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,
})
})
})

View file

@ -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<string, (payload: { buffer: Buffer }) => 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<string, string> {
yield text
return text
}
async function* failing(): AsyncGenerator<string, string> {
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<void>((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)
})
})

View file

@ -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<void> | 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<unknown>),
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
}))
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<boolean> {
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<string, unknown> => {
const builder: Record<string, unknown> = {}
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<void>((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<unknown> = []
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<string, Buffer>()
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<S>(
(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')
})
})

View file

@ -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 확인'])
})
})

View file

@ -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<typeof import('../../src/main/services/LocalLLMService')>()
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')
})
})

View file

@ -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<void> | 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<Array<{ role: string; content: string }>>,
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: 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<typeof meetingSessions.$inferInsert> = {}): 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<void>((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)
})
})

View file

@ -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()
})
})

View file

@ -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' })
})
})

View file

@ -8,13 +8,17 @@ export interface LocalLogoutResult {
* Attempts server-side refresh-token revocation first, then always performs * 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 * 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. * 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( export async function signOutWithLocalFallback(
purgeLocalSession: () => Promise<void>, purgeLocalSession: () => Promise<void>,
): Promise<LocalLogoutResult> { ): Promise<LocalLogoutResult> {
let remoteRevocationConfirmed = false let remoteRevocationConfirmed = false
try { try {
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut({ scope: 'local' })
remoteRevocationConfirmed = error === null remoteRevocationConfirmed = error === null
} catch { } catch {
remoteRevocationConfirmed = false remoteRevocationConfirmed = false

View file

@ -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)
})
})

View file

@ -13,6 +13,26 @@ export function normalizeDictionaryWord(raw: string): string | null {
return word.length > 0 ? word : 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<T extends { id: string; word: string }>(
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: "변경 없음" * - undefined → undefined: "변경 없음"

View file

@ -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
}

View file

@ -142,3 +142,21 @@ export function fitChatRequest(request: ChatRequest, limits: Readonly<ChatReques
if (fittedSystem !== undefined) fitted.system = fittedSystem if (fittedSystem !== undefined) fitted.system = fittedSystem
return fitted return fitted
} }
/** 단일 턴 요청(generate/processText)에서 한도를 넘는 부분. */
export type SingleTurnLimitViolation = 'system' | 'message'
/**
* 단일 턴 요청이 한도를 넘는지 (순수 함수). 서버(parseLlmRequest)처럼 앞뒤 공백을 빼고 센다.
* 채팅과 달리 잘라 보내지 않는다 — 자유 생성 입력(회의 전사·요약 대상)을 조용히 자르면
* 결과가 입력의 앞부분만 반영하고도 완전한 것처럼 보인다. 호출자가 나누거나(map-reduce) 실패를 알린다.
*/
export function singleTurnLimitViolation(
input: { system?: string; message: string },
limits: Readonly<ChatRequestLimits> = 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
}

View file

@ -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'

View file

@ -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<string> {
const normalized = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ')
const grams = new Set<string>()
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,
}
}

View file

@ -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
}