fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -4,7 +4,7 @@ import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
|
|||
import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi'
|
||||
import { VK } from '@d3ro/core/keybinding'
|
||||
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
||||
import { initConfigService, configGet, configSet, onConfigChanged } from './services/ConfigService'
|
||||
import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService'
|
||||
import { acquireGlobalInputHook } from './services/global-input-hook'
|
||||
import { trackModifierKeys } from './services/modifier-state'
|
||||
|
|
@ -26,7 +26,8 @@ import { getSoundEffectService } from './services/SoundEffectService'
|
|||
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||
import { getAudioCaptureService } from './services/AudioCaptureService'
|
||||
import { initLicenseService } from './services/LicenseService'
|
||||
import { openLocal } from './db'
|
||||
import { openForUser, openLocal } from './db'
|
||||
import { openStartupDatabase } from './db/startup-database'
|
||||
import {
|
||||
createMainWindow,
|
||||
getMainWindow,
|
||||
|
|
@ -39,17 +40,16 @@ import {
|
|||
hideCommandPopup,
|
||||
sendKeyToCommandPopup,
|
||||
isCommandPopupVisible,
|
||||
showSuggestionOverlay,
|
||||
updateSuggestionOverlay,
|
||||
hideSuggestionOverlay,
|
||||
isSuggestionOverlayVisible,
|
||||
reapplyThemeToAllPopups,
|
||||
} from './windows/WindowManager'
|
||||
import { createTray } from './windows/TrayManager'
|
||||
import { RecordingTipPresenter, createWindowRecordingTipView } from './windows/recording-tip-presenter'
|
||||
import { SuggestionOverlayPresenter, createWindowSuggestionOverlayView } from './windows/suggestion-overlay-presenter'
|
||||
import { ConfigEffects } from './config-effects'
|
||||
import { PopupNavigationController } from './windows/popup-navigation'
|
||||
import { setupLifecycle } from './lifecycle'
|
||||
import { getShutdownRegistry } from './shutdown-registry'
|
||||
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
|
||||
import { shouldDismissOnEscape } from './suggestion-overlay-policy'
|
||||
import { registerAllIpcHandlers } from './ipc'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import type { IPCChannel } from '@d3ro/core/ipc-channels'
|
||||
|
|
@ -71,13 +71,15 @@ export async function bootstrap(): Promise<void> {
|
|||
const steps: BootstrapStep[] = [
|
||||
{ name: 'logger', critical: false, fn: initLogger },
|
||||
{ name: 'config', critical: false, fn: initConfig },
|
||||
// 빅뱅 Phase 1.5: 앱 시작 시 익명 로컬 DB를 먼저 연다.
|
||||
// 무료 로컬 모드가 사용자 entry point — 회원가입 없이 바로 킬러 피처 사용 가능.
|
||||
// CloudSyncService._onAuthenticated()에서 로그인 시점에 user DB로 스위치한다.
|
||||
{ name: 'database', critical: true, fn: initLocalDatabase },
|
||||
// 빅뱅 Phase 1.5: 앱 시작 시 DB 를 먼저 연다. 로그인하지 않았으면 익명 로컬 DB(무료 로컬 모드 —
|
||||
// 회원가입 없이 바로 사용), 마지막으로 로그인한 계정이 있으면 그 계정 DB 를 바로 연다(세션 복원이
|
||||
// 끝나기 전·오프라인에도 기록이 익명 DB 로 새지 않게). 복원이 확실히 실패하면 CloudSyncService 가
|
||||
// 로컬 DB 로 되돌린다.
|
||||
{ name: 'database', critical: true, fn: initStartupDatabase },
|
||||
{ name: 'license', critical: false, fn: initLicense },
|
||||
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||
{ name: 'tray', critical: false, fn: initTray },
|
||||
{ name: 'config-effects', critical: false, fn: initConfigEffects },
|
||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||
{ name: 'custom-instructions', critical: false, fn: initCustomInstructions },
|
||||
{ name: 'voice-commands', critical: false, fn: initVoiceCommands },
|
||||
|
|
@ -132,9 +134,35 @@ async function initConfig(): Promise<void> {
|
|||
initConfigService()
|
||||
}
|
||||
|
||||
async function initLocalDatabase(): Promise<void> {
|
||||
const { created, dbPath } = openLocal()
|
||||
logger.info(`[bootstrap] local database opened: ${dbPath} (created=${created})`)
|
||||
async function initStartupDatabase(): Promise<void> {
|
||||
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||
const { created, dbPath, accountUserId } = openStartupDatabase({
|
||||
storedAccountUserId: () => getCloudSyncService().peekStoredAccountUserId(),
|
||||
openForUser,
|
||||
openLocal,
|
||||
warn: (message) => logger.warn(`[bootstrap] ${message}`),
|
||||
})
|
||||
logger.info(
|
||||
`[bootstrap] ${accountUserId ? 'account' : 'local'} database opened: ${dbPath} (created=${created})`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 키별 부수효과 — 어느 경로로 바뀌든(설정 화면의 범용 config:set, 전용 IPC, 원격 동기화) 한 번씩 적용한다.
|
||||
* 예전엔 전용 IPC 에만 붙어 있어 설정 화면에서 바꾼 자동 실행·테마가 OS·팝업에 반영되지 않았다.
|
||||
*/
|
||||
async function initConfigEffects(): Promise<void> {
|
||||
const effects = new ConfigEffects({
|
||||
subscribe: (listener) => onConfigChanged(listener),
|
||||
onError: (key, error) =>
|
||||
logger.warn(`[config-effects] ${key} 적용 실패: ${error instanceof Error ? error.message : String(error)}`),
|
||||
})
|
||||
// OS 로그인 항목 — setEnabled 가 같은 값을 다시 저장해도 값이 같으면 효과는 다시 돌지 않는다
|
||||
effects.register('autoLaunch', (value) => getAutoLaunchService().setEnabled(value === true))
|
||||
// 미리 로드된 팝업(녹음 팁·기록·명령·제안·자막)의 테마 CSS
|
||||
effects.register('theme', () => reapplyThemeToAllPopups())
|
||||
effects.start()
|
||||
getShutdownRegistry().register({ name: 'config-effects', dispose: () => effects.dispose() })
|
||||
}
|
||||
|
||||
async function initLicense(): Promise<void> {
|
||||
|
|
@ -177,56 +205,19 @@ async function initInputIntelligence(): Promise<void> {
|
|||
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, payload)
|
||||
)
|
||||
|
||||
// 오버레이 표시 정책은 프레젠터 한 곳 — 여기서는 조립과 메인 창 중계만 한다.
|
||||
// (프레젠터는 오버레이 예외를 삼키므로 메인 창 중계는 항상 실행된다)
|
||||
const overlay = new SuggestionOverlayPresenter({
|
||||
view: createWindowSuggestionOverlayView(),
|
||||
setPresentationActive: (active) => telemetry.setSuggestionPresentationActive(active),
|
||||
warn: (message) => logger.warn(message),
|
||||
})
|
||||
suggestion.on('updated', (state) => {
|
||||
// 오버레이/윈도우 호출이 예외를 던지면 서비스의 진행 플래그가 굳을 수 있다.
|
||||
// 이벤트 경로는 어떤 경우에도 조용히 삼킨다 (UI 문제로 기능 전체가 멈추면 안 된다).
|
||||
try {
|
||||
const hasSomethingToShow =
|
||||
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
|
||||
const shouldPresent = hasSomethingToShow && !!state.anchor
|
||||
telemetry.setSuggestionPresentationActive(shouldPresent)
|
||||
|
||||
const action = decideSuggestionOverlayAction(isSuggestionOverlayVisible(), shouldPresent)
|
||||
if (action === 'hide') {
|
||||
hideSuggestionOverlay()
|
||||
} else if (action === 'update') {
|
||||
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
|
||||
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
|
||||
updateSuggestionOverlay({
|
||||
candidates: state.candidates,
|
||||
activeIndex: state.activeIndex,
|
||||
targetTotal: state.targetTotal,
|
||||
generating: state.generating,
|
||||
warmingUp: state.warmingUp,
|
||||
partialText: state.partialText,
|
||||
provenance: state.provenance
|
||||
})
|
||||
} else {
|
||||
showSuggestionOverlay({
|
||||
candidates: state.candidates,
|
||||
activeIndex: state.activeIndex,
|
||||
targetTotal: state.targetTotal,
|
||||
generating: state.generating,
|
||||
warmingUp: state.warmingUp,
|
||||
partialText: state.partialText,
|
||||
anchor: state.anchor,
|
||||
anchorKind: state.anchorKind,
|
||||
appName: state.appName,
|
||||
provenance: state.provenance
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`제안 오버레이 표시 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
overlay.onUpdated(state)
|
||||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state)
|
||||
})
|
||||
suggestion.on('cleared', (payload) => {
|
||||
telemetry.setSuggestionPresentationActive(false)
|
||||
try {
|
||||
hideSuggestionOverlay()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 오버레이 숨기기 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
overlay.onCleared()
|
||||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload)
|
||||
})
|
||||
suggestion.on('state-changed', (state) =>
|
||||
|
|
@ -469,6 +460,14 @@ async function initCloudSync(): Promise<void> {
|
|||
const { getCloudSyncService } = await import('./services/CloudSyncService')
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
// 지난 실행에서 'recording'/'processing' 으로 남은 회의(강제 종료·정전)를 닫는다. 세션 복원 뒤에 해야
|
||||
// 바뀐 상태가 동기화 outbox 에 기록돼 다른 기기에서도 끝없는 녹음으로 보이지 않는다.
|
||||
try {
|
||||
const { getMeetingModeService } = await import('./services/MeetingModeService')
|
||||
getMeetingModeService().recoverInterruptedSessions()
|
||||
} catch (err) {
|
||||
logger.warn('Interrupted meeting recovery failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function initMeetingSummaryWiring(): Promise<void> {
|
||||
|
|
@ -503,7 +502,13 @@ async function initMeetingSummaryWiring(): Promise<void> {
|
|||
async function initMeetingMode(): Promise<void> {
|
||||
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
|
||||
const { getMeetingModeService } = await import('./services/MeetingModeService')
|
||||
getMeetingModeService()
|
||||
const { getCaptionService } = await import('./services/CaptionService')
|
||||
const meetingMode = getMeetingModeService()
|
||||
// 종료 정리는 등록의 역순이다 — 회의(전사·녹음 저장)가 자막보다 먼저, 둘 다 STT 사이드카보다 먼저 정리된다.
|
||||
// 예전엔 등록되지 않아, 녹음 중 종료(트레이 종료·업데이트 재시작)가 전사와 녹음을 모두 버리고
|
||||
// 세션을 영원히 'recording' 으로 남겼다.
|
||||
getShutdownRegistry().register({ name: 'caption', dispose: () => getCaptionService().dispose() })
|
||||
getShutdownRegistry().register({ name: 'meeting-mode', dispose: () => meetingMode.shutdown() })
|
||||
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
|
||||
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
|
||||
getMeetingDocTemplateService()
|
||||
|
|
|
|||
87
apps/desktop/src/main/config-effects.ts
Normal file
87
apps/desktop/src/main/config-effects.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -435,7 +435,7 @@ export interface OpenForUserResult {
|
|||
|
||||
/**
|
||||
* 사용자별 DB 열기 — userData/users/${userId}/d3ro.db
|
||||
* 이미 다른 사용자 DB가 열려있으면 먼저 close 후 오픈.
|
||||
* 이미 다른 사용자 DB가 열려있으면 새 DB 를 연 뒤에 닫는다(실패하면 이전 DB 유지).
|
||||
* 동일 사용자면 no-op.
|
||||
*/
|
||||
export function openForUser(userId: string): OpenForUserResult {
|
||||
|
|
@ -448,11 +448,6 @@ export function openForUser(userId: string): OpenForUserResult {
|
|||
return { created: false, dbPath: getUserDbPath(userId) }
|
||||
}
|
||||
|
||||
// 다른 사용자가 열려있으면 먼저 닫기
|
||||
if (sqlite) {
|
||||
closeCurrent()
|
||||
}
|
||||
|
||||
// 레거시 DB는 첫 오픈 직전 silent archive
|
||||
archiveLegacyDbIfExists()
|
||||
|
||||
|
|
@ -466,26 +461,33 @@ export function openForUser(userId: string): OpenForUserResult {
|
|||
|
||||
logger.info(`Opening database for user ${userId} at: ${dbPath} (existed=${existed})`)
|
||||
|
||||
// 원자적 교체: 새 DB 를 열고 스키마까지 적용한 뒤에야 이전 DB 를 닫는다.
|
||||
// 예전엔 먼저 닫고 열었다 — 계정 DB 가 손상되면(applySchema 실패) 로컬 DB 도 이미 닫혀 앱이
|
||||
// 재시작 전까지 모든 저장·조회에 실패했다. 실패하면 이전 DB 가 그대로 열려 있다.
|
||||
let next: Database.Database | null = null
|
||||
let nextDb: BetterSQLite3Database<typeof schema>
|
||||
try {
|
||||
sqlite = new Database(dbPath)
|
||||
applySchema(sqlite)
|
||||
db = drizzle(sqlite, { schema })
|
||||
currentUserId = userId
|
||||
next = new Database(dbPath)
|
||||
applySchema(next)
|
||||
nextDb = drizzle(next, { schema })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`Failed to open database for user ${userId}: ${message}`)
|
||||
// 부분 상태 초기화
|
||||
logger.error(`Failed to open database for user ${userId}: ${message} (keeping ${currentUserId ?? 'none'} open)`)
|
||||
try {
|
||||
sqlite?.close()
|
||||
next?.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sqlite = null
|
||||
db = null
|
||||
currentUserId = null
|
||||
throw new D3ROError(ErrorCode.DBOpenFailed, `Failed to open user DB: ${message}`)
|
||||
}
|
||||
|
||||
if (sqlite) {
|
||||
closeCurrent()
|
||||
}
|
||||
sqlite = next
|
||||
db = nextDb
|
||||
currentUserId = userId
|
||||
|
||||
logger.info(`Database opened for user: ${userId}`)
|
||||
// 계정 범위 저장소(명령·템플릿)도 이 사용자로 바꾼다 — 동기화 엔진이 만들어지기 전에 동기적으로 끝난다.
|
||||
emitAccountScopeChanged(userId)
|
||||
|
|
|
|||
30
apps/desktop/src/main/db/like-pattern.ts
Normal file
30
apps/desktop/src/main/db/like-pattern.ts
Normal 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}`,
|
||||
}
|
||||
}
|
||||
42
apps/desktop/src/main/db/startup-database.ts
Normal file
42
apps/desktop/src/main/db/startup-database.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import { getCloudSyncService } from '../services/CloudSyncService'
|
|||
import { getLogger } from '../services/LoggerService'
|
||||
import { onConfigChanged } from '../services/ConfigService'
|
||||
import { isApplyingRemoteSettings } from '../services/sync/settings-sync'
|
||||
import { reapplyThemeToAllPopups } from '../windows/WindowManager'
|
||||
|
||||
const logger = getLogger('cloud-sync-handlers')
|
||||
|
||||
|
|
@ -119,10 +118,10 @@ export function registerCloudSyncHandlers(): void {
|
|||
})
|
||||
// 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독).
|
||||
// 동기화가 메인에서 바꾼 설정(언어·테마 등)을 화면에도 알린다 — IPC로 바꿀 때만 방송되던 경로.
|
||||
// (팝업 테마 등 키별 부수효과는 config-effects 레지스트리가 모든 경로에 한 번씩 적용한다)
|
||||
onConfigChanged((event) => {
|
||||
if (!isApplyingRemoteSettings()) return
|
||||
broadcast(IPC_CHANNELS.CONFIG.CHANGED, event)
|
||||
if (event.key === 'theme') reapplyThemeToAllPopups()
|
||||
})
|
||||
|
||||
sync.on('data-changed', (payload) => {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import { ipcMain, BrowserWindow } from 'electron'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
|
||||
import { getAutoLaunchService } from '../services/AutoLaunchService'
|
||||
import { reapplyThemeToAllPopups } from '../windows/WindowManager'
|
||||
|
||||
/** 설정 변경 시 모든 렌더러에 broadcast */
|
||||
function broadcastConfigChanged(key: string, value: unknown, previousValue?: unknown): void {
|
||||
|
|
@ -64,11 +62,12 @@ export function registerConfigHandlers(): void {
|
|||
return ipcSuccess(configGet('theme'))
|
||||
})
|
||||
|
||||
// 팝업 테마 다시 칠하기·OS 로그인 항목 갱신 같은 키별 부수효과는 config-effects 레지스트리가 설정 변경
|
||||
// 이벤트로 한 번씩 적용한다 — 전용 IPC 든 범용 config:set 이든 같은 효과가 난다.
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_THEME, async (_event, params: SetThemeParams) => {
|
||||
const prev = configGet('theme')
|
||||
configSet('theme', params.theme)
|
||||
broadcastConfigChanged('theme', params.theme, prev)
|
||||
reapplyThemeToAllPopups()
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
||||
|
|
@ -86,7 +85,9 @@ export function registerConfigHandlers(): void {
|
|||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, async (_event, params: SetAutoLaunchParams) => {
|
||||
getAutoLaunchService().setEnabled(params.enabled)
|
||||
const prev = configGet('autoLaunch')
|
||||
configSet('autoLaunch', params.enabled)
|
||||
broadcastConfigChanged('autoLaunch', params.enabled, prev)
|
||||
return ipcSuccess(undefined)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
|||
import { getLocalLLMService } from '../services/LocalLLMService'
|
||||
import { getPremiumLLMService } from '../services/PremiumLLMService'
|
||||
import { getOnlineLLMService } from '../services/OnlineLLMService'
|
||||
import { getLlmGateway } from '../services/llm/LlmGateway'
|
||||
import { configGet, configSet } from '../services/ConfigService'
|
||||
import { buildInstructionInvocation, resolveTargetLanguage } from '../services/llm-prompts'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
|
|
@ -83,9 +84,6 @@ export function registerLLMHandlers(): void {
|
|||
|
||||
ipcMain.handle(IPC_CHANNELS.LLM.PROCESS, async (_event, params: LLMProcessParams) => {
|
||||
try {
|
||||
const backend = configGet('llmBackend')
|
||||
const service =
|
||||
backend === 'online' ? getOnlineLLMService() : getLocalLLMService()
|
||||
const start = performance.now()
|
||||
|
||||
// 지시문 프롬프트를 시스템 프롬프트 자리로 보내고 플레이스홀더를 치환하는 판단은
|
||||
|
|
@ -100,12 +98,25 @@ export function registerLLMHandlers(): void {
|
|||
)
|
||||
: { text: params.text, systemPrompt: params.customPrompt }
|
||||
|
||||
const processedText = await service.processText(
|
||||
invocation.text,
|
||||
params.action,
|
||||
params.targetLanguage,
|
||||
invocation.systemPrompt
|
||||
)
|
||||
// 실제 받아쓰기(VoiceModeService)와 같은 경로 — 백엔드 선택은 LlmGateway 한 곳이 정한다
|
||||
// (online 이면 Supabase Premium, 불가·실패 시 로컬 폴백). 예전엔 online 이면 레거시 .NET
|
||||
// OnlineLLMService 로 가서, Supabase 로 로그인한 사용자는 명령 테스트 벤치·Ollama 가이드 테스트가
|
||||
// '로그인 필요'나 127.0.0.1:5000 연결 실패로 떨어졌는데 실제 받아쓰기는 성공했다.
|
||||
// 레거시 .NET 계정 토큰(authToken)으로 로그인한 경우만 예전 경로를 유지한다(그 계정 전용 서버).
|
||||
const useLegacyOnline = configGet('llmBackend') === 'online' && !!configGet('authToken')
|
||||
const processedText = useLegacyOnline
|
||||
? await getOnlineLLMService().processText(
|
||||
invocation.text,
|
||||
params.action,
|
||||
params.targetLanguage,
|
||||
invocation.systemPrompt
|
||||
)
|
||||
: await getLlmGateway().processText(
|
||||
invocation.text,
|
||||
params.action,
|
||||
params.targetLanguage,
|
||||
invocation.systemPrompt
|
||||
)
|
||||
|
||||
return ipcSuccess({
|
||||
originalText: params.text,
|
||||
|
|
@ -121,7 +132,8 @@ export function registerLLMHandlers(): void {
|
|||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.LLM.CANCEL_PROCESS, async () => {
|
||||
getLocalLLMService().cancelGeneration()
|
||||
// 전역 취소 IPC — 게이트웨이(로컬 + 로드된 Premium)와, 아직 게이트웨이가 로드하지 않은 Premium·레거시 경로까지
|
||||
getLlmGateway().cancel()
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
getOnlineLLMService().cancelGeneration()
|
||||
return ipcSuccess(undefined)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
|||
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
|
||||
import { getMeetingModeService } from '../services/MeetingModeService'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import { LiveSegmentEditor } from '../services/meeting/live-segment-edits'
|
||||
import { LiveSegmentEditor, normalizeSegmentText } from '../services/meeting/live-segment-edits'
|
||||
import {
|
||||
MEETING_CHAT_GET_HISTORY_CHANNEL,
|
||||
MEETING_MODE_GET_LIVE_SESSION_CHANNEL,
|
||||
} from '@d3ro/core/meeting-live'
|
||||
import type { LiveSegmentEditResult } from '../services/meeting/live-segment-edits'
|
||||
import type {
|
||||
CaptionSegment,
|
||||
|
|
@ -32,6 +36,28 @@ interface MeetingEditSegmentParams {
|
|||
|
||||
let liveSegmentEditor: LiveSegmentEditor | null = null
|
||||
|
||||
/**
|
||||
* 녹음 중 받아들인 세그먼트 수정(표시용 사본). 편집기는 세션 완료 때 전사에 반영하고, 이 사본은
|
||||
* 녹음 화면을 다시 열었을 때 고친 문장을 그대로 보여 주는 데 쓴다.
|
||||
*/
|
||||
const acceptedLiveEdits = { sessionId: null as string | null, texts: new Map<string, string>() }
|
||||
|
||||
function rememberLiveEdit(sessionId: string, segmentId: string, text: string): void {
|
||||
if (acceptedLiveEdits.sessionId !== sessionId) {
|
||||
acceptedLiveEdits.sessionId = sessionId
|
||||
acceptedLiveEdits.texts.clear()
|
||||
}
|
||||
acceptedLiveEdits.texts.set(segmentId, normalizeSegmentText(text))
|
||||
}
|
||||
|
||||
function withLiveEdits(sessionId: string, segments: CaptionSegment[]): CaptionSegment[] {
|
||||
if (acceptedLiveEdits.sessionId !== sessionId) return segments
|
||||
return segments.map((segment) => {
|
||||
const edited = acceptedLiveEdits.texts.get(segment.id)
|
||||
return edited === undefined ? segment : { ...segment, text: edited }
|
||||
})
|
||||
}
|
||||
|
||||
/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */
|
||||
function attachLiveSegmentEditor(): LiveSegmentEditor {
|
||||
liveSegmentEditor?.dispose()
|
||||
|
|
@ -114,6 +140,18 @@ export function registerMeetingModeHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
// 녹음 화면을 다시 열 때(페이지 이동 후 복귀) 그동안의 실시간 전사·메모를 복원한다.
|
||||
// 녹음 중 고친 세그먼트는 고친 문장으로 돌려준다(세션 완료 시 저장될 내용과 같게).
|
||||
ipcMain.handle(MEETING_MODE_GET_LIVE_SESSION_CHANNEL, () => {
|
||||
try {
|
||||
const live = getMeetingModeService().getLiveSession()
|
||||
if (!live) return ipcSuccess(null)
|
||||
return ipcSuccess({ ...live, segments: withLiveEdits(live.sessionId, live.segments) })
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.GET_SESSIONS, (_event, params: MeetingGetSessionsParams) => {
|
||||
try {
|
||||
return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize))
|
||||
|
|
@ -190,7 +228,10 @@ export function registerMeetingModeHandlers(): void {
|
|||
ipcMain.handle(ch.EDIT_SEGMENT, (_event, params: MeetingEditSegmentParams) => {
|
||||
try {
|
||||
const result = liveEditor.edit(params.sessionId, params.segmentId, params.text)
|
||||
if (result === 'ok') return ipcSuccess(undefined)
|
||||
if (result === 'ok') {
|
||||
rememberLiveEdit(params.sessionId, params.segmentId, params.text)
|
||||
return ipcSuccess(undefined)
|
||||
}
|
||||
const failure = EDIT_SEGMENT_FAILURES[result]
|
||||
logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`)
|
||||
return ipcError(failure.code, failure.message)
|
||||
|
|
@ -302,24 +343,35 @@ export function registerMeetingModeHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CANCEL, () => {
|
||||
// sessionId 를 주면 그 회의만 — 예전엔 한 회의의 '지우기'가 모든 회의의 대화를 지웠다.
|
||||
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CANCEL, (_event, params?: { sessionId?: string }) => {
|
||||
try {
|
||||
getMeetingModeService().cancelMeetingChat()
|
||||
getMeetingModeService().cancelMeetingChat(params?.sessionId)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CLEAR, () => {
|
||||
ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CLEAR, (_event, params?: { sessionId?: string }) => {
|
||||
try {
|
||||
getMeetingModeService().clearMeetingChatHistory()
|
||||
getMeetingModeService().clearMeetingChatHistory(params?.sessionId)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
// 채팅 패널을 다시 열 때 main 이 기억하는 대화를 보여 준다 — 화면은 비었는데 모델은 숨은 대화를
|
||||
// 이어받던 문제를 없앤다.
|
||||
ipcMain.handle(MEETING_CHAT_GET_HISTORY_CHANNEL, (_event, params: { sessionId: string }) => {
|
||||
try {
|
||||
return ipcSuccess(getMeetingModeService().getMeetingChatHistory(params.sessionId))
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.DIARIZE, async (_event, params: { sessionId: string; numSpeakers?: number }) => {
|
||||
try {
|
||||
await getMeetingModeService().diarizeSession(params.sessionId, params.numSpeakers)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { getHistoryService } from './HistoryService'
|
|||
import { configGet } from './ConfigService'
|
||||
import { buildCaptionRefinePrompt } from './llm-prompts'
|
||||
import { StreamingCaptionTrack, type CaptionTranscription } from './caption/StreamingCaptionTrack'
|
||||
import { CaptionAudioSources } from './caption/CaptionAudioSources'
|
||||
import { acceptCaptionRefinement } from '@d3ro/core/caption-streaming'
|
||||
import {
|
||||
showCaptionOverlay,
|
||||
|
|
@ -91,7 +92,12 @@ interface CaptionServiceEvents {
|
|||
'segment': (segment: CaptionSegment) => void
|
||||
'delta': (data: { text: string; isFinal: boolean }) => void
|
||||
'session-saved': (summary: CaptionSessionResult) => void
|
||||
'error': (error: D3ROError) => void
|
||||
/**
|
||||
* 인식·시작 실패 알림. 'error' 가 아니다 — 리스너 없는 'error' 는 EventEmitter 가 던져서,
|
||||
* 정지 중 마지막 문장 인식이 실패하면 stop() 이 해제 전에 튕겨 나가 서비스가 'stopping' 에
|
||||
* 마이크가 열린 채 굳었다.
|
||||
*/
|
||||
'failure': (error: D3ROError) => void
|
||||
}
|
||||
|
||||
class CaptionService extends EventEmitter {
|
||||
|
|
@ -113,7 +119,8 @@ class CaptionService extends EventEmitter {
|
|||
private _refineQueue: CaptionSegment[] = []
|
||||
private _refining = false
|
||||
|
||||
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||
/** 이번 세션이 잡은 오디오 소스(마이크 캡처·시스템 소리 요청) — 해제는 멱등 */
|
||||
private _audio: CaptionAudioSources = this._createAudioSources()
|
||||
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
|
||||
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
|
||||
|
||||
|
|
@ -203,22 +210,16 @@ class CaptionService extends EventEmitter {
|
|||
}
|
||||
logger.info(`Caption audioSource: ${audioSource} (owner: ${this._session.owner})`)
|
||||
|
||||
// 마이크 캡처 (mic 또는 both)
|
||||
// 트랙 먼저 — 마이크 첫 청크부터 받을 수 있게
|
||||
if (audioSource === 'mic' || audioSource === 'both') {
|
||||
this._micTrack = this._createTrack('mic', initialContext)
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
this._audioDataHandler = (payload) => {
|
||||
this._onAudioData(payload.buffer)
|
||||
}
|
||||
audioCaptureService.on('audio-data', this._audioDataHandler)
|
||||
await audioCaptureService.start()
|
||||
}
|
||||
|
||||
// 시스템 오디오 캡처 요청 (system 또는 both) — 렌더러에 시작 요청
|
||||
if (audioSource === 'system' || audioSource === 'both') {
|
||||
this._systemTrack = this._createTrack('system', initialContext)
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {})
|
||||
}
|
||||
// 마이크 캡처(mic/both) + 시스템 오디오 캡처 요청(system/both — 렌더러에 시작 요청)
|
||||
this._audio = this._createAudioSources()
|
||||
await this._audio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
|
||||
|
||||
// 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다
|
||||
this._tickTimer = setInterval(() => {
|
||||
|
|
@ -236,13 +237,8 @@ class CaptionService extends EventEmitter {
|
|||
}
|
||||
this._micTrack = null
|
||||
this._systemTrack = null
|
||||
if (this._audioDataHandler) {
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
audioCaptureService.off('audio-data', this._audioDataHandler)
|
||||
this._audioDataHandler = null
|
||||
await audioCaptureService.stop().catch(() => {})
|
||||
}
|
||||
hideCaptionOverlay()
|
||||
await this._audio.release()
|
||||
this._hideOverlaySafely()
|
||||
this._session = { ...DEFAULT_START_OPTIONS }
|
||||
this._setState('inactive')
|
||||
const d3roErr =
|
||||
|
|
@ -252,7 +248,7 @@ class CaptionService extends EventEmitter {
|
|||
ErrorCode.CaptionStartFailed,
|
||||
`캡션 시작 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
this.emit('error', d3roErr)
|
||||
this._reportFailure(d3roErr)
|
||||
throw d3roErr
|
||||
}
|
||||
}
|
||||
|
|
@ -284,28 +280,31 @@ class CaptionService extends EventEmitter {
|
|||
this._tickTimer = null
|
||||
}
|
||||
|
||||
// 잔여 오디오 처리 — 말하던 중이면 마지막 문장을 확정한다
|
||||
await Promise.all([this._micTrack?.flush(), this._systemTrack?.flush()])
|
||||
this._micTrack = null
|
||||
this._systemTrack = null
|
||||
|
||||
// 마이크 캡처 정리
|
||||
if (this._audioDataHandler) {
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
audioCaptureService.off('audio-data', this._audioDataHandler)
|
||||
this._audioDataHandler = null
|
||||
await audioCaptureService.stop()
|
||||
// 잔여 오디오 처리 — 말하던 중이면 마지막 문장을 확정한다.
|
||||
// 인식이 실패해도(사이드카 500·타임아웃) 아래 해제·초기화는 반드시 실행한다 — 예전엔 여기서 튕겨
|
||||
// 마이크가 열린 채 'stopping' 에 굳어 재시작 전까지 자막·회의를 다시 시작할 수 없었다.
|
||||
try {
|
||||
const results = await Promise.allSettled([this._micTrack?.flush(), this._systemTrack?.flush()])
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') this._reportFailure(this._toSttError(result.reason, 'flush'))
|
||||
}
|
||||
} finally {
|
||||
this._micTrack = null
|
||||
this._systemTrack = null
|
||||
// 마이크 캡처 정리 + 시스템 오디오 캡처 중지 요청 (멱등, 실패해도 계속)
|
||||
await this._audio.release()
|
||||
// 오버레이 숨김
|
||||
this._hideOverlaySafely()
|
||||
}
|
||||
|
||||
// 시스템 오디오 캡처 중지 요청
|
||||
this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {})
|
||||
|
||||
// 오버레이 숨김
|
||||
hideCaptionOverlay()
|
||||
|
||||
// 세션을 히스토리에 저장 (회의 모드처럼 저장을 끈 소유자는 요약만 돌려받는다)
|
||||
const persist = this._session.persistHistory
|
||||
const summary = this._saveSession(persist)
|
||||
let summary: CaptionSessionResult | null = null
|
||||
try {
|
||||
summary = this._saveSession(persist)
|
||||
} catch (err) {
|
||||
logger.error(`캡션 세션 요약 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
|
|
@ -363,22 +362,65 @@ class CaptionService extends EventEmitter {
|
|||
isVoiced: (chunk) => calculateRMS(chunk) >= SILENCE_RMS_THRESHOLD,
|
||||
onPartial: (text, stable) => this._emitPartial(text, stable),
|
||||
onFinal: (text) => this._emitFinal(text, source),
|
||||
onError: (err) => {
|
||||
const d3roErr =
|
||||
err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(
|
||||
ErrorCode.CaptionSTTFailed,
|
||||
`자막 전사 실패(${source}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
logger.error(d3roErr.message)
|
||||
this.emit('error', d3roErr)
|
||||
},
|
||||
// 절대 던지지 않는 보고 경로 — 트랙의 인식 실패가 정지·틱을 깨뜨리지 않게
|
||||
onError: (err) => this._reportFailure(this._toSttError(err, source)),
|
||||
},
|
||||
initialContext,
|
||||
)
|
||||
}
|
||||
|
||||
private _toSttError(err: unknown, source: string): D3ROError {
|
||||
return err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(
|
||||
ErrorCode.CaptionSTTFailed,
|
||||
`자막 전사 실패(${source}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 실패를 로그와 'failure' 이벤트로 알린다. 리스너가 던져도 호출자에게 새지 않는다. */
|
||||
private _reportFailure(error: D3ROError): void {
|
||||
logger.error(error.message)
|
||||
try {
|
||||
this.emit('failure', error)
|
||||
} catch (err) {
|
||||
logger.warn(`caption failure listener threw: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _hideOverlaySafely(): void {
|
||||
try {
|
||||
hideCaptionOverlay()
|
||||
} catch (err) {
|
||||
logger.warn(`캡션 오버레이 숨기기 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 오디오 소스 어댑터 — 마이크는 AudioCaptureService, 시스템 소리는 렌더러 IPC */
|
||||
private _createAudioSources(): CaptionAudioSources {
|
||||
return new CaptionAudioSources({
|
||||
mic: () => {
|
||||
const capture = getAudioCaptureService()
|
||||
return {
|
||||
onChunk: (listener) => {
|
||||
const handler = (payload: { buffer: Buffer; timestamp: number }): void => listener(payload.buffer)
|
||||
capture.on('audio-data', handler)
|
||||
return () => { capture.off('audio-data', handler) }
|
||||
},
|
||||
start: () => capture.start(),
|
||||
stop: () => capture.stop(),
|
||||
}
|
||||
},
|
||||
system: {
|
||||
requestStart: () => this._sendToMainWindow(IPC_CHANNELS.CAPTION.START_SYSTEM_AUDIO, {}),
|
||||
requestStop: () => this._sendToMainWindow(IPC_CHANNELS.CAPTION.STOP_SYSTEM_AUDIO, {}),
|
||||
},
|
||||
onReleaseError: (stage, err) => {
|
||||
logger.warn(`캡션 오디오 해제 실패(${stage}): ${err instanceof Error ? err.message : String(err)}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 자막은 항상 로컬 엔진으로 인식한다 — 1초마다 다시 인식하므로 클라우드로는 보낼 수 없다. */
|
||||
private async _transcribe(
|
||||
audio: Buffer,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ function sendProgressToRenderer(progress: ChainProgress): void {
|
|||
|
||||
class ChainService {
|
||||
private _cancelRequested = false
|
||||
/** 진행 중인 실행의 취소 신호 (실행마다 새로 만든다) */
|
||||
private _executionAbort: AbortController | null = null
|
||||
|
||||
initialize(): void {
|
||||
if (initialized) return
|
||||
|
|
@ -146,6 +148,11 @@ class ChainService {
|
|||
}
|
||||
|
||||
this._cancelRequested = false
|
||||
// 이 실행만의 취소 신호 — 진행 중인 LLM 단계까지 끊는다(예전엔 전역 cancelGeneration 이 단일 턴 호출을
|
||||
// 멈추지 못해, 응답 없는 프록시에서 체인이 끝없이 기다렸고 다른 기능의 스트림은 함께 끊겼다).
|
||||
this._executionAbort?.abort()
|
||||
const executionAbort = new AbortController()
|
||||
this._executionAbort = executionAbort
|
||||
const startTime = Date.now()
|
||||
const stepResults: Array<{ instructionId: string; output: string; durationMs: number }> = []
|
||||
let previousOutput = inputText
|
||||
|
|
@ -203,6 +210,7 @@ class ChainService {
|
|||
'custom',
|
||||
undefined,
|
||||
invocation.systemPrompt,
|
||||
{ signal: executionAbort.signal },
|
||||
)
|
||||
const stepDuration = Date.now() - stepStart
|
||||
|
||||
|
|
@ -220,6 +228,10 @@ class ChainService {
|
|||
if (error instanceof D3ROError && error.code === ErrorCode.ChainCancelled) {
|
||||
throw error
|
||||
}
|
||||
if (executionAbort.signal.aborted || this._cancelRequested) {
|
||||
logger.info(`Chain execution cancelled during step ${i + 1}/${chain.steps.length}`)
|
||||
throw new D3ROError(ErrorCode.ChainCancelled, 'Chain execution cancelled')
|
||||
}
|
||||
const msg =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
throw new D3ROError(
|
||||
|
|
@ -229,6 +241,7 @@ class ChainService {
|
|||
}
|
||||
}
|
||||
|
||||
if (this._executionAbort === executionAbort) this._executionAbort = null
|
||||
const totalDuration = Date.now() - startTime
|
||||
logger.info(`Chain "${chain.name}" completed in ${totalDuration}ms`)
|
||||
|
||||
|
|
@ -246,13 +259,14 @@ class ChainService {
|
|||
*/
|
||||
cancelExecution(): void {
|
||||
this._cancelRequested = true
|
||||
// LLM 생성도 취소
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
// 이 체인의 LLM 호출만 취소한다 — 다른 기능의 진행 중 호출은 건드리지 않는다
|
||||
this._executionAbort?.abort()
|
||||
logger.info('Chain execution cancel requested')
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._cancelRequested = true
|
||||
this._executionAbort?.abort()
|
||||
logger.info('ChainService disposed')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,13 +17,26 @@ import {
|
|||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet, onConfigChanged } from './ConfigService'
|
||||
import { SUPABASE_URL, SUPABASE_ANON_KEY } from '@d3ro/core/supabase-config'
|
||||
import { openForUser, openLocal, closeCurrent, getCurrentUserId, importLocalModeData } from '../db'
|
||||
import {
|
||||
openForUser,
|
||||
openLocal,
|
||||
closeCurrent,
|
||||
getCurrentUserId,
|
||||
importLocalModeData,
|
||||
LOCAL_USER_ID,
|
||||
} from '../db'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
import { SyncEngine, type SyncStatus } from './sync/SyncEngine'
|
||||
import { SyncAbortedError } from './sync/sync-adapters'
|
||||
import { SyncScheduler } from './sync/sync-scheduler'
|
||||
import { EncryptedFileTokenStore, bindAuthEvents, type SessionTokenStore } from './sync/session-token-store'
|
||||
import {
|
||||
EncryptedFileTokenStore,
|
||||
bindAuthEvents,
|
||||
type SessionTokenStore,
|
||||
type StoredSessionRecord,
|
||||
} from './sync/session-token-store'
|
||||
import { classifySessionRestoreFailure, sessionRestoreRetryDelayMs } from './sync/session-restore-policy'
|
||||
import { openExternalSafe } from '../windows/web-contents-hardening'
|
||||
import { SupabaseSyncRemote } from './sync/supabase-sync-remote'
|
||||
import { enqueueChange, getSyncState, setSyncState } from './sync/sync-outbox'
|
||||
|
|
@ -105,6 +118,15 @@ class CloudSyncService extends EventEmitter {
|
|||
private _pendingSignInUntil: number | null = null
|
||||
private _tokenStore: SessionTokenStore | null = null
|
||||
private _unbindAuthEvents: (() => void) | null = null
|
||||
/**
|
||||
* 로그인된 계정이지만 세션을 아직 복원하지 못한 상태(오프라인으로 시작 등)의 계정 id.
|
||||
* 이 동안 기록은 그 계정 DB 에 쓰이고 outbox 에 쌓였다가, 복원되면 올라간다. 세션이 있으면 null.
|
||||
*/
|
||||
private _offlineUserId: string | null = null
|
||||
private _restoreRetryTimer: NodeJS.Timeout | null = null
|
||||
private _restoreAttempt = 0
|
||||
/** _onSignOut 진행 중 — 자기 signOut() 이 일으킨 SIGNED_OUT 을 다시 처리하지 않게 */
|
||||
private _signingOut = false
|
||||
private readonly _scheduler = new SyncScheduler({
|
||||
flushDelayMs: FLUSH_DEBOUNCE_MS,
|
||||
pullDelayMs: PULL_DEBOUNCE_MS,
|
||||
|
|
@ -144,7 +166,8 @@ class CloudSyncService extends EventEmitter {
|
|||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
}
|
||||
},
|
||||
() => this._onRemoteSignedOut()
|
||||
)
|
||||
|
||||
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
|
||||
|
|
@ -165,39 +188,9 @@ class CloudSyncService extends EventEmitter {
|
|||
})
|
||||
|
||||
// 저장된 refresh token 복원
|
||||
const stored = this._loadStoredRefreshToken()
|
||||
const stored = this._loadStoredSession()
|
||||
if (stored) {
|
||||
try {
|
||||
const { data, error } = await this._client.auth.refreshSession({ refresh_token: stored })
|
||||
if (error) {
|
||||
// U3: 장시간 idle 후 refresh token 소진 시 사용자에게 재인증 필요를
|
||||
// 알리기 위해 error 로깅 승격 + sync-error + auth-changed(user:null) emit.
|
||||
// 기존에는 조용히 토큰만 삭제해서 사용자가 "왜 로그아웃됐지?" 상태로 방치됐음.
|
||||
logger.error(
|
||||
`세션 복원 실패 — 재로그인 필요: ${error.message} (토큰 만료 또는 서버 세션 무효화)`
|
||||
)
|
||||
this._clearStoredRefreshToken()
|
||||
this.emit('sync-error', {
|
||||
error: `Sign-in session expired. Please sign in again. (${error.message})`,
|
||||
})
|
||||
this.emit('auth-changed', { user: null })
|
||||
} else if (data.session) {
|
||||
this._saveRefreshToken(data.session.refresh_token)
|
||||
logger.info(
|
||||
`Restored session for user: ${data.session.user.email ?? data.session.user.id}`
|
||||
)
|
||||
// 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작
|
||||
await this._onAuthenticated(data.session, { reason: 'restore' })
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`세션 복원 예외 — 재로그인 필요: ${message}`)
|
||||
this._clearStoredRefreshToken()
|
||||
this.emit('sync-error', {
|
||||
error: `Sign-in session restore failed. Please sign in again. (${message})`,
|
||||
})
|
||||
this.emit('auth-changed', { user: null })
|
||||
}
|
||||
await this._restoreSession(stored)
|
||||
} else {
|
||||
// 저장된 토큰 없음 → 미인증 상태를 렌더러에 명시 전달
|
||||
this.emit('auth-changed', { user: null })
|
||||
|
|
@ -206,6 +199,141 @@ class CloudSyncService extends EventEmitter {
|
|||
logger.info('CloudSyncService initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 계정 id (없거나 구버전 토큰 파일이면 null). bootstrap 이 세션 복원을 기다리지 않고
|
||||
* 그 계정 DB 를 먼저 열어, 복원 중·오프라인 동안의 기록이 익명 DB 로 새지 않게 한다.
|
||||
*/
|
||||
peekStoredAccountUserId(): string | null {
|
||||
try {
|
||||
return this._loadStoredSession()?.userId ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 세션 복원 (시작 시 + 오프라인 재시도) ─────────────────
|
||||
|
||||
private async _restoreSession(stored: StoredSessionRecord): Promise<void> {
|
||||
const client = this._client
|
||||
if (!client) return
|
||||
try {
|
||||
const { data, error } = await client.auth.refreshSession({ refresh_token: stored.refreshToken })
|
||||
if (this._session) return // 기다리는 동안 사용자가 새로 로그인했다
|
||||
if (error) {
|
||||
this._handleRestoreFailure(error, error.message, stored)
|
||||
return
|
||||
}
|
||||
if (!data.session) {
|
||||
this._handleRestoreFailure(null, 'refreshSession returned no session', stored)
|
||||
return
|
||||
}
|
||||
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
|
||||
logger.info(`Restored session for user: ${data.session.user.email ?? data.session.user.id}`)
|
||||
// 단일 진입점으로 수렴 — DB open + auth-changed emit + Realtime 시작
|
||||
await this._onAuthenticated(data.session, { reason: 'restore' })
|
||||
} catch (err) {
|
||||
if (this._session) return
|
||||
this._handleRestoreFailure(err, err instanceof Error ? err.message : String(err), stored)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 복원 실패 처리. 서버가 토큰을 확실히 거부했을 때만 토큰을 지우고 로그아웃한다.
|
||||
* 네트워크·서버 일시 오류면 토큰을 보관하고, 저장된 계정 DB 에서 오프라인으로 계속 동작하며
|
||||
* 백오프로 다시 시도한다(예전엔 어떤 오류든 토큰을 지워, 오프라인 시작만으로 로그아웃되고
|
||||
* 그 뒤 기록이 익명 DB 에 갇혔다).
|
||||
*/
|
||||
private _handleRestoreFailure(error: unknown, message: string, stored: StoredSessionRecord): void {
|
||||
if (classifySessionRestoreFailure(error) === 'invalid') {
|
||||
// U3: 재인증 필요를 사용자에게 알린다(조용히 토큰만 지우면 "왜 로그아웃됐지?" 상태로 방치된다).
|
||||
logger.error(`세션 복원 실패 — 재로그인 필요: ${message} (토큰 만료 또는 서버 세션 무효화)`)
|
||||
this._cancelRestoreRetry()
|
||||
this._offlineUserId = null
|
||||
this._clearStoredRefreshToken()
|
||||
this._revertToLocalDatabase('restore-invalid')
|
||||
this.emit('sync-error', { error: `Sign-in session expired. Please sign in again. (${message})` })
|
||||
this.emit('auth-changed', { user: null })
|
||||
return
|
||||
}
|
||||
|
||||
logger.warn(`세션 복원 보류(일시 오류) — 토큰 보관, 나중에 다시 시도: ${message}`)
|
||||
const firstFailure = this._restoreAttempt === 0 && this._offlineUserId === null
|
||||
if (stored.userId) this._enterOfflineAccount(stored.userId)
|
||||
this._scheduleRestoreRetry()
|
||||
if (firstFailure) {
|
||||
this.emit('sync-error', {
|
||||
error: `Could not reach the sync server. You are still signed in; sync resumes automatically when the connection is back. (${message})`,
|
||||
})
|
||||
this.emit('auth-changed', { user: null })
|
||||
}
|
||||
}
|
||||
|
||||
/** 세션 없이 저장된 계정 DB 로 동작한다(쓰기는 outbox 에 쌓인다). */
|
||||
private _enterOfflineAccount(userId: string): void {
|
||||
if (getCurrentUserId() !== userId) {
|
||||
try {
|
||||
openForUser(userId)
|
||||
} catch (err) {
|
||||
logger.warn(`Offline account DB open failed (staying in current DB): ${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
this._offlineUserId = userId
|
||||
}
|
||||
|
||||
private _scheduleRestoreRetry(): void {
|
||||
if (this._restoreRetryTimer) return
|
||||
const delay = sessionRestoreRetryDelayMs(this._restoreAttempt)
|
||||
this._restoreAttempt++
|
||||
logger.info(`Session restore retry #${this._restoreAttempt} in ${delay}ms`)
|
||||
this._restoreRetryTimer = setTimeout(() => {
|
||||
this._restoreRetryTimer = null
|
||||
if (this._session) return
|
||||
const stored = this._loadStoredSession()
|
||||
if (!stored) return
|
||||
void this._restoreSession(stored)
|
||||
}, delay)
|
||||
this._restoreRetryTimer.unref?.()
|
||||
}
|
||||
|
||||
private _cancelRestoreRetry(): void {
|
||||
if (this._restoreRetryTimer) {
|
||||
clearTimeout(this._restoreRetryTimer)
|
||||
this._restoreRetryTimer = null
|
||||
}
|
||||
this._restoreAttempt = 0
|
||||
}
|
||||
|
||||
/** 로그인되지 않은 상태로 돌아갈 때 익명 로컬 DB 로 되돌린다(이미 로컬이면 그대로). */
|
||||
private _revertToLocalDatabase(reason: string): void {
|
||||
if (getCurrentUserId() === LOCAL_USER_ID) return
|
||||
try {
|
||||
const { dbPath } = openLocal()
|
||||
logger.info(`[${reason}] Reverted to local DB: ${dbPath}`)
|
||||
} catch (err) {
|
||||
logger.error(`[${reason}] Local DB reopen failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* auth 클라이언트가 세션을 버렸다(SIGNED_OUT) — 다른 기기의 전역 로그아웃, 토큰 폐기 등.
|
||||
* 예전엔 무시해 엔진·Realtime 이 익명 키로 계속 돌며 푸시가 RLS 에 막히고, 5분 뒤 heartbeat 가
|
||||
* "다른 기기에서 연결 해제됨"이라는 틀린 사유로 로그아웃시켰다.
|
||||
*/
|
||||
private _onRemoteSignedOut(): void {
|
||||
if (this._signingOut || !this._session) return
|
||||
logger.warn('Auth session ended outside this app (SIGNED_OUT) — signing out locally')
|
||||
void this._onSignOut({ unregister: false })
|
||||
.then(() => {
|
||||
this.emit('sync-error', {
|
||||
error: 'Your sign-in session has ended (signed out elsewhere or expired). Sign in again to keep syncing.',
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(`Remote sign-out cleanup failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 인증 lifecycle 단일 진입점 ─────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -220,6 +348,8 @@ class CloudSyncService extends EventEmitter {
|
|||
opts: { reason: 'restore' | 'signin' }
|
||||
): Promise<void> {
|
||||
const userId = session.user.id
|
||||
// 로그인(또는 복원) 성공 — 오프라인 재시도는 더 필요 없다.
|
||||
this._cancelRestoreRetry()
|
||||
|
||||
// 0) 이전 계정의 동기화를 먼저 멈춘다 — 새 DB가 열린 뒤 이전 엔진이 그 DB에 쓰지 않게.
|
||||
const previous = this._engine
|
||||
|
|
@ -229,8 +359,10 @@ class CloudSyncService extends EventEmitter {
|
|||
await previous.whenIdle(ENGINE_STOP_TIMEOUT_MS)
|
||||
}
|
||||
this._session = session
|
||||
this._offlineUserId = null
|
||||
|
||||
// 1) 사용자별 DB 열기 (Phase 1 — 사용자별 SQLite 격리)
|
||||
// 이 시점부터 이 계정 DB 의 쓰기는 엔진이 만들어지기 전에도 outbox 에 기록된다(_recordChange).
|
||||
try {
|
||||
const { created, dbPath } = openForUser(userId)
|
||||
logger.info(
|
||||
|
|
@ -239,9 +371,11 @@ class CloudSyncService extends EventEmitter {
|
|||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.error(`[auth:${opts.reason}] DB open failed for ${userId}: ${message}`)
|
||||
// DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut
|
||||
// DB를 열지 못하면 로그인 상태를 유지해서는 안 된다 — 즉시 signOut.
|
||||
// openForUser 는 실패해도 이전 DB 를 닫지 않는다. 그것이 다른 계정의 DB 면 로컬로 되돌린다.
|
||||
this._session = null
|
||||
this._clearStoredRefreshToken()
|
||||
this._revertToLocalDatabase(`auth:${opts.reason}`)
|
||||
this.emit('sync-error', { error: `Database open failed: ${message}` })
|
||||
this.emit('auth-changed', { user: null })
|
||||
return
|
||||
|
|
@ -343,6 +477,20 @@ class CloudSyncService extends EventEmitter {
|
|||
* 그 다음 auth-changed(null) emit, 마지막으로 DB close.
|
||||
*/
|
||||
private async _onSignOut(options: { unregister: boolean } = { unregister: true }): Promise<void> {
|
||||
if (this._signingOut) return
|
||||
this._signingOut = true
|
||||
try {
|
||||
await this._runSignOut(options)
|
||||
} finally {
|
||||
this._signingOut = false
|
||||
}
|
||||
}
|
||||
|
||||
private async _runSignOut(options: { unregister: boolean }): Promise<void> {
|
||||
// 오프라인 복원 대기도 끝낸다 — 로그아웃한 계정의 토큰으로 다시 복원하지 않게.
|
||||
this._cancelRestoreRetry()
|
||||
this._offlineUserId = null
|
||||
|
||||
// 1) 활성 녹음/세션 강제 종료 (DB close 전에 flush 기회 제공)
|
||||
try {
|
||||
const { getVoiceModeService } = await import('./VoiceModeService')
|
||||
|
|
@ -392,10 +540,11 @@ class CloudSyncService extends EventEmitter {
|
|||
await unregisterDesktopDevice(this._client).catch(() => undefined)
|
||||
}
|
||||
|
||||
// 4) Supabase 세션 무효화
|
||||
// 4) Supabase 세션 무효화 — 이 기기의 세션만(scope:'local').
|
||||
// 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 휴대폰·다른 PC 까지 로그아웃시켰다.
|
||||
if (this._client && this._session) {
|
||||
try {
|
||||
await this._client.auth.signOut()
|
||||
await this._client.auth.signOut({ scope: 'local' })
|
||||
} catch (err) {
|
||||
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
|
@ -582,6 +731,11 @@ class CloudSyncService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/** 세션은 없지만 저장된 계정으로 오프라인 동작 중인지 (복원 재시도 대기) */
|
||||
isOfflineAccount(): boolean {
|
||||
return this._session === null && this._offlineUserId !== null
|
||||
}
|
||||
|
||||
getState(): CloudSyncState {
|
||||
const status = this.getSyncStatus()
|
||||
return {
|
||||
|
|
@ -787,7 +941,7 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
this._saveRefreshToken(data.session.refresh_token)
|
||||
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
|
||||
logger.info(`Signed in: ${data.session.user.email ?? data.session.user.id}`)
|
||||
|
||||
await this._onAuthenticated(data.session, { reason: 'signin' })
|
||||
|
|
@ -822,7 +976,7 @@ class CloudSyncService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
this._saveRefreshToken(data.session.refresh_token)
|
||||
this._saveRefreshToken(data.session.refresh_token, data.session.user.id)
|
||||
logger.info(`Signed in (implicit): ${data.session.user.email ?? data.session.user.id}`)
|
||||
|
||||
await this._onAuthenticated(data.session, { reason: 'signin' })
|
||||
|
|
@ -850,8 +1004,14 @@ class CloudSyncService extends EventEmitter {
|
|||
this._recordChange(entity, id, 'delete')
|
||||
}
|
||||
|
||||
/**
|
||||
* 변경을 이 계정 DB 의 outbox 에 기록한다. 엔진이 아직 없어도(로그인 직후 티어 조회 중,
|
||||
* 오프라인 복원 대기 중) 기록한다 — 예전엔 버려져 최초 대조가 끝난 계정에서는 영영 올라가지 않았다.
|
||||
* 엔진이 있을 때만 곧바로 flush 를 예약한다(없으면 엔진 생성 후 최초 동기화가 올린다).
|
||||
*/
|
||||
private _recordChange(entity: SyncEntity, id: string, op: 'upsert' | 'delete'): void {
|
||||
if (!this._session || !this._engine || getCurrentUserId() !== this._session.user.id) return
|
||||
const accountUserId = this._session?.user.id ?? this._offlineUserId
|
||||
if (!accountUserId || getCurrentUserId() !== accountUserId) return
|
||||
try {
|
||||
enqueueChange(entity, id, op)
|
||||
} catch (err) {
|
||||
|
|
@ -859,7 +1019,7 @@ class CloudSyncService extends EventEmitter {
|
|||
logger.warn(`Sync enqueue ${entity}/${id} failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
this._scheduleFlush()
|
||||
if (this._engine && this._session) this._scheduleFlush()
|
||||
}
|
||||
|
||||
private _scheduleFlush(): void {
|
||||
|
|
@ -1029,12 +1189,12 @@ class CloudSyncService extends EventEmitter {
|
|||
return this._tokenStore
|
||||
}
|
||||
|
||||
private _saveRefreshToken(token: string): void {
|
||||
this._tokens().save(token)
|
||||
private _saveRefreshToken(token: string, userId: string): void {
|
||||
this._tokens().save(token, userId)
|
||||
}
|
||||
|
||||
private _loadStoredRefreshToken(): string | null {
|
||||
return this._tokens().load()
|
||||
private _loadStoredSession(): StoredSessionRecord | null {
|
||||
return this._tokens().loadRecord()
|
||||
}
|
||||
|
||||
private _clearStoredRefreshToken(): void {
|
||||
|
|
@ -1044,6 +1204,7 @@ class CloudSyncService extends EventEmitter {
|
|||
/** 테스트 전용: 타이머·auth 구독을 해제한다. */
|
||||
disposeForTests(): void {
|
||||
this._scheduler.stop()
|
||||
this._cancelRestoreRetry()
|
||||
this._unbindAuthEvents?.()
|
||||
this._unbindAuthEvents = null
|
||||
void this._client?.auth.stopAutoRefresh().catch(() => undefined)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ import {
|
|||
} from './account-scope'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { CustomInstruction } from '@d3ro/core/types'
|
||||
import {
|
||||
INSTRUCTION_LIMITS,
|
||||
instructionLimitViolation,
|
||||
validateInstructionDraft,
|
||||
type InstructionLimitField,
|
||||
} from '@d3ro/core/instruction-policy'
|
||||
|
||||
const logger = getLogger('CustomInstructionService')
|
||||
|
||||
|
|
@ -76,41 +82,29 @@ const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' |
|
|||
]
|
||||
|
||||
// ============================================================
|
||||
// 길이 제한 — 서버 custom_instructions CHECK 와 같은 값(마이그레이션 20260821000005)
|
||||
// 서버 쓰기 제약 — 정본은 @d3ro/core/instruction-policy (서버 custom_instructions CHECK 와 같은 값)
|
||||
// 기존 import 경로(동기화 어댑터·테스트)를 위해 다시 내보낸다.
|
||||
// ============================================================
|
||||
|
||||
export const INSTRUCTION_LIMITS = { name: 80, description: 240, prompt: 4000 } as const
|
||||
export { INSTRUCTION_LIMITS, instructionLimitViolation }
|
||||
export type { InstructionLimitField }
|
||||
|
||||
export type InstructionLimitField = keyof typeof INSTRUCTION_LIMITS
|
||||
|
||||
/** Postgres char_length 와 같은 코드 포인트 수 */
|
||||
function charLength(value: string): number {
|
||||
return [...value].length
|
||||
}
|
||||
|
||||
/** 서버 제한을 넘는 첫 필드. 없으면 null. (이름·프롬프트는 앞뒤 공백을 빼고 센다 — 서버 btrim 과 같다) */
|
||||
export function instructionLimitViolation(input: {
|
||||
name?: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
}): InstructionLimitField | null {
|
||||
if (input.name !== undefined && charLength(input.name.trim()) > INSTRUCTION_LIMITS.name) return 'name'
|
||||
if (input.description !== undefined && charLength(input.description.trim()) > INSTRUCTION_LIMITS.description) {
|
||||
return 'description'
|
||||
}
|
||||
if (input.prompt !== undefined && charLength(input.prompt.trim()) > INSTRUCTION_LIMITS.prompt) return 'prompt'
|
||||
return null
|
||||
}
|
||||
|
||||
function assertWithinLimits(input: { name?: string; description?: string; prompt?: string }): void {
|
||||
const field = instructionLimitViolation(input)
|
||||
if (field !== null) {
|
||||
/** 서버 제약(빈 값·길이)을 어기면 저장하지 않는다 — 동기화가 올리지 못해 편집이 다른 기기로 가지 않는다. */
|
||||
function assertValidDraft(input: { name?: string; description?: string; prompt?: string }): void {
|
||||
const problem = validateInstructionDraft(input)
|
||||
if (problem === null) return
|
||||
if (problem.reason === 'empty') {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigInvalidValue,
|
||||
`Instruction ${field} is too long (max ${INSTRUCTION_LIMITS[field]} characters)`,
|
||||
{ field, max: INSTRUCTION_LIMITS[field] }
|
||||
`Instruction ${problem.field} is empty`,
|
||||
{ field: problem.field }
|
||||
)
|
||||
}
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigInvalidValue,
|
||||
`Instruction ${problem.field} is too long (max ${problem.max} characters)`,
|
||||
{ field: problem.field, max: problem.max }
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -199,14 +193,8 @@ class CustomInstructionService {
|
|||
}
|
||||
|
||||
create(input: CreateInput): CustomInstruction {
|
||||
if (!input.name.trim()) {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction name is empty')
|
||||
}
|
||||
if (!input.prompt.trim()) {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Instruction prompt is empty')
|
||||
}
|
||||
// 서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
|
||||
assertWithinLimits(input)
|
||||
// 빈 값·서버 제한을 넘으면 저장하지 않는다 — 동기화가 잘라 올리면 다음 pull이 원본을 덮는다.
|
||||
assertValidDraft(input)
|
||||
const now = Date.now()
|
||||
const instruction: CustomInstruction = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -232,7 +220,8 @@ class CustomInstructionService {
|
|||
if (index === -1) return null
|
||||
|
||||
const existing = instructions[index]
|
||||
assertWithinLimits(existing.isBuiltin ? { prompt: data.prompt } : data)
|
||||
// 생성과 같은 규칙(빈 이름·빈 프롬프트 포함) — 예전엔 편집이 빈 프롬프트를 받아들여 서버 CHECK 에 막혔다.
|
||||
assertValidDraft(existing.isBuiltin ? { prompt: data.prompt } : data)
|
||||
|
||||
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
|
||||
if (existing.isBuiltin) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/services/DictionaryService.ts
|
||||
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
|
||||
|
||||
import { eq, and, like, desc, count, sql } from 'drizzle-orm'
|
||||
import { eq, and, desc, count, sql } from 'drizzle-orm'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { app, dialog } from 'electron'
|
||||
|
|
@ -12,8 +12,11 @@ import { getLogger } from './LoggerService'
|
|||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { containsPattern } from '../db/like-pattern'
|
||||
import {
|
||||
buildDictionaryUpdatePatch,
|
||||
dictionaryIdentityKey,
|
||||
findDictionaryIdentityConflict,
|
||||
isUniqueConstraintViolation,
|
||||
normalizeDictionaryPronunciation,
|
||||
normalizeDictionaryWord
|
||||
|
|
@ -118,6 +121,43 @@ function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
|||
return 'user'
|
||||
}
|
||||
|
||||
type DictionaryDb = Pick<ReturnType<typeof getDatabase>, 'select'>
|
||||
|
||||
/**
|
||||
* 같은 카테고리에 대소문자·앞뒤 공백만 다른 단어가 있으면 그 행. 서버 유일성(lower(btrim(word)))과 같은
|
||||
* 기준으로 로컬 입력 단계에서 거절한다 — 로컬 인덱스는 대소문자를 구분해 그대로 받아들이고, 동기화가
|
||||
* 나중에 사용자의 항목을 서버 행으로 조용히 대체했다. (Unicode 대소문자까지 JS 로 비교한다)
|
||||
*/
|
||||
function findCaseInsensitiveDuplicate(
|
||||
db: DictionaryDb,
|
||||
word: string,
|
||||
category: DictionaryEntry['category'],
|
||||
excludeId?: string
|
||||
): { id: string; word: string } | null {
|
||||
const candidates = db
|
||||
.select({ id: dictionary.id, word: dictionary.word })
|
||||
.from(dictionary)
|
||||
.where(eq(dictionary.category, category))
|
||||
.all()
|
||||
return findDictionaryIdentityConflict(word, candidates, excludeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 가져오기용 한 번 조회 — 대량 행을 JS 로 비교하지 않고 SQL 에서 (lower(trim(word)), category) 로 찾는다.
|
||||
* SQLite lower() 는 ASCII 만 접으므로 비 ASCII 대소문자 변형은 추가·수정 경로(JS 비교)만큼 엄밀하지 않다.
|
||||
*/
|
||||
function findImportDuplicate(
|
||||
db: DictionaryDb,
|
||||
word: string,
|
||||
category: DictionaryEntry['category']
|
||||
): { id: string } | undefined {
|
||||
return db
|
||||
.select({ id: dictionary.id })
|
||||
.from(dictionary)
|
||||
.where(and(sql`lower(trim(${dictionary.word})) = ${dictionaryIdentityKey(word)}`, eq(dictionary.category, category)))
|
||||
.get()
|
||||
}
|
||||
|
||||
class DictionaryService {
|
||||
add(params: DictionaryAddParams): DictionaryEntry {
|
||||
const word = normalizeDictionaryWord(params.word)
|
||||
|
|
@ -128,12 +168,17 @@ class DictionaryService {
|
|||
const db = getDatabase()
|
||||
const now = Date.now()
|
||||
const id = crypto.randomUUID()
|
||||
const category = params.category ?? 'user'
|
||||
const duplicate = findCaseInsensitiveDuplicate(db, word, category)
|
||||
if (duplicate) {
|
||||
throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${word} (existing: ${duplicate.word})`)
|
||||
}
|
||||
|
||||
const entry: NewDictionary = {
|
||||
id,
|
||||
word,
|
||||
pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null,
|
||||
category: params.category ?? 'user',
|
||||
category,
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
createdAt: now,
|
||||
|
|
@ -164,6 +209,15 @@ class DictionaryService {
|
|||
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||
if (!existing) return null
|
||||
|
||||
if (result.patch.word !== undefined || result.patch.category !== undefined) {
|
||||
const nextWord = result.patch.word ?? existing.word
|
||||
const nextCategory = result.patch.category ?? (existing.category as DictionaryEntry['category'])
|
||||
const duplicate = findCaseInsensitiveDuplicate(db, nextWord, nextCategory, existing.id)
|
||||
if (duplicate) {
|
||||
throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${nextWord} (existing: ${duplicate.word})`)
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Partial<NewDictionary> = { ...result.patch, updatedAt: Date.now() }
|
||||
try {
|
||||
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
|
||||
|
|
@ -217,19 +271,20 @@ class DictionaryService {
|
|||
search(params: DictionarySearchParams): DictionaryPage {
|
||||
const db = getDatabase()
|
||||
const { query, page, pageSize } = params
|
||||
const pattern = `%${query}%`
|
||||
// 검색어의 %·_ 는 글자 그대로 찾는다(와일드카드로 해석하지 않는다)
|
||||
const condition = containsPattern(query).match(dictionary.word)
|
||||
|
||||
const totalResult = db
|
||||
.select({ count: count() })
|
||||
.from(dictionary)
|
||||
.where(like(dictionary.word, pattern))
|
||||
.where(condition)
|
||||
.get()
|
||||
const total = totalResult?.count ?? 0
|
||||
|
||||
const entries = db
|
||||
.select()
|
||||
.from(dictionary)
|
||||
.where(like(dictionary.word, pattern))
|
||||
.where(condition)
|
||||
.orderBy(desc(dictionary.usageCount))
|
||||
.limit(pageSize)
|
||||
.offset(page * pageSize)
|
||||
|
|
@ -365,11 +420,8 @@ class DictionaryService {
|
|||
continue
|
||||
}
|
||||
const category = normalizeCategory(pickString(row, ['category']))
|
||||
const existing = tx
|
||||
.select({ id: dictionary.id })
|
||||
.from(dictionary)
|
||||
.where(and(eq(dictionary.word, word), eq(dictionary.category, category)))
|
||||
.get()
|
||||
// 대소문자만 다른 단어도 같은 단어로 본다(서버 유일성과 같은 기준)
|
||||
const existing = findImportDuplicate(tx, word, category)
|
||||
if (existing) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { spawn } from 'child_process'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
|
@ -54,16 +54,31 @@ const defaultFfmpegLocator: FfmpegLocator = {
|
|||
provision: () => getRuntimeProvisioner().ensure('ffmpeg'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 전사 작업 한 건의 상태. 싱글톤 필드에 두면 취소된 작업의 늦은 정리(임시 폴더 삭제·idle 복귀)가
|
||||
* 다음 작업을 덮었다 — 작업마다 따로 갖는다.
|
||||
*/
|
||||
interface TranscriptionJob {
|
||||
readonly id: string
|
||||
readonly tempDir: string
|
||||
cancelled: boolean
|
||||
/** 이 작업이 띄운 ffmpeg 프로세스 — 취소하면 모두 종료한다 */
|
||||
readonly processes: Set<ChildProcess>
|
||||
}
|
||||
|
||||
function cancelledError(): D3ROError {
|
||||
return new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
|
||||
class FileTranscriptionService extends EventEmitter {
|
||||
constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) {
|
||||
super()
|
||||
}
|
||||
|
||||
private _state: FileTranscriptionState = 'idle'
|
||||
private _jobId: string | null = null
|
||||
private _cancelled = false
|
||||
/** 현재 작업. 취소하면 곧바로 null 이 되고, 남은 정리는 그 작업 객체만 건드린다. */
|
||||
private _job: TranscriptionJob | null = null
|
||||
private _progress: FileTranscriptionProgress | null = null
|
||||
private _tempDir: string | null = null
|
||||
|
||||
get state(): FileTranscriptionState {
|
||||
return this._state
|
||||
|
|
@ -72,7 +87,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
getStateInfo(): FileTranscriptionStateInfo {
|
||||
return {
|
||||
state: this._state,
|
||||
jobId: this._jobId,
|
||||
jobId: this._job?.id ?? null,
|
||||
progress: this._progress,
|
||||
}
|
||||
}
|
||||
|
|
@ -110,45 +125,49 @@ class FileTranscriptionService extends EventEmitter {
|
|||
if (err instanceof D3ROError) throw err
|
||||
}
|
||||
|
||||
this._jobId = crypto.randomUUID()
|
||||
this._cancelled = false
|
||||
this._tempDir = path.join(app.getPath('temp'), `d3ro-ft-${this._jobId}`)
|
||||
fs.mkdirSync(this._tempDir, { recursive: true })
|
||||
const jobId = crypto.randomUUID()
|
||||
const job: TranscriptionJob = {
|
||||
id: jobId,
|
||||
tempDir: path.join(app.getPath('temp'), `d3ro-ft-${jobId}`),
|
||||
cancelled: false,
|
||||
processes: new Set(),
|
||||
}
|
||||
this._job = job
|
||||
this._progress = null
|
||||
fs.mkdirSync(job.tempDir, { recursive: true })
|
||||
|
||||
const startTime = Date.now()
|
||||
const fileName = path.basename(filePath)
|
||||
const throwIfCancelled = (): void => {
|
||||
if (job.cancelled) throw cancelledError()
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환
|
||||
this._setState('converting')
|
||||
this._setJobState(job, 'converting')
|
||||
const ffmpegPath = await this._ensureFfmpeg()
|
||||
if (this._cancelled) {
|
||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
const wavPath = path.join(this._tempDir, 'audio.wav')
|
||||
await this._convertToWav(ffmpegPath, filePath, wavPath)
|
||||
|
||||
if (this._cancelled) {
|
||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
throwIfCancelled()
|
||||
const wavPath = path.join(job.tempDir, 'audio.wav')
|
||||
await this._convertToWav(job, ffmpegPath, filePath, wavPath)
|
||||
throwIfCancelled()
|
||||
|
||||
// 오디오 길이 확인
|
||||
const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath)
|
||||
const totalDurationSec = await this._probeDuration(job, ffmpegPath, wavPath)
|
||||
throwIfCancelled()
|
||||
const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC)
|
||||
|
||||
// Phase 2: 청크별 STT
|
||||
this._setState('transcribing')
|
||||
this._setJobState(job, 'transcribing')
|
||||
const allSegments: FileTranscriptionSegment[] = []
|
||||
const allTexts: string[] = []
|
||||
let previousContext = ''
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
if (this._cancelled) {
|
||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
throwIfCancelled()
|
||||
|
||||
const startSec = i * CHUNK_DURATION_SEC
|
||||
const chunkBuffer = await this._extractChunk(ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC)
|
||||
const chunkBuffer = await this._extractChunk(job, ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC)
|
||||
throwIfCancelled()
|
||||
|
||||
const sttService = getSTTManager()
|
||||
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||
|
|
@ -158,6 +177,8 @@ class FileTranscriptionService extends EventEmitter {
|
|||
initialPrompt: previousContext || undefined,
|
||||
vadFilter: true,
|
||||
})
|
||||
// 인식 중에 취소됐으면 결과를 버린다(STT 호출 자체는 끊을 수 없다)
|
||||
throwIfCancelled()
|
||||
|
||||
if (result.text && result.text.trim().length > 0) {
|
||||
allTexts.push(result.text.trim())
|
||||
|
|
@ -173,23 +194,24 @@ class FileTranscriptionService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
this._progress = {
|
||||
jobId: this._jobId!,
|
||||
const progress: FileTranscriptionProgress = {
|
||||
jobId,
|
||||
currentChunk: i + 1,
|
||||
totalChunks,
|
||||
percent: Math.round(((i + 1) / totalChunks) * 100),
|
||||
currentText: result.text?.trim() ?? '',
|
||||
}
|
||||
if (this._job === job) this._progress = progress
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, this._progress)
|
||||
this.emit('progress', this._progress)
|
||||
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, progress)
|
||||
this.emit('progress', progress)
|
||||
}
|
||||
|
||||
const fullText = allTexts.join(' ')
|
||||
const processingTimeMs = Date.now() - startTime
|
||||
|
||||
const resultData: FileTranscriptionResult = {
|
||||
jobId: this._jobId!,
|
||||
jobId,
|
||||
filePath,
|
||||
fileName,
|
||||
fullText,
|
||||
|
|
@ -225,39 +247,50 @@ class FileTranscriptionService extends EventEmitter {
|
|||
logger.warn('Failed to save file transcription to history:', err)
|
||||
}
|
||||
|
||||
this._setState('completed')
|
||||
this._setJobState(job, 'completed')
|
||||
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.COMPLETE, resultData)
|
||||
this.emit('complete', resultData)
|
||||
|
||||
return resultData
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled) {
|
||||
this._setState('idle')
|
||||
throw err
|
||||
// 취소된 작업의 실패(ffmpeg 종료 등)는 모두 취소로 본다
|
||||
if (job.cancelled || (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled)) {
|
||||
throw cancelledError()
|
||||
}
|
||||
this._setState('error')
|
||||
this._setJobState(job, 'error')
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.ERROR, { message: errorMsg })
|
||||
this.emit('error', err)
|
||||
// 'error' 는 리스너가 없으면 던져진다 — 실패 알림은 'failure' 로 보낸다
|
||||
this.emit('failure', err)
|
||||
throw err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(ErrorCode.FileTranscriptionChunkFailed, errorMsg)
|
||||
} finally {
|
||||
this._cleanup()
|
||||
// 완료/에러 후 idle로 복귀
|
||||
setTimeout(() => {
|
||||
this._state = 'idle'
|
||||
this._jobId = null
|
||||
this._killProcesses(job)
|
||||
this._cleanup(job)
|
||||
// 완료/에러/취소 후 곧바로 idle — 예전엔 1초 뒤 타이머가 작업 확인 없이 idle 로 돌려, 그사이 시작한
|
||||
// 다음 작업의 상태·jobId·진행률을 지웠다. 이 작업이 아직 현재 작업일 때만 되돌린다.
|
||||
if (this._job === job) {
|
||||
this._job = null
|
||||
this._progress = null
|
||||
}, 1000)
|
||||
this._state = 'idle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 작업을 취소한다 — 실행 중인 ffmpeg 를 곧바로 종료하고, 서비스는 즉시 idle 이 되어 새 작업을
|
||||
* 받을 수 있다(취소된 작업의 남은 정리는 그 작업의 임시 폴더만 건드린다).
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this._state !== 'idle') {
|
||||
this._cancelled = true
|
||||
logger.info(`File transcription cancelled: ${this._jobId}`)
|
||||
}
|
||||
const job = this._job
|
||||
if (!job) return
|
||||
job.cancelled = true
|
||||
this._killProcesses(job)
|
||||
this._job = null
|
||||
this._progress = null
|
||||
this._setState('idle')
|
||||
logger.info(`File transcription cancelled: ${job.id}`)
|
||||
}
|
||||
|
||||
private _setState(state: FileTranscriptionState): void {
|
||||
|
|
@ -265,6 +298,37 @@ class FileTranscriptionService extends EventEmitter {
|
|||
this.emit('state-changed', state)
|
||||
}
|
||||
|
||||
/** 현재 작업의 상태만 바꾼다 — 취소된 작업이 뒤늦게 상태를 덮지 않게 */
|
||||
private _setJobState(job: TranscriptionJob, state: FileTranscriptionState): void {
|
||||
if (this._job !== job) return
|
||||
this._setState(state)
|
||||
}
|
||||
|
||||
/** 작업의 ffmpeg 프로세스를 추적한다(취소 시 종료). 이미 취소된 작업이면 곧바로 종료한다. */
|
||||
private _trackProcess(job: TranscriptionJob, proc: ChildProcess): ChildProcess {
|
||||
job.processes.add(proc)
|
||||
const forget = (): void => {
|
||||
job.processes.delete(proc)
|
||||
}
|
||||
proc.on('close', forget)
|
||||
proc.on('error', forget)
|
||||
if (job.cancelled) this._killProcess(proc)
|
||||
return proc
|
||||
}
|
||||
|
||||
private _killProcesses(job: TranscriptionJob): void {
|
||||
for (const proc of job.processes) this._killProcess(proc)
|
||||
job.processes.clear()
|
||||
}
|
||||
|
||||
private _killProcess(proc: ChildProcess): void {
|
||||
try {
|
||||
if (proc.exitCode === null && !proc.killed) proc.kill()
|
||||
} catch (err) {
|
||||
logger.warn(`ffmpeg kill failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _sendToRenderer(channel: string, data: unknown): void {
|
||||
try {
|
||||
const mainWindow = getMainWindow()
|
||||
|
|
@ -298,13 +362,16 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
|
||||
*/
|
||||
private _convertToWav(ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
|
||||
private _convertToWav(job: TranscriptionJob, ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = buildConvertToWavArgs(inputPath, outputPath)
|
||||
|
||||
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -329,13 +396,16 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
|
||||
*/
|
||||
private _probeDuration(ffmpegPath: string, wavPath: string): Promise<number> {
|
||||
private _probeDuration(job: TranscriptionJob, ffmpegPath: string, wavPath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나,
|
||||
// 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출
|
||||
const args = buildProbeDurationArgs(wavPath)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -362,6 +432,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
* WAV 파일에서 특정 구간을 PCM16 Buffer로 추출
|
||||
*/
|
||||
private _extractChunk(
|
||||
job: TranscriptionJob,
|
||||
ffmpegPath: string,
|
||||
wavPath: string,
|
||||
startSec: number,
|
||||
|
|
@ -370,7 +441,10 @@ class FileTranscriptionService extends EventEmitter {
|
|||
return new Promise((resolve, reject) => {
|
||||
const args = buildExtractChunkArgs(wavPath, startSec, durationSec)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
|
|
@ -391,20 +465,21 @@ class FileTranscriptionService extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
private _cleanup(): void {
|
||||
if (this._tempDir && fs.existsSync(this._tempDir)) {
|
||||
/** 이 작업의 임시 폴더만 지운다 */
|
||||
private _cleanup(job: TranscriptionJob): void {
|
||||
if (fs.existsSync(job.tempDir)) {
|
||||
try {
|
||||
fs.rmSync(this._tempDir, { recursive: true, force: true })
|
||||
fs.rmSync(job.tempDir, { recursive: true, force: true })
|
||||
} catch (err) {
|
||||
logger.warn('Failed to cleanup temp dir:', err)
|
||||
}
|
||||
this._tempDir = null
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
const job = this._job
|
||||
this.cancel()
|
||||
this._cleanup()
|
||||
if (job) this._cleanup(job)
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/main/services/HistoryService.ts
|
||||
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
|
||||
|
||||
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
|
||||
import { eq, desc, and, or, sql, count } from 'drizzle-orm'
|
||||
import { getDatabase } from '../db'
|
||||
import { history, stats } from '../db/schema'
|
||||
import type { History, NewHistory } from '../db/schema'
|
||||
|
|
@ -10,6 +10,7 @@ import { getCloudSyncService } from './CloudSyncService'
|
|||
import { getInputTelemetryService } from './InputTelemetryService'
|
||||
import { deleteHistoryLocally } from './history-deletion'
|
||||
import { computeStreakDays } from './history-stats'
|
||||
import { containsPattern } from '../db/like-pattern'
|
||||
import type {
|
||||
HistoryEntry,
|
||||
HistoryQueryParams,
|
||||
|
|
@ -96,10 +97,14 @@ class HistoryService {
|
|||
search(params: HistorySearchParams): HistoryPage {
|
||||
const db = getDatabase()
|
||||
const { query, page, pageSize } = params
|
||||
const pattern = `%${query}%`
|
||||
|
||||
const conditions = and(
|
||||
like(history.originalText, pattern)
|
||||
// 카드에 보이는 텍스트(다듬은 문장·번역문·제목·요약)로도 찾는다 — 예전엔 원문만 봤다.
|
||||
// 검색어의 %·_ 는 글자 그대로 찾는다(와일드카드로 해석하지 않는다).
|
||||
const pattern = containsPattern(query)
|
||||
const conditions = or(
|
||||
pattern.match(history.originalText),
|
||||
pattern.match(history.polishedText),
|
||||
pattern.match(history.title),
|
||||
pattern.match(history.summaryText)
|
||||
)
|
||||
|
||||
const totalResult = db
|
||||
|
|
@ -253,9 +258,10 @@ class HistoryService {
|
|||
if (!text || text.length < 10) return null
|
||||
|
||||
try {
|
||||
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
||||
const llm = getPremiumLLMService()
|
||||
const result = await llm.generate(
|
||||
// 백엔드 선택은 게이트웨이가 한다 — 예전엔 Premium 에 고정돼, 로컬을 고른 로그인 사용자의 받아쓰기마다
|
||||
// 원문이 클라우드로 가고 주간 쿼터를 썼다. 로컬이면 로컬 LLM 만 쓴다(없으면 제목 없이 넘어간다).
|
||||
const { getLlmGateway } = await import('./llm/LlmGateway')
|
||||
const result = await getLlmGateway().generate(
|
||||
text.slice(0, 2000),
|
||||
{
|
||||
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',
|
||||
|
|
|
|||
|
|
@ -3,15 +3,22 @@
|
|||
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { BrowserWindow, Notification, dialog } from 'electron'
|
||||
import { BrowserWindow, Notification, app, dialog } from 'electron'
|
||||
import fs from 'fs'
|
||||
import { dirname as pathDirname } from 'path'
|
||||
import { eq, desc, sql } from 'drizzle-orm'
|
||||
import { dirname as pathDirname, join as pathJoin } from 'path'
|
||||
import { eq, desc, sql, inArray } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { getDatabase } from '../db'
|
||||
import { onAccountScopeChanged } from './account-scope'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { MeetingRecordingSession, type RecordingSnapshot } from './meeting/MeetingRecordingSession'
|
||||
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
|
||||
import { selectTranscriptExcerpts } from '@d3ro/core/meeting-llm-input'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import type { MeetingChatHistoryTurn, MeetingLiveSession } from '@d3ro/core/meeting-live'
|
||||
import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
|
|
@ -46,7 +53,6 @@ import type {
|
|||
MeetingGenerateDocParams,
|
||||
MeetingExportFormat,
|
||||
MeetingDocGeneratingProgress,
|
||||
CaptionSegment,
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
const logger = getLogger('MeetingModeService')
|
||||
|
|
@ -57,46 +63,82 @@ const REWRITE_CHUNK_CHARS = 1500
|
|||
const REWRITE_CHUNK_MAX_TOKENS = 2048
|
||||
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
|
||||
const MAX_CHAT_HISTORY_MESSAGES = 20
|
||||
/** 녹음 중 전사를 DB 에 남기는 주기 — 비정상 종료 시 잃는 양의 상한 */
|
||||
const TRANSCRIPT_CHECKPOINT_MS = 15_000
|
||||
/** 중단된 녹음(전사 없음)의 오류 메시지 — 저장용(UI 는 status 로 표시) */
|
||||
const INTERRUPTED_MESSAGE = 'Recording was interrupted before it was saved'
|
||||
|
||||
/**
|
||||
* 회의 기능이 쓰는 LLM 포트 — 백엔드 선택(local/online, 폴백)은 LlmGateway 한 곳이 정한다.
|
||||
* 문서 생성·요약 입력은 이 한도(프록시 메시지 한도) 안으로 맞춘다.
|
||||
*/
|
||||
type MeetingLlm = {
|
||||
generate: (
|
||||
text: string,
|
||||
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
|
||||
) => Promise<{ text: string; model?: string }>
|
||||
chatStream: (
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: { temperature?: number },
|
||||
) => AsyncGenerator<string, string>
|
||||
}
|
||||
|
||||
/** 문서 생성 사용자 메시지의 머리말(LLM 지시문) */
|
||||
const DOCUMENT_REQUEST_PREFIX = '다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n'
|
||||
const CONDENSED_NOTE = '(회의가 길어 구간별로 정리한 내용입니다)\n'
|
||||
/** 채팅 system 프롬프트 머리말 여유 — 지시문·발췌 안내가 들어갈 자리 */
|
||||
const CHAT_SYSTEM_OVERHEAD_CHARS = 600
|
||||
|
||||
/** 회의 녹음 WAV 경로 (화자 구분·동기화·삭제가 같은 규칙을 쓴다) */
|
||||
function meetingAudioFilePathSync(sessionId: string): string {
|
||||
return pathJoin(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
||||
}
|
||||
|
||||
async function meetingAudioFilePath(sessionId: string): Promise<string> {
|
||||
const { app } = await import('electron')
|
||||
const path = await import('path')
|
||||
return path.join(app.getPath('userData'), 'meeting-audio', `${sessionId}.wav`)
|
||||
return meetingAudioFilePathSync(sessionId)
|
||||
}
|
||||
|
||||
let isShowingMeetingSaveDialog = false
|
||||
|
||||
class MeetingModeService extends EventEmitter {
|
||||
private _state: MeetingModeState = 'idle'
|
||||
private _sessionId: string | null = null
|
||||
private _sessionStartedAt: number | null = null
|
||||
private _segments: CaptionSegment[] = []
|
||||
private _memos: MeetingMemo[] = []
|
||||
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
|
||||
private _audioDataHandler: ((payload: { buffer: Buffer; timestamp: number }) => void) | null = null
|
||||
/**
|
||||
* 진행 중인 녹음. 녹음 한 건의 세그먼트·메모·오디오·구독은 이 객체가 단독으로 소유한다.
|
||||
* 후처리가 끝날 때까지 남아 있어 getStateInfo 가 세션 id 를 계속 보고한다(실시간 편집 반영용).
|
||||
*/
|
||||
private _current: MeetingRecordingSession | null = null
|
||||
/** 진행 중인 정지 — 동시에 온 정지 요청(두 번 클릭, 로그아웃)은 이 promise 를 함께 기다린다 */
|
||||
private _stopInFlight: Promise<void> | null = null
|
||||
private _audioLevelTimer: ReturnType<typeof setInterval> | null = null
|
||||
private _lastRms = 0
|
||||
private _checkpointTimer: ReturnType<typeof setInterval> | null = null
|
||||
private _checkpointedSegments = 0
|
||||
/** CaptionService session-saved 방지 플래그 */
|
||||
private _meetingModeActive = false
|
||||
|
||||
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
||||
private _audioBuffersForFile: Buffer[] = []
|
||||
|
||||
// Phase 15: Meeting AI Chat — 세션마다 따로 보관한다(회의 A 의 질의응답이 B 로 새지 않게)
|
||||
private _chatHistories = new Map<string, Array<{ role: 'user' | 'assistant'; content: string }>>()
|
||||
private _chatAbortController: AbortController | null = null
|
||||
/** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */
|
||||
private _chatAbortControllers = new Map<string, AbortController>()
|
||||
private readonly _unsubscribeScope: () => void
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// 이 서비스가 내는 'error' 는 알림용이다 — 듣는 쪽이 없어도 throw 되지 않게 기본 수신자를 둔다
|
||||
// (EventEmitter 는 리스너 없는 'error' 를 던져 후처리 catch 를 깨뜨렸다).
|
||||
this.on('error', () => { /* default sink */ })
|
||||
// 계정 DB 가 바뀔 때마다(로그인·로그아웃) 그 DB 에 남은 중단된 녹음을 정리한다.
|
||||
this._unsubscribeScope = onAccountScopeChanged(() => {
|
||||
try {
|
||||
this.recoverInterruptedSessions()
|
||||
} catch (err) {
|
||||
logger.warn(`중단된 회의 정리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 테스트 전용: 타이머·구독을 푼다 */
|
||||
disposeForTests(): void {
|
||||
this._stopTimers()
|
||||
this._unsubscribeScope()
|
||||
this.cancelMeetingChat()
|
||||
this.removeAllListeners()
|
||||
}
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
||||
|
|
@ -105,12 +147,30 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
getStateInfo(): MeetingModeStateInfo {
|
||||
const session = this._current
|
||||
return {
|
||||
state: this._state,
|
||||
sessionId: this._sessionId,
|
||||
elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
|
||||
memoCount: this._memos.length,
|
||||
segmentCount: this._segments.length,
|
||||
sessionId: session?.id ?? null,
|
||||
elapsedMs: session ? session.elapsedMs() : 0,
|
||||
memoCount: session?.memos.length ?? 0,
|
||||
segmentCount: session?.segments.length ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중인 녹음의 세그먼트·메모 사본. 녹음 화면을 다시 열었을 때(페이지 이동 후 복귀) 그동안의
|
||||
* 실시간 전사와 메모를 복원한다. 녹음 중이 아니면 null.
|
||||
*/
|
||||
getLiveSession(): MeetingLiveSession | null {
|
||||
const session = this._current
|
||||
if (!session) return null
|
||||
return {
|
||||
sessionId: session.id,
|
||||
state: this._state,
|
||||
startedAt: session.startedAt,
|
||||
elapsedMs: session.elapsedMs(),
|
||||
segments: session.segments.map((segment) => ({ ...segment })),
|
||||
memos: session.memos.map((memo) => ({ ...memo })),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,26 +181,23 @@ class MeetingModeService extends EventEmitter {
|
|||
/** 활성 세션, 자막, 오디오 캡처 상태를 강제로 완전 초기화 */
|
||||
async forceReset(): Promise<void> {
|
||||
logger.warn('MeetingModeService: forceReset 실행')
|
||||
if (this._audioLevelTimer) {
|
||||
clearInterval(this._audioLevelTimer)
|
||||
this._audioLevelTimer = null
|
||||
}
|
||||
if (this._audioDataHandler) {
|
||||
try {
|
||||
const { getAudioCaptureService } = await import('./AudioCaptureService')
|
||||
getAudioCaptureService().off('audio-data', this._audioDataHandler)
|
||||
} catch { /* noop */ }
|
||||
this._audioDataHandler = null
|
||||
this._stopTimers()
|
||||
const session = this._current
|
||||
if (session) {
|
||||
session.detach()
|
||||
// 아직 녹음 중이던 세션은 지금까지의 전사를 남기고 닫는다(행이 'recording' 으로 굳지 않게).
|
||||
if (!session.isStopping) {
|
||||
try {
|
||||
this._persistSnapshot(session.checkpoint({ includeAudio: true }), { interrupted: true })
|
||||
} catch (err) {
|
||||
logger.warn(`forceReset 전사 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const { getCaptionService } = await import('./CaptionService')
|
||||
const captionService = getCaptionService()
|
||||
// 강제 정지(소유자 무시) 후 구독을 푼다
|
||||
await captionService.stop().catch(() => null)
|
||||
if (this._segmentHandler) {
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
}
|
||||
// 강제 정지(소유자 무시)
|
||||
await getCaptionService().stop().catch(() => null)
|
||||
} catch { /* noop */ }
|
||||
|
||||
try {
|
||||
|
|
@ -148,11 +205,8 @@ class MeetingModeService extends EventEmitter {
|
|||
await getAudioCaptureService().stop().catch(() => {})
|
||||
} catch { /* noop */ }
|
||||
|
||||
this._meetingModeActive = false
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
this._segments = []
|
||||
this._memos = []
|
||||
this._stopInFlight = null
|
||||
this._resetSession()
|
||||
this._setState('idle')
|
||||
this._sendStateToRenderer()
|
||||
}
|
||||
|
|
@ -192,11 +246,6 @@ class MeetingModeService extends EventEmitter {
|
|||
const sessionId = crypto.randomUUID()
|
||||
const now = Date.now()
|
||||
|
||||
this._sessionId = sessionId
|
||||
this._sessionStartedAt = now
|
||||
this._segments = []
|
||||
this._memos = []
|
||||
|
||||
// DB에 세션 생성
|
||||
const db = getDatabase()
|
||||
db.insert(meetingSessions).values({
|
||||
|
|
@ -207,13 +256,41 @@ class MeetingModeService extends EventEmitter {
|
|||
updatedAt: now,
|
||||
}).run()
|
||||
|
||||
// CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
|
||||
this._segmentHandler = (segment: CaptionSegment) => {
|
||||
this._segments.push(segment)
|
||||
this.emit('segment', segment)
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
|
||||
}
|
||||
captionService.on('segment', this._segmentHandler)
|
||||
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
const session = new MeetingRecordingSession(
|
||||
sessionId,
|
||||
now,
|
||||
{
|
||||
caption: {
|
||||
onSegment: (listener) => {
|
||||
captionService.on('segment', listener)
|
||||
return () => { captionService.off('segment', listener) }
|
||||
},
|
||||
// 회의가 소유한 세션만 멈춘다. 다른 소유자면 false(요약 없음).
|
||||
stop: () => captionService.stop('meeting'),
|
||||
},
|
||||
audio: {
|
||||
onChunk: (listener) => {
|
||||
const handler = (payload: { buffer: Buffer; timestamp: number }): void => listener(payload.buffer)
|
||||
audioCaptureService.on('audio-data', handler)
|
||||
return () => { audioCaptureService.off('audio-data', handler) }
|
||||
},
|
||||
},
|
||||
onSegment: (segment) => {
|
||||
this.emit('segment', segment)
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logger.error(`자막 정지 실패 — 지금까지의 전사로 저장한다: ${err instanceof Error ? err.message : String(err)}`)
|
||||
},
|
||||
},
|
||||
calculateRMS,
|
||||
)
|
||||
this._current = session
|
||||
this._checkpointedSegments = 0
|
||||
// 세그먼트 구독은 자막 시작 전에 건다 — 첫 문장을 놓치지 않게.
|
||||
session.attachCaption()
|
||||
|
||||
try {
|
||||
// 회의 모드가 소유한 자막 세션: 마이크 고정(전역 설정은 건드리지 않음), 오버레이 없음
|
||||
|
|
@ -226,11 +303,9 @@ class MeetingModeService extends EventEmitter {
|
|||
})
|
||||
} catch (err) {
|
||||
// 시작 실패 시 복원
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
session.detach()
|
||||
if (this._current === session) this._current = null
|
||||
this._meetingModeActive = false
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
|
|
@ -243,20 +318,13 @@ class MeetingModeService extends EventEmitter {
|
|||
throw err
|
||||
}
|
||||
|
||||
// 오디오 레벨 모니터링 시작
|
||||
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
this._audioBuffersForFile = []
|
||||
this._audioDataHandler = (payload: { buffer: Buffer; timestamp: number }) => {
|
||||
this._lastRms = calculateRMS(payload.buffer)
|
||||
// Phase 15.5-2: 오디오 버퍼 축적 (WAV 저장용)
|
||||
this._audioBuffersForFile.push(Buffer.from(payload.buffer))
|
||||
}
|
||||
audioCaptureService.on('audio-data', this._audioDataHandler)
|
||||
|
||||
// 오디오 레벨 모니터링 + WAV 저장용 버퍼 (Phase 15.5-2)
|
||||
session.attachAudio()
|
||||
this._audioLevelTimer = setInterval(() => {
|
||||
this._sendToRenderer('meetingMode:audioLevel', { level: this._lastRms })
|
||||
this._sendToRenderer('meetingMode:audioLevel', { level: session.lastRms })
|
||||
}, 100)
|
||||
// 전사를 주기적으로 DB 에 남긴다 — 앱이 비정상 종료돼도 회의 전체가 사라지지 않게.
|
||||
this._checkpointTimer = setInterval(() => this._checkpointTranscript(), TRANSCRIPT_CHECKPOINT_MS)
|
||||
|
||||
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
|
||||
this._sendStateToRenderer()
|
||||
|
|
@ -272,20 +340,15 @@ class MeetingModeService extends EventEmitter {
|
|||
// ── 메모 추가 ──
|
||||
|
||||
async addMemo(content: string): Promise<MeetingMemo> {
|
||||
if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
|
||||
const session = this._current
|
||||
if (this._state !== 'recording' || !session) {
|
||||
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
||||
}
|
||||
|
||||
const memo: MeetingMemo = {
|
||||
id: crypto.randomUUID(),
|
||||
sessionId: this._sessionId,
|
||||
content,
|
||||
timestampMs: Date.now() - this._sessionStartedAt,
|
||||
createdAt: Date.now(),
|
||||
const memo = session.addMemo(crypto.randomUUID(), content)
|
||||
if (!memo) {
|
||||
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
||||
}
|
||||
|
||||
this._memos.push(memo)
|
||||
|
||||
// DB 저장
|
||||
const db = getDatabase()
|
||||
db.insert(meetingMemos).values({
|
||||
|
|
@ -307,69 +370,113 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
// ── 녹음 종료 ──
|
||||
|
||||
/**
|
||||
* 녹음을 멈추고 전사를 저장한다. 정지가 진행 중이면 그 정지를 함께 기다린다 — 정지를 두 번 눌러도
|
||||
* (또는 로그아웃이 동시에 멈춰도) 정지·저장·완료 알림은 한 번만 일어난다.
|
||||
*/
|
||||
async stopRecording(): Promise<void> {
|
||||
if (this._state !== 'recording') {
|
||||
if (this._stopInFlight) return this._stopInFlight
|
||||
const session = this._current
|
||||
if (this._state !== 'recording' || !session) {
|
||||
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
|
||||
}
|
||||
|
||||
const sessionId = this._sessionId!
|
||||
const now = Date.now()
|
||||
|
||||
// 오디오 레벨 모니터링 정리
|
||||
if (this._audioLevelTimer) {
|
||||
clearInterval(this._audioLevelTimer)
|
||||
this._audioLevelTimer = null
|
||||
}
|
||||
if (this._audioDataHandler) {
|
||||
const { getAudioCaptureService } = await import('./AudioCaptureService')
|
||||
getAudioCaptureService().off('audio-data', this._audioDataHandler)
|
||||
this._audioDataHandler = null
|
||||
const run = this._stopSession(session)
|
||||
this._stopInFlight = run
|
||||
try {
|
||||
await run
|
||||
} finally {
|
||||
if (this._stopInFlight === run) this._stopInFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
// CaptionService 종료 — 먼저 멈추고(말하던 마지막 문장이 flush 되어 'segment' 로 온다)
|
||||
// 그다음 구독을 푼다. 예전엔 구독을 먼저 풀어 정지 직전 문장이 회의 전사에서 빠졌다.
|
||||
const { getCaptionService } = await import('./CaptionService')
|
||||
const captionService = getCaptionService()
|
||||
private async _stopSession(session: MeetingRecordingSession): Promise<void> {
|
||||
this._stopTimers()
|
||||
// 정지(마지막 문장 flush)가 끝날 때까지 '처리 중'으로 보인다 — 정지 버튼이 사라져 다시 누를 수 없다.
|
||||
this._setState('processing')
|
||||
this._sendStateToRenderer()
|
||||
|
||||
const captionSummary = await captionService.stop('meeting')
|
||||
const snapshot = await session.stop()
|
||||
|
||||
if (this._segmentHandler) {
|
||||
captionService.off('segment', this._segmentHandler)
|
||||
this._segmentHandler = null
|
||||
}
|
||||
|
||||
// 혹시 구독 밖에서 확정된 세그먼트가 있으면 요약에서 보충한다
|
||||
if (captionSummary) {
|
||||
const known = new Set(this._segments.map((seg) => seg.id))
|
||||
for (const segment of captionSummary.segments) {
|
||||
if (!known.has(segment.id)) this._segments.push(segment)
|
||||
}
|
||||
this._segments.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
|
||||
// 세션 종료 시간 기록
|
||||
// 세션 종료 시간 기록 — 불변 스냅샷에서 읽는다(인스턴스 필드는 다른 호출이 바꿀 수 있다)
|
||||
const db = getDatabase()
|
||||
db.update(meetingSessions).set({
|
||||
endedAt: now,
|
||||
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
endedAt: snapshot.endedAt,
|
||||
durationMs: snapshot.durationMs,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, snapshot.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', snapshot.id)
|
||||
|
||||
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
|
||||
logger.info(`회의 녹음 종료: sessionId=${snapshot.id}, segments=${snapshot.segments.length}`)
|
||||
|
||||
// 후처리 시작 (비동기)
|
||||
this._runPostProcessing().catch((err: unknown) => {
|
||||
logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
await this._runPostProcessing(snapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 종료(ShutdownRegistry)용. 녹음 중이면 지금까지의 전사·녹음을 곧바로 저장하고(자막 flush 를
|
||||
* 기다리지 않는다 — 종료 예산 안에 끝나야 한다), 자막·마이크를 멈춘다. 알림·렌더러 전송은 하지 않는다.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
this._stopTimers()
|
||||
const session = this._current
|
||||
if (!session) return
|
||||
if (!session.isStopping) {
|
||||
try {
|
||||
this._persistSnapshot(session.checkpoint({ includeAudio: true }), { interrupted: false })
|
||||
logger.info(`종료 중 회의 저장: sessionId=${session.id}`)
|
||||
} catch (err) {
|
||||
logger.error(`종료 중 회의 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
session.detach()
|
||||
try {
|
||||
const { getCaptionService } = await import('./CaptionService')
|
||||
await getCaptionService().stop().catch(() => null)
|
||||
} catch { /* noop */ }
|
||||
this._resetSession()
|
||||
this._state = 'idle'
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 DB 에 'recording'/'processing' 으로 남은 회의(앱 강제 종료·정전 등)를 닫는다.
|
||||
* 저장된 전사가 있으면 'completed', 없으면 'error'. 진행 중인 녹음은 건드리지 않는다.
|
||||
* @returns 정리한 회의 수
|
||||
*/
|
||||
recoverInterruptedSessions(): number {
|
||||
let db: ReturnType<typeof getDatabase>
|
||||
try {
|
||||
db = getDatabase()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
const liveId = this._current?.id ?? null
|
||||
const rows = db.select().from(meetingSessions).where(inArray(meetingSessions.status, ['recording', 'processing'])).all()
|
||||
let recovered = 0
|
||||
for (const row of rows) {
|
||||
if (row.id === liveId) continue
|
||||
const now = Date.now()
|
||||
const endedAt = row.endedAt ?? row.updatedAt ?? now
|
||||
const hasTranscript = !!row.rawTranscript && row.rawTranscript.trim().length > 0
|
||||
db.update(meetingSessions).set({
|
||||
status: hasTranscript ? 'completed' : 'error',
|
||||
endedAt,
|
||||
durationMs: row.durationMs ?? Math.max(0, endedAt - row.startedAt),
|
||||
errorMessage: hasTranscript ? row.errorMessage : (row.errorMessage ?? INTERRUPTED_MESSAGE),
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, row.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', row.id)
|
||||
recovered++
|
||||
}
|
||||
if (recovered > 0) logger.warn(`중단된 회의 ${recovered}건을 정리했다`)
|
||||
return recovered
|
||||
}
|
||||
|
||||
// ── 후처리 파이프라인 ──
|
||||
|
||||
private async _runPostProcessing(): Promise<void> {
|
||||
/** 불변 스냅샷만 읽는다 — 서비스 필드(다른 호출이 초기화할 수 있다)에 기대지 않는다. */
|
||||
private async _runPostProcessing(snapshot: RecordingSnapshot): Promise<void> {
|
||||
this._setState('processing')
|
||||
this._sendStateToRenderer()
|
||||
const sessionId = this._sessionId!
|
||||
const sessionId = snapshot.id
|
||||
const db = getDatabase()
|
||||
|
||||
try {
|
||||
|
|
@ -381,41 +488,22 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
// Step 1: 전사 텍스트 합산
|
||||
this._sendProgress(sessionId, 'merging', 40)
|
||||
// 세그먼트 시각은 epoch 이다 — 회의 시작 기준 상대 시각으로 바꿔 [MM:SS] 로 쓴다
|
||||
const startedAt = this._sessionStartedAt ?? this._segments[0]?.timestamp ?? 0
|
||||
const rawTranscript = buildTranscriptFromSegments(this._segments, startedAt)
|
||||
|
||||
// Step 2: DB 저장 (rawTranscript + completed)
|
||||
// Step 2: DB 저장 (rawTranscript + completed) + Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
||||
this._sendProgress(sessionId, 'saving', 80)
|
||||
const now = Date.now()
|
||||
db.update(meetingSessions).set({
|
||||
status: 'completed',
|
||||
rawTranscript,
|
||||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
|
||||
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
|
||||
if (this._audioBuffersForFile.length > 0) {
|
||||
try {
|
||||
const wavPath = await meetingAudioFilePath(sessionId)
|
||||
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
|
||||
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
||||
getCloudSyncService().pushOne('meeting_audio', sessionId)
|
||||
logger.info(`회의 오디오 저장: ${wavPath}`)
|
||||
} catch (err) {
|
||||
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
this._audioBuffersForFile = []
|
||||
this._persistSnapshot(snapshot, { interrupted: false })
|
||||
|
||||
// Step 3: 알림
|
||||
this._sendProgress(sessionId, 'notifying', 95)
|
||||
new Notification({
|
||||
title: '녹음 완료',
|
||||
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
|
||||
}).show()
|
||||
// 알림 실패는 저장된 회의를 실패로 만들지 않는다
|
||||
try {
|
||||
new Notification({
|
||||
title: '녹음 완료',
|
||||
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
|
||||
}).show()
|
||||
} catch (err) {
|
||||
logger.warn(`완료 알림 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
// 완료 이벤트
|
||||
this._sendProgress(sessionId, 'notifying', 100)
|
||||
|
|
@ -425,21 +513,21 @@ class MeetingModeService extends EventEmitter {
|
|||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
|
||||
}
|
||||
|
||||
this._resetSession()
|
||||
this._setState('idle')
|
||||
this._sendStateToRenderer()
|
||||
this._finishSession(snapshot.id)
|
||||
logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`)
|
||||
} catch (err) {
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
try {
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
} catch (dbErr) {
|
||||
logger.error(`후처리 오류 기록 실패: ${dbErr instanceof Error ? dbErr.message : String(dbErr)}`)
|
||||
}
|
||||
|
||||
this._resetSession()
|
||||
this._setState('idle')
|
||||
this._sendStateToRenderer()
|
||||
this._finishSession(snapshot.id)
|
||||
|
||||
const d3roErr = new D3ROError(
|
||||
ErrorCode.MeetingProcessingFailed,
|
||||
|
|
@ -454,6 +542,76 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스냅샷(전사·종료 시각·녹음)을 DB·파일에 쓴다. 동기 — 앱 종료 예산 안에서도 끝난다.
|
||||
* interrupted 면 전사가 없을 때 'error' 로 닫는다.
|
||||
*/
|
||||
private _persistSnapshot(snapshot: RecordingSnapshot, options: { interrupted: boolean }): void {
|
||||
const db = getDatabase()
|
||||
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
|
||||
const hasTranscript = rawTranscript.trim().length > 0
|
||||
const status = options.interrupted && !hasTranscript ? 'error' : 'completed'
|
||||
db.update(meetingSessions).set({
|
||||
status,
|
||||
rawTranscript,
|
||||
endedAt: snapshot.endedAt,
|
||||
durationMs: snapshot.durationMs,
|
||||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
...(status === 'error' ? { errorMessage: INTERRUPTED_MESSAGE } : {}),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, snapshot.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', snapshot.id)
|
||||
|
||||
if (snapshot.pcm.length > 0) {
|
||||
try {
|
||||
const wavPath = meetingAudioFilePathSync(snapshot.id)
|
||||
fs.mkdirSync(pathDirname(wavPath), { recursive: true })
|
||||
this._saveWav(wavPath, snapshot.pcm)
|
||||
getCloudSyncService().pushOne('meeting_audio', snapshot.id)
|
||||
logger.info(`회의 오디오 저장: ${wavPath}`)
|
||||
} catch (err) {
|
||||
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 녹음 중 주기 저장 — 새 세그먼트가 있을 때만 rawTranscript 를 갱신한다(푸시는 정지 때). */
|
||||
private _checkpointTranscript(): void {
|
||||
const session = this._current
|
||||
if (!session || session.isStopping || this._state !== 'recording') return
|
||||
if (session.segments.length === this._checkpointedSegments) return
|
||||
try {
|
||||
const snapshot = session.checkpoint({ includeAudio: false })
|
||||
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
|
||||
getDatabase().update(meetingSessions).set({
|
||||
rawTranscript,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, snapshot.id)).run()
|
||||
this._checkpointedSegments = snapshot.segments.length
|
||||
} catch (err) {
|
||||
logger.warn(`전사 중간 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _stopTimers(): void {
|
||||
if (this._audioLevelTimer) {
|
||||
clearInterval(this._audioLevelTimer)
|
||||
this._audioLevelTimer = null
|
||||
}
|
||||
if (this._checkpointTimer) {
|
||||
clearInterval(this._checkpointTimer)
|
||||
this._checkpointTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 이 세션의 후처리가 끝났다 — 그사이 새 녹음이 시작됐으면 그것은 건드리지 않는다. */
|
||||
private _finishSession(sessionId: string): void {
|
||||
if (this._current && this._current.id !== sessionId) return
|
||||
this._resetSession()
|
||||
this._setState('idle')
|
||||
this._sendStateToRenderer()
|
||||
}
|
||||
|
||||
// ── 세션 조회 ──
|
||||
|
||||
getSessions(page: number, pageSize: number): MeetingSessionPage {
|
||||
|
|
@ -518,6 +676,7 @@ class MeetingModeService extends EventEmitter {
|
|||
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
|
||||
// 서버는 회의 삭제가 메모·문서로 cascade 된다 — 회의 하나만 지우면 된다.
|
||||
getCloudSyncService().pushDelete('meetings', sessionId)
|
||||
this.cancelMeetingChat(sessionId)
|
||||
this._chatHistories.delete(sessionId)
|
||||
// 로컬 녹음(WAV)도 지운다 — 행이 사라지면 어떤 경로도 이 파일을 다시 찾지 않는다(best-effort)
|
||||
void this._deleteMeetingAudio(sessionId)
|
||||
|
|
@ -604,12 +763,11 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
private _resetSession(): void {
|
||||
this._sessionId = null
|
||||
this._sessionStartedAt = null
|
||||
this._segments = []
|
||||
this._memos = []
|
||||
this._stopTimers()
|
||||
this._current?.detach()
|
||||
this._current = null
|
||||
this._checkpointedSegments = 0
|
||||
this._meetingModeActive = false
|
||||
this._audioBuffersForFile = []
|
||||
}
|
||||
|
||||
/** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */
|
||||
|
|
@ -657,27 +815,17 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* 회의 문서/폴리시/채팅/화자추정 LLM.
|
||||
* llmBackend==='local' 이면 LocalLLM(Ollama). online 이면 Premium, 불가 시 Local 폴백.
|
||||
* 회의 문서/폴리시/화자추정 LLM — LlmGateway(백엔드 선택 정책의 정본)에 위임한다.
|
||||
* llmBackend==='local' 이면 로컬, online 이면 Premium(불가·실패 시 로컬 폴백).
|
||||
*/
|
||||
private async _resolveMeetingLlm(): Promise<MeetingLlm> {
|
||||
const backend = configGet('llmBackend')
|
||||
if (backend === 'online') {
|
||||
try {
|
||||
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
||||
const premium = getPremiumLLMService()
|
||||
if (premium.isAvailable()) {
|
||||
return premium
|
||||
}
|
||||
logger.warn('Premium LLM unavailable for meeting — falling back to local')
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Premium LLM init failed for meeting: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
const gateway = getLlmGateway()
|
||||
return {
|
||||
generate: (text, options) =>
|
||||
gateway.generate(text, options, {
|
||||
onFallback: (reason) => logger.warn(`Meeting LLM fallback: ${reason}`),
|
||||
}),
|
||||
}
|
||||
const { getLocalLLMService } = await import('./LocalLLMService')
|
||||
return getLocalLLMService()
|
||||
}
|
||||
|
||||
// ── Phase 14.5: 전사 수정 ──
|
||||
|
|
@ -746,8 +894,17 @@ class MeetingModeService extends EventEmitter {
|
|||
const memoSection = formattedMemos
|
||||
? `\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}`
|
||||
: ''
|
||||
// 긴 회의는 한 번에 보내면 프록시 한도(8,000자)를 넘어 거부된다 — 구간별로 줄인 뒤 보낸다.
|
||||
const budget =
|
||||
LLM_PROXY_CHAT_LIMITS.maxMessageChars - DOCUMENT_REQUEST_PREFIX.length - CONDENSED_NOTE.length - memoSection.length
|
||||
const fitted = await condenseTranscriptToBudget(llmService, transcript, budget)
|
||||
if (fitted.condensed) {
|
||||
logger.info(`문서 생성: 전사를 ${fitted.rounds}회 구간 정리해 한도 안에 맞췄다 (sessionId=${params.sessionId})`)
|
||||
}
|
||||
sendProgress(60)
|
||||
const transcriptSection = fitted.condensed ? `${CONDENSED_NOTE}${fitted.text}` : fitted.text
|
||||
const result = await llmService.generate(
|
||||
`다음 회의 전사록과 참석자 메모를 분석하여 문서를 작성해주세요:\n\n## 전사록\n${transcript}${memoSection}`,
|
||||
`${DOCUMENT_REQUEST_PREFIX}${transcriptSection}${memoSection}`,
|
||||
{
|
||||
systemPrompt,
|
||||
temperature: 0.3,
|
||||
|
|
@ -1092,13 +1249,33 @@ class MeetingModeService extends EventEmitter {
|
|||
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
|
||||
const systemPrompt = `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
|
||||
// system 은 한도(8,000자)에서 앞부분만 남기고 잘린다 — 예전엔 긴 회의의 뒷부분이 조용히 빠져
|
||||
// 그 부분 질문에 "확인되지 않습니다"라고 답했다. 한도를 넘으면 질문과 관련된 구간을 골라 넣고,
|
||||
// 일부만 봤다는 사실을 모델이 답에 밝히게 한다.
|
||||
const excerpt = selectTranscriptExcerpts(
|
||||
transcript,
|
||||
userMessage,
|
||||
LLM_PROXY_CHAT_LIMITS.maxSystemChars - CHAT_SYSTEM_OVERHEAD_CHARS,
|
||||
)
|
||||
const systemPrompt = excerpt.complete
|
||||
? `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
|
||||
아래 회의 전사 내용을 참고하여 사용자의 질문에 정확하게 답변하세요.
|
||||
전사 내용에 없는 것은 "전사 내용에서 확인되지 않습니다"라고 답하세요.
|
||||
|
||||
## 회의 전사
|
||||
${transcript}`
|
||||
${excerpt.text}`
|
||||
: `당신은 회의 내용을 분석하는 AI 어시스턴트입니다.
|
||||
회의가 길어 전체 ${excerpt.totalChunks}개 구간 중 질문과 관련된 ${excerpt.includedChunks}개 구간의 발췌만 아래에 있습니다.
|
||||
발췌를 참고하여 사용자의 질문에 정확하게 답변하세요.
|
||||
발췌에 없는 것은 "제공된 전사 발췌에서는 확인되지 않습니다(회의가 길어 일부 구간만 참고했습니다)"라고 답하세요.
|
||||
|
||||
## 회의 전사 (발췌)
|
||||
${excerpt.text}`
|
||||
if (!excerpt.complete) {
|
||||
logger.info(
|
||||
`회의 채팅: 전사 ${excerpt.totalChunks}개 구간 중 ${excerpt.includedChunks}개만 발췌해 보냈다 (sessionId=${sessionId})`,
|
||||
)
|
||||
}
|
||||
|
||||
// 이 회의의 히스토리만 쓴다. 사용자 턴은 답변이 끝났을 때 함께 기록한다
|
||||
// (실패·취소 시 짝 없는 사용자 턴이 남지 않게).
|
||||
|
|
@ -1109,16 +1286,19 @@ ${transcript}`
|
|||
{ role: 'user' as const, content: userMessage },
|
||||
]
|
||||
|
||||
// 다른 채팅이 흐르고 있으면 끊는다 — 컨트롤러는 하나뿐이다
|
||||
this._chatAbortController?.abort()
|
||||
// 같은 회의에서 흐르던 채팅만 끊는다 — 다른 회의의 채팅은 건드리지 않는다
|
||||
this._chatAbortControllers.get(sessionId)?.abort()
|
||||
const controller = new AbortController()
|
||||
this._chatAbortController = controller
|
||||
this._chatAbortControllers.set(sessionId, controller)
|
||||
const signal = controller.signal
|
||||
|
||||
let fullResponse = ''
|
||||
try {
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
const generator = llm.chatStream(messages, { temperature: 0.5 })
|
||||
const { generator } = await getLlmGateway().openChatStream(
|
||||
messages,
|
||||
{ signal, local: { temperature: 0.5 } },
|
||||
{ onFallback: (reason) => logger.warn(`Meeting chat fallback: ${reason}`) },
|
||||
)
|
||||
|
||||
for await (const token of generator) {
|
||||
if (signal.aborted) break
|
||||
|
|
@ -1148,15 +1328,19 @@ ${transcript}`
|
|||
})
|
||||
}
|
||||
} finally {
|
||||
if (this._chatAbortController === controller) this._chatAbortController = null
|
||||
if (this._chatAbortControllers.get(sessionId) === controller) this._chatAbortControllers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
cancelMeetingChat(): void {
|
||||
if (this._chatAbortController) {
|
||||
this._chatAbortController.abort()
|
||||
this._chatAbortController = null
|
||||
/** 회의 채팅을 멈춘다. sessionId 를 주면 그 회의만, 생략하면 전부. */
|
||||
cancelMeetingChat(sessionId?: string): void {
|
||||
if (sessionId) {
|
||||
this._chatAbortControllers.get(sessionId)?.abort()
|
||||
this._chatAbortControllers.delete(sessionId)
|
||||
return
|
||||
}
|
||||
for (const controller of this._chatAbortControllers.values()) controller.abort()
|
||||
this._chatAbortControllers.clear()
|
||||
}
|
||||
|
||||
/** 회의 채팅 히스토리를 비운다. sessionId 를 주면 그 회의만, 생략하면 전부. */
|
||||
|
|
@ -1168,8 +1352,8 @@ ${transcript}`
|
|||
}
|
||||
}
|
||||
|
||||
/** 테스트/진단용: 특정 회의의 채팅 히스토리 사본 */
|
||||
getMeetingChatHistory(sessionId: string): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
/** 특정 회의의 채팅 히스토리 사본 — 채팅 패널을 다시 열 때 main 이 기억하는 대화를 보여 준다 */
|
||||
getMeetingChatHistory(sessionId: string): MeetingChatHistoryTurn[] {
|
||||
return [...(this._chatHistories.get(sessionId) ?? [])]
|
||||
}
|
||||
|
||||
|
|
@ -1314,6 +1498,8 @@ export function getMeetingModeService(): MeetingModeService {
|
|||
}
|
||||
|
||||
export function resetMeetingModeServiceForTests(): void {
|
||||
if (instance) instance.removeAllListeners()
|
||||
if (instance) instance.disposeForTests()
|
||||
instance = null
|
||||
}
|
||||
|
||||
export type { MeetingModeService }
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@ import fs from 'fs'
|
|||
import { app, dialog } from 'electron'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
import { getDatabase } from '../db'
|
||||
import { history } from '../db/schema'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
|
|
@ -76,9 +78,22 @@ class MeetingSummaryService extends EventEmitter {
|
|||
this._sendProgress(historyId, 'generating')
|
||||
|
||||
try {
|
||||
// LLM 요약 생성
|
||||
const llmService = getPremiumLLMService()
|
||||
const result = await llmService.generate(transcript, {
|
||||
// LLM 요약 생성 — 백엔드 선택은 게이트웨이가 한다(llmBackend='local' 이면 로컬 Ollama).
|
||||
// 예전엔 설정과 무관하게 Premium 에 고정돼, 로컬을 고른 로그인 사용자의 자막 전사가 클라우드로 가고
|
||||
// 쿼터를 썼으며, 로그인하지 않은 사용자는 요약을 전혀 받지 못했다.
|
||||
const gateway = getLlmGateway()
|
||||
const llm = {
|
||||
generate: (text: string, options?: { systemPrompt?: string; temperature?: number; maxTokens?: number }) =>
|
||||
gateway.generate(text, options, {
|
||||
onFallback: (reason) => logger.warn(`Meeting summary LLM fallback: ${reason}`),
|
||||
}),
|
||||
}
|
||||
// 긴 전사는 한 메시지 한도(8,000자)를 넘어 거부된다 — 구간별로 정리한 뒤 요약한다.
|
||||
const fitted = await condenseTranscriptToBudget(llm, transcript, LLM_PROXY_CHAT_LIMITS.maxMessageChars)
|
||||
if (fitted.condensed) {
|
||||
logger.info(`Summary input condensed in ${fitted.rounds} round(s): ${historyId}`)
|
||||
}
|
||||
const result = await llm.generate(fitted.text, {
|
||||
systemPrompt: MEETING_SUMMARY_PROMPT,
|
||||
temperature: 0.3,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { memoTags, history } from '../db/schema'
|
|||
import type { MemoTagRow } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { memoTagKey, normalizeMemoTag } from './sync/memo-tag-sync'
|
||||
import { MEMO_TAG_MAX_LENGTH, memoTagKey, normalizeMemoTag, validateMemoTag } from '@d3ro/core/memo-tag-policy'
|
||||
import { buildMemoExportFileName } from './memo/memo-export-file-name'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
|
|
@ -47,9 +47,18 @@ class MemoService {
|
|||
addTag(historyId: string, tag: string): MemoTag {
|
||||
const db = getDatabase()
|
||||
const normalizedTag = normalizeMemoTag(tag)
|
||||
if (!normalizedTag) {
|
||||
const problem = validateMemoTag(tag)
|
||||
if (problem === 'empty') {
|
||||
throw new D3ROError(ErrorCode.ConfigInvalidValue, 'Memo tag is empty')
|
||||
}
|
||||
if (problem === 'too-long') {
|
||||
// 서버 CHECK(1~80자)를 넘는 태그는 저장하지 않는다 — 올라가지 못하고 outbox 에 영구 보관됐다.
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigInvalidValue,
|
||||
`Memo tag is too long (max ${MEMO_TAG_MAX_LENGTH} characters)`,
|
||||
{ field: 'tag', max: MEMO_TAG_MAX_LENGTH }
|
||||
)
|
||||
}
|
||||
|
||||
// 중복 검사
|
||||
const existing = db
|
||||
|
|
|
|||
|
|
@ -50,9 +50,14 @@ class OnlineLLMService extends EventEmitter {
|
|||
const token = this._ensureAuth()
|
||||
const apiUrl = normalizeLoopbackUrl(configGet('onlineApiUrl') ?? 'http://127.0.0.1:5000')
|
||||
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
|
||||
// cancelGeneration() 이 실제로 이 요청을 끊도록 컨트롤러를 등록한다(예전엔 한 번도 할당되지 않아 no-op).
|
||||
this._abortController?.abort()
|
||||
const controller = new AbortController()
|
||||
this._abortController = controller
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/llm/generate`, {
|
||||
signal: controller.signal,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -89,10 +94,15 @@ class OnlineLLMService extends EventEmitter {
|
|||
return data.text.trim()
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError) throw err
|
||||
if (controller.signal.aborted) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'Online LLM request cancelled')
|
||||
}
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMProcessingFailed,
|
||||
`온라인 API 서버 연결 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
} finally {
|
||||
if (this._abortController === controller) this._abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { parseAnthropicSSE, AnthropicStreamError } from '../utils/sse-parser'
|
|||
import {
|
||||
fitChatRequest,
|
||||
LLM_PROXY_CHAT_LIMITS,
|
||||
singleTurnLimitViolation,
|
||||
toChatRequest,
|
||||
type ChatRequest,
|
||||
type ChatStreamOptions,
|
||||
|
|
@ -70,6 +71,27 @@ interface PremiumCall {
|
|||
close: () => void
|
||||
}
|
||||
|
||||
/** 단일 턴 호출(processText/generate)의 호출 단위 옵션 — 취소·기한은 이 호출에만 적용된다. */
|
||||
export interface PremiumCallOptions {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 턴 요청이 llm-proxy 한도를 넘으면 보내지 않고 LLMPromptTooLong 을 던진다.
|
||||
* 서버는 400('Invalid content length')으로 거부하므로 보내 봐야 실패와 왕복 시간만 쓴다.
|
||||
*/
|
||||
export function assertSingleTurnWithinProxyLimits(input: { system?: string; message: string }): void {
|
||||
const violation = singleTurnLimitViolation(input, LLM_PROXY_CHAT_LIMITS)
|
||||
if (violation === null) return
|
||||
const max = violation === 'system' ? LLM_PROXY_CHAT_LIMITS.maxSystemChars : LLM_PROXY_CHAT_LIMITS.maxMessageChars
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMPromptTooLong,
|
||||
`Premium LLM ${violation} is too long (max ${max} characters)`,
|
||||
{ field: violation, max },
|
||||
)
|
||||
}
|
||||
|
||||
/** llm-proxy 출력 토큰 상한 (llm-contract MAX_OUTPUT_TOKENS) */
|
||||
const PROXY_MAX_OUTPUT_TOKENS = 4096
|
||||
const DEFAULT_CHAT_MAX_TOKENS = 2048
|
||||
|
|
@ -213,10 +235,12 @@ class PremiumLLMService extends EventEmitter {
|
|||
action: LLMAction,
|
||||
targetLanguage?: string,
|
||||
customPrompt?: string,
|
||||
options?: PremiumCallOptions,
|
||||
): Promise<string> {
|
||||
this._ensureAuth()
|
||||
|
||||
const systemPrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
|
||||
assertSingleTurnWithinProxyLimits({ system: systemPrompt, message: text })
|
||||
|
||||
const body: LlmProxyRequest = {
|
||||
messages: [{ role: 'user', content: text }],
|
||||
|
|
@ -226,7 +250,7 @@ class PremiumLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await this._invokeProxy(body)
|
||||
const response = await this._invokeSingleTurn(body, options)
|
||||
// Claude 응답 → text 추출
|
||||
const firstBlock = response.content?.[0]
|
||||
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
|
||||
|
|
@ -323,15 +347,19 @@ class PremiumLLMService extends EventEmitter {
|
|||
*/
|
||||
async generate(
|
||||
text: string,
|
||||
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number },
|
||||
options?: { systemPrompt?: string; temperature?: number; maxTokens?: number } & PremiumCallOptions,
|
||||
): Promise<{ text: string }> {
|
||||
this._ensureAuth()
|
||||
const response = await this._invokeProxy({
|
||||
messages: [{ role: 'user', content: text }],
|
||||
system: options?.systemPrompt,
|
||||
max_tokens: options?.maxTokens ?? 2048,
|
||||
stream: false,
|
||||
})
|
||||
assertSingleTurnWithinProxyLimits({ system: options?.systemPrompt, message: text })
|
||||
const response = await this._invokeSingleTurn(
|
||||
{
|
||||
messages: [{ role: 'user', content: text }],
|
||||
system: options?.systemPrompt,
|
||||
max_tokens: options?.maxTokens ?? 2048,
|
||||
stream: false,
|
||||
},
|
||||
options,
|
||||
)
|
||||
const firstBlock = response.content?.[0]
|
||||
if (!firstBlock || firstBlock.type !== 'text' || !firstBlock.text.trim()) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Premium LLM generate returned empty content')
|
||||
|
|
@ -356,6 +384,24 @@ class PremiumLLMService extends EventEmitter {
|
|||
|
||||
// ── 내부: Edge Function 호출 ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 단일 턴 호출도 호출 단위 AbortController·기한을 갖는다 — 예전엔 _activeCalls 에 등록되지 않아
|
||||
* cancelGeneration() 도 호출자의 signal 도 진행 중인 요청을 멈추지 못했다(응답 없는 프록시에서 체인이 멈춤).
|
||||
*/
|
||||
private async _invokeSingleTurn(body: LlmProxyRequest, options?: PremiumCallOptions): Promise<ClaudeMessageResponse> {
|
||||
const call = this._beginCall(options?.signal, resolveTimeoutMs(options?.timeoutMs))
|
||||
try {
|
||||
if (call.controller.signal.aborted) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'Premium LLM generation cancelled')
|
||||
}
|
||||
return await this._invokeProxy(body, call.controller.signal)
|
||||
} catch (err) {
|
||||
throw this._toChatError(err, call)
|
||||
} finally {
|
||||
call.close()
|
||||
}
|
||||
}
|
||||
|
||||
private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> {
|
||||
const cloud = getCloudSyncService()
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getPremiumLLMService } from './PremiumLLMService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getAudioCaptureService } from './AudioCaptureService'
|
||||
import { getTTSPlaybackService } from './TTSPlaybackService'
|
||||
|
|
@ -37,6 +36,12 @@ class VoiceConversationService extends EventEmitter {
|
|||
private _audioBuffers: Buffer[] = []
|
||||
private _audioListenerBound = false
|
||||
private _audioLevelListenerBound = false
|
||||
/**
|
||||
* 이 서비스가 AudioCaptureService.start() 로 잡은 마이크가 있는가.
|
||||
* 캡처는 참조 카운트라 start/stop 짝이 맞아야 한다 — 예전엔 텍스트 턴이 멈추지 않은 채 다시 start 해
|
||||
* 카운트가 쌓였고, 세션을 끝내도 마이크(SoX)가 계속 열려 있었다.
|
||||
*/
|
||||
private _micHeld = false
|
||||
private _responseAbortController: AbortController | null = null
|
||||
|
||||
get state(): ConversationState {
|
||||
|
|
@ -106,9 +111,10 @@ class VoiceConversationService extends EventEmitter {
|
|||
if (!this._isActive) return
|
||||
|
||||
this._stopListening()
|
||||
// 이 대화의 응답만 멈춘다 — signal 이 LLM 호출까지 전달된다(전역 cancelGeneration 은 다른 기능의
|
||||
// 진행 중 호출까지 끊었다).
|
||||
this._responseAbortController?.abort()
|
||||
getTTSPlaybackService().stop()
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
|
||||
this._isActive = false
|
||||
this._setState('idle')
|
||||
|
|
@ -131,11 +137,9 @@ class VoiceConversationService extends EventEmitter {
|
|||
*/
|
||||
cancelResponse(): void {
|
||||
this._responseAbortController?.abort()
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
getTTSPlaybackService().stop()
|
||||
if (this._isActive && this._state !== 'listening') {
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
this._resumeListening()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -168,10 +172,14 @@ class VoiceConversationService extends EventEmitter {
|
|||
this._audioLevelListenerBound = true
|
||||
}
|
||||
|
||||
audioService.start().catch((err) => {
|
||||
logger.error('Failed to start audio capture for conversation:', err)
|
||||
this._emitError('stt', 'Failed to start microphone')
|
||||
})
|
||||
if (!this._micHeld) {
|
||||
this._micHeld = true
|
||||
audioService.start().catch((err) => {
|
||||
this._micHeld = false
|
||||
logger.error('Failed to start audio capture for conversation:', err)
|
||||
this._emitError('stt', 'Failed to start microphone')
|
||||
})
|
||||
}
|
||||
|
||||
// 녹음 시작 사운드 (fire-and-forget)
|
||||
getSoundEffectService().play('recording-start')
|
||||
|
|
@ -187,10 +195,20 @@ class VoiceConversationService extends EventEmitter {
|
|||
audioService.off('audio-level', this._onAudioLevel)
|
||||
this._audioLevelListenerBound = false
|
||||
}
|
||||
audioService.stop().catch(() => { /* ignore */ })
|
||||
if (this._micHeld) {
|
||||
this._micHeld = false
|
||||
audioService.stop().catch(() => { /* ignore */ })
|
||||
}
|
||||
this._audioBuffers = []
|
||||
}
|
||||
|
||||
/** 세션이 살아 있으면 listening 으로 돌아가 다시 듣는다. */
|
||||
private _resumeListening(): void {
|
||||
if (!this._isActive) return
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
}
|
||||
|
||||
private _onAudioData = (payload: { buffer: Buffer }): void => {
|
||||
if (this._state !== 'listening') return
|
||||
this._audioBuffers.push(payload.buffer)
|
||||
|
|
@ -229,36 +247,37 @@ class VoiceConversationService extends EventEmitter {
|
|||
// Bug 13: 너무 짧으면 조용히 listening 복귀 대신 에러 피드백.
|
||||
const minBytes = 16000 * 2 * 0.5
|
||||
if (audioBuffer.length < minBytes) {
|
||||
// 먼저 듣기 상태로 돌린 뒤 알린다 — 알림이 실패해도 세션이 'thinking' 에 굳지 않게.
|
||||
this._resumeListening()
|
||||
this._emitError('stt', 'No speech detected. Please speak and try again.')
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
return
|
||||
}
|
||||
|
||||
let result: { text: string }
|
||||
try {
|
||||
// STT
|
||||
const sttService = getLocalSTTService()
|
||||
const language = (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||
const result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
|
||||
result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
|
||||
|
||||
if (!this._isActive) return
|
||||
|
||||
if (!result.text || result.text.trim().length === 0) {
|
||||
// Bug 13: VAD가 전체 오디오를 무음 판정한 경우에도 사용자 피드백.
|
||||
this._resumeListening()
|
||||
this._emitError('stt', 'No speech detected. Check microphone and try again.')
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
return
|
||||
}
|
||||
|
||||
await this._processUserMessage(result.text.trim())
|
||||
} catch (err) {
|
||||
if (!this._isActive) return
|
||||
logger.error('STT failed in conversation:', err)
|
||||
this._resumeListening()
|
||||
this._emitError('stt', err instanceof Error ? err.message : 'STT failed')
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
return
|
||||
}
|
||||
|
||||
// LLM 단계의 실패는 _processUserMessage 가 'llm' 으로 알린다 — STT 오류로 잘못 다시 알리지 않게 try 밖에서 부른다.
|
||||
await this._processUserMessage(result.text.trim())
|
||||
}
|
||||
|
||||
private async _processUserMessage(text: string): Promise<void> {
|
||||
|
|
@ -268,6 +287,8 @@ class VoiceConversationService extends EventEmitter {
|
|||
|
||||
const responseAbortController = new AbortController()
|
||||
this._responseAbortController = responseAbortController
|
||||
// 응답하는 동안은 듣지 않는다(텍스트 턴 포함) — 마이크를 놓아 캡처 참조 카운트를 맞춘다.
|
||||
this._stopListening()
|
||||
|
||||
// 사용자 메시지 추가
|
||||
const userMsg: ConversationMessage = {
|
||||
|
|
@ -365,19 +386,13 @@ class VoiceConversationService extends EventEmitter {
|
|||
}
|
||||
|
||||
// 재생 완료 → 다시 listening
|
||||
if (this._isActive) {
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
}
|
||||
this._resumeListening()
|
||||
} catch (err) {
|
||||
if (responseAbortController.signal.aborted) return
|
||||
|
||||
logger.error('LLM chat failed in conversation:', err)
|
||||
this._resumeListening()
|
||||
this._emitError('llm', err instanceof Error ? err.message : 'LLM failed')
|
||||
if (this._isActive) {
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
}
|
||||
} finally {
|
||||
if (this._responseAbortController === responseAbortController) {
|
||||
this._responseAbortController = null
|
||||
|
|
@ -440,12 +455,21 @@ class VoiceConversationService extends EventEmitter {
|
|||
this._messages = pairs
|
||||
}
|
||||
|
||||
/**
|
||||
* 오류를 렌더러에 알리고 에러음을 낸다. 'error' 가 아니라 'conversation-error' 로 내보낸다 —
|
||||
* 리스너 없는 'error' 는 EventEmitter 가 던져서, 뒤따르는 상태 복구가 실행되지 않고 세션이
|
||||
* 'thinking' 에 마이크가 꺼진 채 굳었다.
|
||||
*/
|
||||
private _emitError(phase: ConversationError['phase'], message: string): void {
|
||||
const error: ConversationError = { message, phase }
|
||||
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.ERROR, error)
|
||||
this.emit('error', error)
|
||||
this.emit('conversation-error', error)
|
||||
// 에러 사운드 (fire-and-forget)
|
||||
getSoundEffectService().play('error')
|
||||
try {
|
||||
getSoundEffectService().play('error')
|
||||
} catch {
|
||||
// 효과음 실패는 무시
|
||||
}
|
||||
}
|
||||
|
||||
private _sendToRenderer(channel: string, data: unknown): void {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -93,7 +93,12 @@ export class StreamingCaptionTrack {
|
|||
try {
|
||||
await job()
|
||||
} catch (error) {
|
||||
this._deps.onError(error)
|
||||
// 보고 경로가 던져도 트랙(과 이를 기다리는 flush/stop)은 멈추지 않는다
|
||||
try {
|
||||
this._deps.onError(error)
|
||||
} catch {
|
||||
// ignore — onError 는 알림일 뿐이다
|
||||
}
|
||||
} finally {
|
||||
this._busy = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,18 @@ export type LlmBackendKind = 'local' | 'premium'
|
|||
export interface LlmCallHooks {
|
||||
/** Premium 을 쓰려 했으나 로컬로 넘어갔을 때 사유와 함께 호출된다. */
|
||||
onFallback?: (reason: string) => void
|
||||
/**
|
||||
* 이 호출만 취소한다. 어댑터의 전역 cancelGeneration() 과 달리 다른 기능의 진행 중 호출은 건드리지 않는다.
|
||||
* 취소·기한 초과는 로컬로 폴백하지 않고 그대로 전파된다(취소가 로컬 재실행으로 바뀌지 않게).
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
/** 이 호출의 기한(ms) — Premium 단일 턴 호출에 적용된다 */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** 호출 단위 옵션(취소·기한·폴백 알림) — LlmCallHooks 와 같다 */
|
||||
export type LlmCallOptions = LlmCallHooks
|
||||
|
||||
export interface LlmChatStreamHandle {
|
||||
generator: AsyncGenerator<string, string>
|
||||
backend: LlmBackendKind
|
||||
|
|
@ -73,24 +83,42 @@ export interface LlmGateway {
|
|||
options: { signal: AbortSignal; local?: LocalChatStreamOptions },
|
||||
hooks?: LlmCallHooks,
|
||||
): Promise<LlmChatStreamHandle>
|
||||
/** 두 어댑터의 진행 중 생성을 모두 취소한다. */
|
||||
/** 두 어댑터의 진행 중 생성을 모두 취소한다 — 앱 종료·전역 취소 전용. 기능 단위 취소는 signal 을 쓴다. */
|
||||
cancel(): void
|
||||
}
|
||||
|
||||
// ── 어댑터 계약 (기존 Premium/Local 서비스가 구조적으로 만족) ─────
|
||||
|
||||
/** Premium 단일 턴 호출의 호출 단위 옵션 */
|
||||
export interface PremiumCallAdapterOptions {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface PremiumLlmAdapter {
|
||||
isAvailable(): boolean
|
||||
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
|
||||
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string }>
|
||||
chatStream(messages: LlmChatMessage[], options?: { temperature?: number }): AsyncGenerator<string, string>
|
||||
processText(
|
||||
text: string,
|
||||
action: LLMAction,
|
||||
targetLanguage?: string,
|
||||
customPrompt?: string,
|
||||
options?: PremiumCallAdapterOptions,
|
||||
): Promise<string>
|
||||
generate(text: string, options?: LlmGenerateOptions & PremiumCallAdapterOptions): Promise<{ text: string }>
|
||||
chatStream(
|
||||
messages: LlmChatMessage[],
|
||||
options?: { temperature?: number; signal?: AbortSignal },
|
||||
): AsyncGenerator<string, string>
|
||||
cancelGeneration(): void
|
||||
}
|
||||
|
||||
export interface LocalLlmAdapter {
|
||||
isAvailable(): boolean
|
||||
processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string>
|
||||
generate(text: string, options?: LlmGenerateOptions): Promise<{ text: string; model?: string }>
|
||||
generate(
|
||||
text: string,
|
||||
options?: LlmGenerateOptions & { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<{ text: string; model?: string }>
|
||||
chatStream(
|
||||
messages: LlmChatMessage[],
|
||||
options?: LocalChatStreamOptions & { signal?: AbortSignal },
|
||||
|
|
@ -112,6 +140,30 @@ function describe(err: unknown): string {
|
|||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
/** 취소·기한 초과는 폴백하지 않는다 — 사용자가 멈춘 호출을 로컬에서 다시 돌리지 않게. */
|
||||
function isCancellation(err: unknown, signal: AbortSignal | undefined): boolean {
|
||||
if (signal?.aborted) return true
|
||||
if (err instanceof D3ROError) {
|
||||
return err.code === ErrorCode.LLMProcessingCancelled || err.code === ErrorCode.LLMProcessingTimeout
|
||||
}
|
||||
return err instanceof Error && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
if (signal?.aborted) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM call cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
/** 호출 단위 옵션. 없으면 undefined — 어댑터 호출 모양(인자 수)을 예전과 같게 유지한다. */
|
||||
function callOptions(hooks: LlmCallHooks | undefined): { signal?: AbortSignal; timeoutMs?: number } | undefined {
|
||||
if (!hooks?.signal && hooks?.timeoutMs === undefined) return undefined
|
||||
const options: { signal?: AbortSignal; timeoutMs?: number } = {}
|
||||
if (hooks.signal) options.signal = hooks.signal
|
||||
if (hooks.timeoutMs !== undefined) options.timeoutMs = hooks.timeoutMs
|
||||
return options
|
||||
}
|
||||
|
||||
// ── 라우팅 구현 ─────────────────────────────────────────────
|
||||
|
||||
class RoutedLlmGateway implements LlmGateway {
|
||||
|
|
@ -148,27 +200,42 @@ class RoutedLlmGateway implements LlmGateway {
|
|||
customPrompt?: string,
|
||||
hooks?: LlmCallHooks,
|
||||
): Promise<string> {
|
||||
throwIfAborted(hooks?.signal)
|
||||
const premium = await this._selectPremium(hooks)
|
||||
if (premium) {
|
||||
try {
|
||||
return await premium.processText(text, action, targetLanguage, customPrompt)
|
||||
const perCall = callOptions(hooks)
|
||||
return perCall
|
||||
? await premium.processText(text, action, targetLanguage, customPrompt, perCall)
|
||||
: await premium.processText(text, action, targetLanguage, customPrompt)
|
||||
} catch (err) {
|
||||
if (isCancellation(err, hooks?.signal)) throw err
|
||||
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
|
||||
}
|
||||
}
|
||||
return this._deps.getLocal().processText(text, action, targetLanguage, customPrompt)
|
||||
throwIfAborted(hooks?.signal)
|
||||
// 로컬 processText 는 호출 단위 signal 을 받지 않는다 — 끝난 뒤 취소됐으면 결과를 버린다.
|
||||
const result = await this._deps.getLocal().processText(text, action, targetLanguage, customPrompt)
|
||||
throwIfAborted(hooks?.signal)
|
||||
return result
|
||||
}
|
||||
|
||||
async generate(text: string, options?: LlmGenerateOptions, hooks?: LlmCallHooks): Promise<LlmGenerateResult> {
|
||||
throwIfAborted(hooks?.signal)
|
||||
const premium = await this._selectPremium(hooks)
|
||||
if (premium) {
|
||||
try {
|
||||
return await premium.generate(text, options)
|
||||
const perCall = callOptions(hooks)
|
||||
return await premium.generate(text, perCall ? { ...options, ...perCall } : options)
|
||||
} catch (err) {
|
||||
if (isCancellation(err, hooks?.signal)) throw err
|
||||
this._fallback(hooks, `Premium call failed: ${describe(err)}`)
|
||||
}
|
||||
}
|
||||
return this._deps.getLocal().generate(text, options)
|
||||
throwIfAborted(hooks?.signal)
|
||||
const perCall = callOptions(hooks)
|
||||
const local = this._deps.getLocal()
|
||||
return perCall ? local.generate(text, { ...options, ...perCall }) : local.generate(text, options)
|
||||
}
|
||||
|
||||
async openChatStream(
|
||||
|
|
@ -178,7 +245,9 @@ class RoutedLlmGateway implements LlmGateway {
|
|||
): Promise<LlmChatStreamHandle> {
|
||||
const premium = await this._selectPremium(hooks)
|
||||
if (premium) {
|
||||
return { generator: premium.chatStream(messages), backend: 'premium' }
|
||||
// 호출자의 signal 을 넘긴다 — 예전엔 버려져 기능들이 전역 cancelGeneration() 으로 멈췄고,
|
||||
// 그것이 다른 기능(회의 채팅 등)의 Premium 스트림까지 끊었다.
|
||||
return { generator: premium.chatStream(messages, { signal: options.signal }), backend: 'premium' }
|
||||
}
|
||||
|
||||
const local = this._deps.getLocal()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -8,18 +8,14 @@ import { history, memoTags } from '../../db/schema'
|
|||
import { isUuid, type PushContext } from './sync-adapters'
|
||||
import { toSyncRemoteError, type PushOutcome, type RemoteCursor, type SyncOp, type SyncRemote } from './sync-types'
|
||||
|
||||
import { memoTagKey, normalizeMemoTag } from '@d3ro/core/memo-tag-policy'
|
||||
|
||||
// 정규화·자연키 규칙의 정본은 @d3ro/core/memo-tag-policy 다 — 기존 import 경로를 위해 다시 내보낸다.
|
||||
export { memoTagKey, normalizeMemoTag }
|
||||
|
||||
const SEPARATOR = '|'
|
||||
const PAGE_SIZE = 1000
|
||||
|
||||
/** 서버 정규화와 같다: trim → 연속 공백 1칸 → 소문자. */
|
||||
export function normalizeMemoTag(tag: string): string {
|
||||
return tag.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
export function memoTagKey(historyId: string, tag: string): string {
|
||||
return `${historyId}${SEPARATOR}${normalizeMemoTag(tag)}`
|
||||
}
|
||||
|
||||
export function parseMemoTagKey(key: string): { historyId: string; tag: string } | null {
|
||||
const index = key.indexOf(SEPARATOR)
|
||||
if (index <= 0) return null
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -21,12 +21,42 @@ export interface SessionTokenStoreLogger {
|
|||
warn(message: string): void
|
||||
}
|
||||
|
||||
/** 저장된 세션 — refresh token 과 그 토큰의 계정 id(구버전 파일은 id 가 없다). */
|
||||
export interface StoredSessionRecord {
|
||||
refreshToken: string
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
export interface SessionTokenStore {
|
||||
load(): string | null
|
||||
save(refreshToken: string): void
|
||||
/** 토큰과 계정 id 를 함께 읽는다. 시작 시 복원 전에 그 계정 DB 를 열 수 있게 한다. */
|
||||
loadRecord(): StoredSessionRecord | null
|
||||
/** userId 를 주면 토큰과 함께 저장한다(생략하면 토큰만 — 구버전 형식). */
|
||||
save(refreshToken: string, userId?: string | null): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const RECORD_VERSION = 1
|
||||
|
||||
/** 파일 내용 → 레코드. 구버전(토큰 문자열만)도 읽는다. refresh token 은 '{' 로 시작하지 않는다. */
|
||||
export function decodeSessionRecord(plain: string): StoredSessionRecord | null {
|
||||
const text = plain.trim()
|
||||
if (!text) return null
|
||||
if (!text.startsWith('{')) return { refreshToken: text, userId: null }
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { v?: unknown; rt?: unknown; uid?: unknown }
|
||||
if (typeof parsed.rt !== 'string' || parsed.rt.length === 0) return null
|
||||
return { refreshToken: parsed.rt, userId: typeof parsed.uid === 'string' && parsed.uid ? parsed.uid : null }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeSessionRecord(refreshToken: string, userId: string | null | undefined): string {
|
||||
if (!userId) return refreshToken
|
||||
return JSON.stringify({ v: RECORD_VERSION, rt: refreshToken, uid: userId })
|
||||
}
|
||||
|
||||
export class EncryptedFileTokenStore implements SessionTokenStore {
|
||||
constructor(
|
||||
private readonly filePath: string,
|
||||
|
|
@ -36,21 +66,25 @@ export class EncryptedFileTokenStore implements SessionTokenStore {
|
|||
) {}
|
||||
|
||||
load(): string | null {
|
||||
return this.loadRecord()?.refreshToken ?? null
|
||||
}
|
||||
|
||||
loadRecord(): StoredSessionRecord | null {
|
||||
try {
|
||||
if (!this.fs.existsSync(this.filePath)) return null
|
||||
if (!this.cipher.isEncryptionAvailable()) return null
|
||||
return this.cipher.decryptString(this.fs.readFileSync(this.filePath))
|
||||
return decodeSessionRecord(this.cipher.decryptString(this.fs.readFileSync(this.filePath)))
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token load failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
save(refreshToken: string): void {
|
||||
save(refreshToken: string, userId?: string | null): void {
|
||||
try {
|
||||
if (!refreshToken) return
|
||||
if (this.cipher.isEncryptionAvailable()) {
|
||||
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(refreshToken))
|
||||
this.fs.writeFileSync(this.filePath, this.cipher.encryptString(encodeSessionRecord(refreshToken, userId)))
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Token save failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
|
|
@ -81,20 +115,27 @@ export type AuthStateSubscribe<S extends AuthSessionLike> = (
|
|||
/**
|
||||
* auth 상태 이벤트에 붙어 회전된 refresh token을 저장한다.
|
||||
* - 현재 로그인된 사용자(activeUserId)의 세션만 저장한다(로그아웃 뒤 늦게 온 이벤트는 무시).
|
||||
* - SIGNED_OUT 은 무시한다 — 로그아웃 경로가 직접 지운다.
|
||||
* - SIGNED_OUT: 저장소는 건드리지 않는다(로그아웃 경로가 직접 지운다). 로그인된 사용자가 있을 때
|
||||
* onSignedOut 을 부른다 — 다른 기기의 전역 로그아웃·토큰 폐기로 auth 클라이언트가 세션을 버리면
|
||||
* 서비스가 익명 키로 계속 도는 대신 로그아웃 정리를 하게 한다. (onSignedOut 이 없으면 무시)
|
||||
* onSession 은 저장 뒤 호출돼 서비스가 메모리 세션(access token)도 갱신하게 한다.
|
||||
*/
|
||||
export function bindAuthEvents<S extends AuthSessionLike>(
|
||||
subscribe: AuthStateSubscribe<S>,
|
||||
store: SessionTokenStore,
|
||||
activeUserId: () => string | null,
|
||||
onSession: (session: S) => void
|
||||
onSession: (session: S) => void,
|
||||
onSignedOut?: () => void
|
||||
): () => void {
|
||||
const subscription = subscribe((event, session) => {
|
||||
if (event === 'SIGNED_OUT') {
|
||||
if (onSignedOut && activeUserId()) onSignedOut()
|
||||
return
|
||||
}
|
||||
if (!session || !ROTATION_EVENTS.has(event)) return
|
||||
const userId = activeUserId()
|
||||
if (!userId || session.user.id !== userId) return
|
||||
store.save(session.refresh_token)
|
||||
store.save(session.refresh_token, userId)
|
||||
onSession(session)
|
||||
})
|
||||
return () => subscription.unsubscribe()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// src/main/suggestion-overlay-policy.ts
|
||||
//
|
||||
// 제안 오버레이 표시 전략 (순수 함수) — bootstrap.ts 의 suggestion.on('updated', ...)
|
||||
// 배선에서 쓴다. Electron/서비스 의존이 없는 별도 파일로 뺀 이유: bootstrap.ts 는
|
||||
// 최상위에서 electron/서비스 모듈을 다수 import 하므로 그대로는 단위 테스트하기
|
||||
// 어렵다 — 이 판단 로직만 독립적으로 테스트한다.
|
||||
// 제안 오버레이 표시 전략 (순수 함수) — windows/suggestion-overlay-presenter.ts 가 쓴다
|
||||
// (Esc 판단은 bootstrap 의 원본 keydown 배선이 쓴다). Electron/서비스 의존이 없어
|
||||
// 이 판단 로직만 독립적으로 테스트한다.
|
||||
|
||||
/**
|
||||
* 제안 오버레이를 이번 'updated' 이벤트에 어떻게 반영할지 결정한다.
|
||||
|
|
|
|||
126
apps/desktop/src/main/windows/suggestion-overlay-presenter.ts
Normal file
126
apps/desktop/src/main/windows/suggestion-overlay-presenter.ts
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// src/preload/index.ts
|
||||
// contextBridge로 렌더러에 노출할 API 정의
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import type {
|
||||
AudioDevice,
|
||||
|
|
@ -184,6 +184,12 @@ import type {
|
|||
DiarizeSessionParams,
|
||||
} from '@d3ro/core/types'
|
||||
import { Feature } from '@d3ro/core/types'
|
||||
import {
|
||||
MEETING_CHAT_GET_HISTORY_CHANNEL,
|
||||
MEETING_MODE_GET_LIVE_SESSION_CHANNEL,
|
||||
type MeetingChatHistoryTurn,
|
||||
type MeetingLiveSession,
|
||||
} from '@d3ro/core/meeting-live'
|
||||
import type { IPCResult } from '@d3ro/core/errors'
|
||||
|
||||
/** 로컬 AI 런타임(사이드카 엔진/ffmpeg) 상태 — RuntimeProvisioner가 제공한다 */
|
||||
|
|
@ -581,6 +587,8 @@ const electronAPI = {
|
|||
invoke<FileTranscriptionResult>(IPC_CHANNELS.FILE_TRANSCRIPTION.START, params),
|
||||
cancel: () =>
|
||||
invoke<void>(IPC_CHANNELS.FILE_TRANSCRIPTION.CANCEL),
|
||||
/** 드롭한 File 의 실제 경로 (Electron 32+ 는 File.path 가 없다). 알 수 없으면 빈 문자열. */
|
||||
getPathForFile: (file: File): string => webUtils.getPathForFile(file),
|
||||
getState: () =>
|
||||
invoke<FileTranscriptionStateInfo>(IPC_CHANNELS.FILE_TRANSCRIPTION.GET_STATE),
|
||||
onProgress: (cb: (data: FileTranscriptionProgress) => void): Unsubscribe =>
|
||||
|
|
@ -725,6 +733,9 @@ const electronAPI = {
|
|||
invoke<MeetingMemo>(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params),
|
||||
getState: () =>
|
||||
invoke<MeetingModeStateInfo>(IPC_CHANNELS.MEETING_MODE.GET_STATE),
|
||||
/** 진행 중인 녹음의 실시간 전사·메모 (페이지를 다시 열 때 복원용). 녹음 중이 아니면 null */
|
||||
getLiveSession: () =>
|
||||
invoke<MeetingLiveSession | null>(MEETING_MODE_GET_LIVE_SESSION_CHANNEL),
|
||||
getSessions: (params: MeetingGetSessionsParams) =>
|
||||
invoke<MeetingSessionPage>(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params),
|
||||
getSession: (params: MeetingGetSessionParams) =>
|
||||
|
|
@ -782,10 +793,15 @@ const electronAPI = {
|
|||
meetingChat: {
|
||||
send: (params: MeetingChatSendParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_CHAT.SEND, params),
|
||||
cancel: () =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CANCEL),
|
||||
clear: () =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CLEAR),
|
||||
/** sessionId 를 주면 그 회의의 채팅만 멈춘다 */
|
||||
cancel: (params?: { sessionId: string }) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CANCEL, params),
|
||||
/** sessionId 를 주면 그 회의의 대화만 지운다(생략하면 전부) */
|
||||
clear: (params?: { sessionId: string }) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_CHAT.CLEAR, params),
|
||||
/** main 이 기억하는 이 회의의 대화 — 패널을 다시 열 때 보여 준다 */
|
||||
getHistory: (params: { sessionId: string }) =>
|
||||
invoke<MeetingChatHistoryTurn[]>(MEETING_CHAT_GET_HISTORY_CHANNEL, params),
|
||||
onDelta: (cb: (e: MeetingChatDelta) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.MEETING_CHAT.DELTA, cb),
|
||||
onMessage: (cb: (e: MeetingChatMessage) => void): Unsubscribe =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
FileTranscriptionResult,
|
||||
FileTranscriptionState,
|
||||
} from '@d3ro/core/types'
|
||||
import { StartRequestTracker, classifyStartFailure, resolveDroppedFilePath } from './file-drop-model'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = [
|
||||
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
|
||||
|
|
@ -52,6 +53,7 @@ export function FileDropZone(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
const isBusyRef = useRef(false)
|
||||
const requestsRef = useRef(new StartRequestTracker())
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -68,34 +70,26 @@ export function FileDropZone(): React.ReactElement {
|
|||
return
|
||||
}
|
||||
|
||||
isBusyRef.current = true
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const filePath = (file as unknown as { path: string }).path
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath })
|
||||
if (!resp.success) {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
} finally {
|
||||
isBusyRef.current = false
|
||||
// Electron 32+ 에서는 File.path 가 없다 — preload 의 webUtils.getPathForFile 로 얻는다.
|
||||
// 경로를 못 얻으면 빈 경로로 시작하지 않는다(main 이 파일 선택 대화상자를 띄운다).
|
||||
const filePath = resolveDroppedFilePath(file, window.electronAPI.fileTranscription.getPathForFile)
|
||||
if (!filePath) {
|
||||
setError(t('fileTranscription.error.unknown'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
|
||||
isBusyRef.current = true
|
||||
const request = requestsRef.current.begin()
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath })
|
||||
if (!requestsRef.current.isCurrent(request)) return
|
||||
if (!resp.success) {
|
||||
if (resp.error.message.includes('cancelled') || resp.error.message.includes('already active')) {
|
||||
if (classifyStartFailure(resp.error) === 'cancelled') {
|
||||
setState('idle')
|
||||
} else {
|
||||
setError(resp.error.message)
|
||||
|
|
@ -103,11 +97,37 @@ export function FileDropZone(): React.ReactElement {
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
isBusyRef.current = false
|
||||
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
|
||||
isBusyRef.current = true
|
||||
const request = requestsRef.current.begin()
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
|
||||
if (!requestsRef.current.isCurrent(request)) return
|
||||
if (!resp.success) {
|
||||
if (classifyStartFailure(resp.error) === 'cancelled') {
|
||||
setState('idle')
|
||||
} else {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// 취소된 요청의 늦은 응답은 무시한다(새로 드롭한 작업의 화면을 덮지 않게)
|
||||
requestsRef.current.invalidate()
|
||||
await window.electronAPI.fileTranscription.cancel()
|
||||
setState('idle')
|
||||
setProgress(null)
|
||||
|
|
|
|||
48
apps/desktop/src/renderer/components/file-drop-model.ts
Normal file
48
apps/desktop/src/renderer/components/file-drop-model.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { isImeComposingEvent } from '../../utils/keyboard'
|
|||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { MeetingChatMessage } from '@d3ro/core/types'
|
||||
import { withRestoredHistory } from './meeting-chat-history'
|
||||
|
||||
interface MeetingChatPanelProps {
|
||||
sessionId: string
|
||||
|
|
@ -85,17 +86,27 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
scrollToBottom()
|
||||
}, [messages, streamingContent, scrollToBottom])
|
||||
|
||||
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
|
||||
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
|
||||
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버리고,
|
||||
// main 이 이 회의에 대해 기억하는 대화를 불러와 보여 준다 — 예전엔 빈 화면인데 모델은 보이지 않는
|
||||
// 이전 턴을 이어받아 답했다.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setMessages([])
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
setError(null)
|
||||
isSendingChatRef.current = false
|
||||
window.electronAPI.meetingChat
|
||||
.getHistory({ sessionId })
|
||||
.then((resp) => {
|
||||
if (cancelled || !resp.success || resp.data.length === 0) return
|
||||
setMessages((current) => withRestoredHistory(current, resp.data))
|
||||
})
|
||||
.catch(() => { /* 불러오지 못하면 빈 대화로 시작 */ })
|
||||
return () => {
|
||||
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||
void window.electronAPI.meetingChat.cancel()
|
||||
cancelled = true
|
||||
// 떠나는 회의의 답변 스트림만 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||
void window.electronAPI.meetingChat.cancel({ sessionId })
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
|
|
@ -167,7 +178,9 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
setMessages([])
|
||||
setStreamingContent('')
|
||||
setStreaming(false)
|
||||
window.electronAPI.meetingChat.clear()
|
||||
// 이 회의의 대화만 지운다 — 예전엔 인자 없이 불러 모든 회의의 대화가 지워졌다
|
||||
void window.electronAPI.meetingChat.cancel({ sessionId })
|
||||
void window.electronAPI.meetingChat.clear({ sessionId })
|
||||
}, [sessionId])
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ import {
|
|||
} from '@d3ro/ui/components/ds'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { INSTRUCTION_LIMITS } from '@d3ro/core/instruction-policy'
|
||||
import { commandSaveError } from './commands/command-form'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { TemplateSection } from '../components/TemplateSection'
|
||||
|
||||
|
|
@ -63,6 +65,8 @@ export function CommandsPage(): React.ReactElement {
|
|||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
/** 저장 거부 사유 — 대화상자 안에 보여 준다 */
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const isDeletingRef = useRef(false)
|
||||
|
||||
|
|
@ -123,6 +127,7 @@ export function CommandsPage(): React.ReactElement {
|
|||
setFormName('')
|
||||
setFormDesc('')
|
||||
setFormPrompt(t('commands.defaultPrompt'))
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -132,30 +137,36 @@ export function CommandsPage(): React.ReactElement {
|
|||
setFormName(inst.name)
|
||||
setFormDesc(inst.description)
|
||||
setFormPrompt(inst.prompt)
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 저장이 거부되면(빈 값·길이 초과 등) 대화상자를 닫지 않고 이유를 보여 준다 — 예전엔 결과를 보지 않고
|
||||
// 닫아 입력한 내용이 조용히 사라졌다.
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim() || saving || isSavingRef.current) return
|
||||
const draft = { name: formName.trim(), description: formDesc.trim(), prompt: formPrompt.trim() }
|
||||
const invalid = commandSaveError({ kind: 'draft', draft })
|
||||
if (invalid) {
|
||||
setFormError(t(invalid.key))
|
||||
return
|
||||
}
|
||||
isSavingRef.current = true
|
||||
setSaving(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
if (editId) {
|
||||
await window.electronAPI.instruction.update({
|
||||
id: editId,
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
prompt: formPrompt.trim(),
|
||||
})
|
||||
} else {
|
||||
await window.electronAPI.instruction.create({
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
prompt: formPrompt.trim(),
|
||||
})
|
||||
const res = editId
|
||||
? await window.electronAPI.instruction.update({ id: editId, ...draft })
|
||||
: await window.electronAPI.instruction.create(draft)
|
||||
if (!res.success) {
|
||||
const failure = commandSaveError({ kind: 'ipc', error: res.error })
|
||||
setFormError(failure ? t(failure.key) : res.error.message)
|
||||
return
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
isSavingRef.current = false
|
||||
|
|
@ -601,34 +612,42 @@ export function CommandsPage(): React.ReactElement {
|
|||
<TextField
|
||||
label={t('commands.name')}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
onChange={(e) => { setFormName(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
autoFocus
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.name }}
|
||||
sx={{ mt: 1.5 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('commands.description')}
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
onChange={(e) => { setFormDesc(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.description }}
|
||||
sx={{ mt: 2.5 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('commands.promptTemplate')}
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
onChange={(e) => { setFormPrompt(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.prompt }}
|
||||
sx={{ mt: 2.5 }}
|
||||
helperText={t('commands.promptHelp')}
|
||||
helperText={`${t('commands.promptHelp')} · ${formPrompt.length}/${INSTRUCTION_LIMITS.prompt}`}
|
||||
/>
|
||||
{formError && (
|
||||
<PhosphorText variant="body" sx={{ mt: 1.5, color: d3roPalette.status.danger, display: 'block' }}>
|
||||
{formError}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
|
||||
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || saving}>
|
||||
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || !formPrompt.trim() || saving}>
|
||||
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
replaceSegmentText,
|
||||
rollbackSegmentText,
|
||||
} from './meeting/live-session'
|
||||
import { mergeRestoredItems } from './meeting/live-restore'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
|
|
@ -148,11 +149,13 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// ── 진행 중 세션 복원 ──
|
||||
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
|
||||
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
|
||||
// 그동안의 실시간 전사·메모도 main 에서 받아 되살린다(예전엔 뷰만 복원돼 이전 전사가 사라져 보이고
|
||||
// 편집할 수 없었다). 조회하는 사이 도착한 세그먼트와는 id 로 합친다.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.electronAPI.meetingMode
|
||||
.getState()
|
||||
.then((resp) => {
|
||||
.then(async (resp) => {
|
||||
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
|
||||
const info = resp.data
|
||||
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
|
||||
|
|
@ -160,6 +163,11 @@ export function MeetingModePage(): React.ReactElement {
|
|||
setLiveSessionId(info.sessionId)
|
||||
setElapsedMs(info.elapsedMs)
|
||||
setView('recording')
|
||||
const live = await window.electronAPI.meetingMode.getLiveSession()
|
||||
if (cancelled || !live.success || !live.data || live.data.sessionId !== info.sessionId) return
|
||||
const restored = live.data
|
||||
setSegments((arrived) => mergeRestoredItems(restored.segments, arrived, (s) => s.timestamp))
|
||||
setMemos((arrived) => mergeRestoredItems(restored.memos, arrived, (m) => m.timestampMs))
|
||||
})
|
||||
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
|
||||
return () => {
|
||||
|
|
@ -342,9 +350,24 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}
|
||||
}, [parseMeetingError])
|
||||
|
||||
// 정지는 마지막 문장 인식(flush)이 끝날 때까지 걸린다 — 그동안 다시 누를 수 없게 하고,
|
||||
// 실패하면 알린다(예전엔 응답을 버려 화면이 계속 '녹음 중'이었다).
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const isStoppingRef = useRef(false)
|
||||
const handleStopRecording = useCallback(async () => {
|
||||
await window.electronAPI.meetingMode.stopRecording()
|
||||
}, [])
|
||||
if (isStoppingRef.current) return
|
||||
isStoppingRef.current = true
|
||||
setIsStopping(true)
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.stopRecording()
|
||||
if (!resp.success) setSnackbarMsg(`${t('meeting.error')}: ${resp.error.message}`)
|
||||
} catch (err: unknown) {
|
||||
setSnackbarMsg(`${t('meeting.error')}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
isStoppingRef.current = false
|
||||
setIsStopping(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
|
||||
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
|
||||
|
|
@ -792,7 +815,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
</Box>
|
||||
|
||||
{!isProcessing && (
|
||||
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording}>
|
||||
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording} disabled={isStopping}>
|
||||
<Square size={14} style={{ marginRight: 4 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
|
|
|
|||
35
apps/desktop/src/renderer/pages/commands/command-form.ts
Normal file
35
apps/desktop/src/renderer/pages/commands/command-form.ts
Normal 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
|
||||
}
|
||||
26
apps/desktop/src/renderer/pages/meeting/live-restore.ts
Normal file
26
apps/desktop/src/renderer/pages/meeting/live-restore.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal file
52
apps/desktop/tests/main/db-open-atomic-redteam-r2-1.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal file
114
apps/desktop/tests/red/data-policy-redteam-r2-1.test.ts
Normal 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 확인'])
|
||||
})
|
||||
})
|
||||
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal file
132
apps/desktop/tests/red/llm-privacy-routing-redteam-r2-1.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal file
237
apps/desktop/tests/red/meeting-recording-redteam-r2-1.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
64
apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts
Normal file
64
apps/desktop/tests/unit/renderer-models-redteam-r2-1.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue