fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id

This commit is contained in:
Yun Chan 2026-09-28 00:54:03 +09:00
parent a8983c583a
commit a85b24d385
15 changed files with 974 additions and 69 deletions

View file

@ -6,7 +6,11 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors' import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
import { getMeetingModeService } from '../services/MeetingModeService' import { getMeetingModeService } from '../services/MeetingModeService'
import { getLogger } from '../services/LoggerService' import { getLogger } from '../services/LoggerService'
import { LiveSegmentEditor } from '../services/meeting/live-segment-edits'
import type { LiveSegmentEditResult } from '../services/meeting/live-segment-edits'
import type { import type {
CaptionSegment,
MeetingSessionDetail,
MeetingAddMemoParams, MeetingAddMemoParams,
MeetingGetSessionsParams, MeetingGetSessionsParams,
MeetingGetSessionParams, MeetingGetSessionParams,
@ -19,8 +23,47 @@ import type {
const logger = getLogger('meeting-mode-handlers') const logger = getLogger('meeting-mode-handlers')
/** 렌더러 editSegment 요청 — preload `meetingMode.editSegment` 와 같은 모양 */
interface MeetingEditSegmentParams {
sessionId: string
segmentId: string
text: string
}
let liveSegmentEditor: LiveSegmentEditor | null = null
/** MeetingModeService 를 LiveMeetingPort 로 감싸 실시간 세그먼트 편집기를 붙인다(재등록 시 이전 구독 해지). */
function attachLiveSegmentEditor(): LiveSegmentEditor {
liveSegmentEditor?.dispose()
const service = getMeetingModeService()
liveSegmentEditor = new LiveSegmentEditor(
{
getStateInfo: () => service.getStateInfo(),
onSegment: (listener: (segment: CaptionSegment) => void) => {
service.on('segment', listener)
return () => { service.off('segment', listener) }
},
onSessionCompleted: (listener: (detail: MeetingSessionDetail) => void) => {
service.on('session-completed', listener)
return () => { service.off('session-completed', listener) }
},
saveEditedTranscript: (sessionId, editedTranscript) =>
service.updateTranscript(sessionId, editedTranscript),
},
logger,
)
return liveSegmentEditor
}
const EDIT_SEGMENT_FAILURES: Readonly<Record<Exclude<LiveSegmentEditResult, 'ok'>, { code: ErrorCode; message: string }>> = {
'not-live': { code: ErrorCode.MeetingNotRecording, message: '진행 중인 회의 세션이 아닙니다' },
'unknown-segment': { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '수정할 발화를 찾을 수 없습니다' },
empty: { code: ErrorCode.MeetingTranscriptUpdateFailed, message: '빈 내용으로는 수정할 수 없습니다' },
}
export function registerMeetingModeHandlers(): void { export function registerMeetingModeHandlers(): void {
const ch = IPC_CHANNELS.MEETING_MODE const ch = IPC_CHANNELS.MEETING_MODE
const liveEditor = attachLiveSegmentEditor()
ipcMain.handle(ch.START_RECORDING, async (_event, params?: { force?: boolean }) => { ipcMain.handle(ch.START_RECORDING, async (_event, params?: { force?: boolean }) => {
try { try {
@ -143,6 +186,23 @@ export function registerMeetingModeHandlers(): void {
} }
}) })
// 녹음 중 실시간 세그먼트 수정 — 세션 완료 시 editedTranscript 로 반영된다.
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)
const failure = EDIT_SEGMENT_FAILURES[result]
logger.warn(`editSegment 거부(${result}): sessionId=${params.sessionId}, segmentId=${params.segmentId}`)
return ipcError(failure.code, failure.message)
} catch (err) {
logger.error(`editSegment 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(
ErrorCode.MeetingTranscriptUpdateFailed,
err instanceof Error ? err.message : String(err),
)
}
})
ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => { ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => {
try { try {
const doc = await getMeetingModeService().generateDocument(params) const doc = await getMeetingModeService().generateDocument(params)

View file

@ -0,0 +1,146 @@
// src/main/services/meeting/live-segment-edits.ts
// 회의 녹음 중 사용자가 고친 실시간 세그먼트를 모아 두었다가, 세션이 완료되면
// 원문 전사(rawTranscript)는 그대로 두고 수정본(editedTranscript)에 반영한다.
//
// MeetingModeService 에 직접 기대지 않고 LiveMeetingPort 로만 소통한다(DIP) —
// IPC 핸들러가 서비스 어댑터를 넘기고, 테스트는 가짜 포트를 넘긴다.
import type {
CaptionSegment,
MeetingModeStateInfo,
MeetingSessionDetail,
} from '@d3ro/core/types'
/** 세그먼트는 전사에서 한 줄로 저장되므로, 수정 텍스트의 줄바꿈은 공백으로 접는다. */
export function normalizeSegmentText(text: string): string {
return text.replace(/\s*\r?\n\s*/g, ' ').trim()
}
/**
* 원문 전사에 실시간 세그먼트 수정을 적용한다.
*
* rawTranscript 는 세그먼트마다 `<시각 접두사> <text>` 를 '\n' 으로 이은 문자열이다
* (세그먼트 텍스트에 줄바꿈이 있으면 그 세그먼트가 여러 줄을 차지한다).
* 접두사 형식에 의존하지 않도록, 각 세그먼트가 차지하는 줄 묶음이 원래 텍스트로
* 끝나는지 확인한 뒤 그 꼬리만 바꾼다. 구조가 맞지 않으면 엉뚱한 줄을 덮어쓰지 않도록 null.
* 바뀐 것이 없어도 null.
*/
export function applyLiveSegmentEdits(
rawTranscript: string,
segments: ReadonlyArray<Pick<CaptionSegment, 'id' | 'text'>>,
edits: ReadonlyMap<string, string>,
): string | null {
if (edits.size === 0 || segments.length === 0) return null
const lines = rawTranscript.split('\n')
const spans = segments.map((s) => s.text.split('\n').length)
const expectedLines = spans.reduce((sum, n) => sum + n, 0)
if (expectedLines !== lines.length) return null
const out: string[] = []
let cursor = 0
let changed = false
segments.forEach((segment, idx) => {
const block = lines.slice(cursor, cursor + spans[idx]).join('\n')
cursor += spans[idx]
const edited = edits.get(segment.id)
if (edited === undefined || edited === segment.text) {
out.push(block)
return
}
if (!block.endsWith(segment.text)) {
out.push(block)
return
}
out.push(block.slice(0, block.length - segment.text.length) + edited)
changed = true
})
// 구조 검증에서 끝을 맞추지 못한 세그먼트는 원문을 유지한다(부분 적용).
return changed ? out.join('\n') : null
}
/** 진행 중인 회의 세션에 대한 최소 계약 — MeetingModeService 가 어댑터로 구현한다. */
export interface LiveMeetingPort {
getStateInfo(): MeetingModeStateInfo
/** 구독하고, 해지 함수를 돌려준다 */
onSegment(listener: (segment: CaptionSegment) => void): () => void
onSessionCompleted(listener: (detail: MeetingSessionDetail) => void): () => void
saveEditedTranscript(sessionId: string, editedTranscript: string): void
}
export interface LiveSegmentEditLogger {
warn(message: string): void
info(message: string): void
}
export type LiveSegmentEditResult = 'ok' | 'not-live' | 'unknown-segment' | 'empty'
export class LiveSegmentEditor {
private sessionId: string | null = null
private segments: CaptionSegment[] = []
private readonly edits = new Map<string, string>()
private readonly unsubscribers: Array<() => void>
constructor(
private readonly port: LiveMeetingPort,
private readonly logger?: LiveSegmentEditLogger,
) {
this.unsubscribers = [
port.onSegment((segment) => this.recordSegment(segment)),
port.onSessionCompleted((detail) => this.flush(detail)),
]
}
/** 녹음·후처리 중인 세션의 세그먼트 수정을 기록한다. 실제 저장은 세션 완료 시. */
edit(sessionId: string, segmentId: string, text: string): LiveSegmentEditResult {
const state = this.port.getStateInfo()
if (state.state === 'idle' || state.sessionId !== sessionId) return 'not-live'
this.syncSession(sessionId)
if (!this.segments.some((s) => s.id === segmentId)) return 'unknown-segment'
const normalized = normalizeSegmentText(text)
if (!normalized) return 'empty'
this.edits.set(segmentId, normalized)
return 'ok'
}
dispose(): void {
for (const unsubscribe of this.unsubscribers) unsubscribe()
this.unsubscribers.length = 0
this.reset(null)
}
private recordSegment(segment: CaptionSegment): void {
const sessionId = this.port.getStateInfo().sessionId
if (!sessionId) return
this.syncSession(sessionId)
this.segments.push(segment)
}
private syncSession(sessionId: string): void {
if (this.sessionId !== sessionId) this.reset(sessionId)
}
private reset(sessionId: string | null): void {
this.sessionId = sessionId
this.segments = []
this.edits.clear()
}
/** 서비스의 emit 안에서 불린다 — 예외가 새면 후처리가 실패로 기록되므로 여기서 삼킨다. */
private flush(detail: MeetingSessionDetail): void {
try {
if (detail.id !== this.sessionId || this.edits.size === 0) return
const next = applyLiveSegmentEdits(detail.rawTranscript ?? '', this.segments, this.edits)
const editCount = this.edits.size
if (next === null) {
this.logger?.warn(`실시간 세그먼트 수정 ${editCount}건을 전사에 맞추지 못했습니다: sessionId=${detail.id}`)
return
}
this.port.saveEditedTranscript(detail.id, next)
this.logger?.info(`실시간 세그먼트 수정 ${editCount}건 반영: sessionId=${detail.id}`)
} catch (err) {
this.logger?.warn(`실시간 세그먼트 수정 반영 실패: ${err instanceof Error ? err.message : String(err)}`)
} finally {
this.reset(null)
}
}
}

View file

@ -24,6 +24,8 @@ import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage' import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage' import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal' import { SettingsModal } from './SettingsModal'
import { DEFAULT_SETTINGS_TAB, OPEN_SETTINGS_EVENT, readOpenSettingsTab } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
import { LicenseModal } from './LicenseModal' import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal' import { OnboardingModal } from './OnboardingModal'
import { AdBanner } from './ads/AdBanner' import { AdBanner } from './ads/AdBanner'
@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement {
const { t } = useI18n() const { t } = useI18n()
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard') const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [settingsInitialTab, setSettingsInitialTab] = useState<number>(0) const [settingsInitialTab, setSettingsInitialTab] = useState<SettingsTabId>(DEFAULT_SETTINGS_TAB)
const [onboardingOpen, setOnboardingOpen] = useState(false) const [onboardingOpen, setOnboardingOpen] = useState(false)
const [licenseModalOpen, setLicenseModalOpen] = useState(false) const [licenseModalOpen, setLicenseModalOpen] = useState(false)
const [supportModalOpen, setSupportModalOpen] = useState(false) const [supportModalOpen, setSupportModalOpen] = useState(false)
@ -107,16 +109,12 @@ export function AppLayout(): React.ReactElement {
const handleOpenLicenseModal = () => setLicenseModalOpen(true) const handleOpenLicenseModal = () => setLicenseModalOpen(true)
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal) window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
const handleOpenSettings = (e?: Event) => { // detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 창 방지)
const customEvent = e as CustomEvent<{ tab?: number }> | undefined const handleOpenSettings = (e: Event) => {
if (customEvent?.detail?.tab !== undefined) { setSettingsInitialTab(readOpenSettingsTab(e))
setSettingsInitialTab(customEvent.detail.tab)
} else {
setSettingsInitialTab(0)
}
setSettingsOpen(true) setSettingsOpen(true)
} }
window.addEventListener('d3ro:open-settings', handleOpenSettings as EventListener) window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
const unsubVoiceError = window.electronAPI.voice.onError((e) => { const unsubVoiceError = window.electronAPI.voice.onError((e) => {
const severity = e.severity ?? 'error' const severity = e.severity ?? 'error'
@ -146,7 +144,7 @@ export function AppLayout(): React.ReactElement {
unsubUpgradeReq() unsubUpgradeReq()
unsubVoiceError() unsubVoiceError()
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal) window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
window.removeEventListener('d3ro:open-settings', handleOpenSettings) window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
} }
}, [t]) }, [t])

View file

@ -38,21 +38,23 @@ import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding' import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types' import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
import { useKeyBindingMap } from '../hooks/useKeyBindingMap' import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
import { DEFAULT_SETTINGS_TAB, SETTINGS_TAB_ORDER } from '../navigation/settingsTabs'
import type { SettingsTabId } from '../navigation/settingsTabs'
interface SettingsModalProps { interface SettingsModalProps {
open: boolean open: boolean
initialTab?: number initialTab?: SettingsTabId
onClose: () => void onClose: () => void
} }
interface TabPanelProps { interface TabPanelProps {
children: React.ReactNode children: React.ReactNode
value: number value: SettingsTabId
index: number tab: SettingsTabId
} }
function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null { function TabPanel({ children, value, tab }: TabPanelProps): React.ReactElement | null {
if (value !== index) return null if (value !== tab) return null
return <Box sx={{ pt: 2 }}>{children}</Box> return <Box sx={{ pt: 2 }}>{children}</Box>
} }
@ -65,9 +67,22 @@ const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> =
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input'] const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input']
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement { export function SettingsModal({ open, initialTab = DEFAULT_SETTINGS_TAB, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n() const { t, locale, setLocale } = useI18n()
const [activeTab, setActiveTab] = useState(initialTab) const [activeTab, setActiveTab] = useState<SettingsTabId>(initialTab)
const tabMeta = useMemo<Record<SettingsTabId, { label: string; icon?: React.ReactElement }>>(
() => ({
general: { label: t('settings.tabs.general'), icon: <Keyboard size={14} /> },
audio: { label: t('settings.tabs.audio'), icon: <Mic size={14} /> },
stt: { label: t('settings.tabs.stt') },
llm: { label: t('settings.tabs.llm') },
input: { label: t('input.tab'), icon: <TextCursorInput size={14} /> },
license: { label: t('license.nav'), icon: <Lock size={14} /> },
cloud: { label: t('settings.tabs.cloud') ?? 'Cloud', icon: <Cloud size={14} /> },
about: { label: t('settings.tabs.about') },
}),
[t],
)
const [config, setConfig] = useState<Partial<AppConfig>>({}) const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(null) const [appVersion, setAppVersion] = useState<string | null>(null)
@ -222,7 +237,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 0 }}> <DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 0 }}>
<Tabs <Tabs
value={activeTab} value={activeTab}
onChange={(_, v: number) => setActiveTab(v)} onChange={(_, v: SettingsTabId) => setActiveTab(v)}
variant="scrollable" variant="scrollable"
scrollButtons={false} scrollButtons={false}
sx={{ sx={{
@ -243,19 +258,14 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
}} }}
> >
<Tab label={t('settings.tabs.general')} icon={<Keyboard size={14} />} iconPosition="start" /> {SETTINGS_TAB_ORDER.map((tab) => (
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} iconPosition="start" /> <Tab key={tab} value={tab} label={tabMeta[tab].label} icon={tabMeta[tab].icon} iconPosition="start" />
<Tab label={t('settings.tabs.stt')} /> ))}
<Tab label={t('settings.tabs.llm')} />
<Tab label={t('input.tab')} icon={<TextCursorInput size={14} />} iconPosition="start" />
<Tab label={t('license.nav')} icon={<Lock size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.cloud') ?? 'Cloud'} icon={<Cloud size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.about')} />
</Tabs> </Tabs>
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
{/* ── 일반 탭 ─────────────────────────────── */} {/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}> <TabPanel value={activeTab} tab="general">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}> <Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
@ -403,7 +413,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel> </TabPanel>
{/* ── 오디오 탭 ────────────────────────────── */} {/* ── 오디오 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={1}> <TabPanel value={activeTab} tab="audio">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}> <Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.microphone')} {t('settings.microphone')}
@ -539,12 +549,12 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel> </TabPanel>
{/* ── STT 탭 ───────────────────────────────── */} {/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}> <TabPanel value={activeTab} tab="stt">
<STTTab config={config} updateConfig={updateConfig} /> <STTTab config={config} updateConfig={updateConfig} />
</TabPanel> </TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */} {/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}> <TabPanel value={activeTab} tab="llm">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* LLM 백엔드 선택 */} {/* LLM 백엔드 선택 */}
<Box> <Box>
@ -802,21 +812,21 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</TabPanel> </TabPanel>
{/* ── 라이선스 탭 ────────────────────────────── */} {/* ── 라이선스 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={4}> <TabPanel value={activeTab} tab="input">
<InputConsentPanel /> <InputConsentPanel />
</TabPanel> </TabPanel>
<TabPanel value={activeTab} index={5}> <TabPanel value={activeTab} tab="license">
<LicenseTab /> <LicenseTab />
</TabPanel> </TabPanel>
{/* ── Cloud Sync 탭 ──────────────────────────── */} {/* ── Cloud Sync 탭 ──────────────────────────── */}
<TabPanel value={activeTab} index={6}> <TabPanel value={activeTab} tab="cloud">
<CloudSyncSection /> <CloudSyncSection />
</TabPanel> </TabPanel>
{/* ── 정보 탭 ──────────────────────────────── */} {/* ── 정보 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={7}> <TabPanel value={activeTab} tab="about">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}> <Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
D3RO-VOICE D3RO-VOICE

View file

@ -15,6 +15,7 @@ import {
import { Lock, CheckCircle2 } from 'lucide-react' import { Lock, CheckCircle2 } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n' import { useI18n } from '@d3ro/i18n'
import { openSettings } from '../navigation/settingsTabs'
import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types' import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types'
export function UpgradePromptModal(): React.ReactElement { export function UpgradePromptModal(): React.ReactElement {
@ -49,7 +50,7 @@ export function UpgradePromptModal(): React.ReactElement {
const openCloudSyncSettings = (): void => { const openCloudSyncSettings = (): void => {
setOpen(false) setOpen(false)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } })) openSettings('cloud')
} }
return ( return (

View file

@ -2,7 +2,7 @@
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) // Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript' import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments'
import { import {
Box, Box,
Tabs, Tabs,
@ -12,6 +12,8 @@ import {
Tooltip, Tooltip,
Typography, Typography,
Checkbox, Checkbox,
Snackbar,
Alert,
} from '@mui/material' } from '@mui/material'
import { import {
ArrowLeft, ArrowLeft,
@ -50,21 +52,6 @@ interface MeetingDetailTabsProps {
type ViewMode = 'split' | 'transcript' | 'doc' type ViewMode = 'split' | 'transcript' | 'doc'
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
function parseSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
// 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다.
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
id: `seg-${idx}`,
timestamp: line.timestampMs,
text: line.text,
edited: false,
speaker: line.speaker ?? undefined,
}))
}
// Extract Action items from documents // Extract Action items from documents
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> { function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
const lines = content.split('\n') const lines = content.split('\n')
@ -112,7 +99,7 @@ export function MeetingDetailTabs({
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반) // 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
const segments = useMemo( const segments = useMemo(
() => parseSegments(detail.rawTranscript, detail.editedTranscript), () => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript),
[detail.rawTranscript, detail.editedTranscript], [detail.rawTranscript, detail.editedTranscript],
) )
@ -165,14 +152,38 @@ export function MeetingDetailTabs({
}, [detail.id, titleDraft]) }, [detail.id, titleDraft])
// ── 세그먼트 편집 ── // ── 세그먼트 편집 ──
// 세그먼트는 전사 줄에서 파생되므로, 고친 줄로 전사 전체를 재조립해 updateTranscript 로 저장한다.
// 화면은 낙관적으로 먼저 바꾸고, 저장이 실패하면 이전 수정본으로 되돌린다.
const [segmentEditError, setSegmentEditError] = useState<string | null>(null)
const latestTranscriptRef = useRef<{ edited: string | null; raw: string | null }>({
edited: detail.editedTranscript,
raw: detail.rawTranscript,
})
latestTranscriptRef.current = { edited: detail.editedTranscript, raw: detail.rawTranscript }
const handleEditSegment = useCallback( const handleEditSegment = useCallback(
async (segmentId: string, newText: string) => { async (segmentId: string, newText: string) => {
await window.electronAPI.meetingMode.editSegment({ const { edited: previous, raw } = latestTranscriptRef.current
sessionId: detail.id, const next = applyTranscriptSegmentEdit(previous || raw, segmentId, newText)
segmentId, if (next === null) return
text: newText, latestTranscriptRef.current = { edited: next, raw }
}) setDetail((prev) => ({ ...prev, editedTranscript: next }))
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript })) const rollback = (message: string): void => {
if (latestTranscriptRef.current.edited === next) {
latestTranscriptRef.current = { edited: previous, raw }
setDetail((prev) => (prev.editedTranscript === next ? { ...prev, editedTranscript: previous } : prev))
}
setSegmentEditError(message)
}
try {
const resp = await window.electronAPI.meetingMode.updateTranscript({
sessionId: detail.id,
editedTranscript: next,
})
if (!resp.success) rollback(resp.error.message)
} catch (err: unknown) {
rollback(err instanceof Error ? err.message : String(err))
}
}, },
[detail.id], [detail.id],
) )
@ -639,6 +650,17 @@ export function MeetingDetailTabs({
generating={generating} generating={generating}
onGenerate={handleGenerate} onGenerate={handleGenerate}
/> />
<Snackbar
open={segmentEditError !== null}
autoHideDuration={4000}
onClose={() => setSegmentEditError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSegmentEditError(null)} sx={{ width: '100%' }}>
{segmentEditError}
</Alert>
</Snackbar>
</Box> </Box>
) )
} }

View file

@ -0,0 +1,62 @@
// src/renderer/components/meeting/transcript-segments.ts
// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환.
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과
// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript'
export interface TranscriptSegmentView {
id: string
timestamp: number
text: string
edited: boolean
speaker?: string
}
const SEGMENT_ID_PREFIX = 'seg-'
export function transcriptSegmentId(index: number): string {
return `${SEGMENT_ID_PREFIX}${index}`
}
/** `seg-<n>` → n. 형식이 아니면 null. */
export function transcriptSegmentIndex(segmentId: string): number | null {
if (!segmentId.startsWith(SEGMENT_ID_PREFIX)) return null
const raw = segmentId.slice(SEGMENT_ID_PREFIX.length)
if (!/^\d+$/.test(raw)) return null
return Number(raw)
}
/** 수정본이 있으면 수정본을, 없으면 원문을 세그먼트로 나눈다. */
export function parseTranscriptSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): TranscriptSegmentView[] {
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
id: transcriptSegmentId(idx),
timestamp: line.timestampMs,
text: line.text,
edited: false,
speaker: line.speaker ?? undefined,
}))
}
/**
* 세그먼트 하나의 텍스트를 바꾼 전사 전체를 돌려준다(시각·화자 표기는 유지).
* 세그먼트를 찾지 못했거나, 내용이 비었거나, 바뀐 것이 없으면 null.
*/
export function applyTranscriptSegmentEdit(
transcript: string | null,
segmentId: string,
newText: string,
): string | null {
const index = transcriptSegmentIndex(segmentId)
if (index === null) return null
const lines = parseTranscriptLines(transcript)
const target = lines[index]
if (!target) return null
const text = newText.replace(/\s*\r?\n\s*/g, ' ').trim()
if (!text || text === target.text) return null
const next = lines.map((line, idx) => (idx === index ? { ...line, text } : line))
return formatTranscriptLines(next)
}

View file

@ -8,6 +8,7 @@ import type {
UsageQuota, UsageQuota,
TierComparison, TierComparison,
} from '@d3ro/core/types' } from '@d3ro/core/types'
import { openSettings } from '../navigation/settingsTabs'
interface CloudState { interface CloudState {
authenticated: boolean authenticated: boolean
@ -69,7 +70,7 @@ export function useLicenseState(): LicenseState {
}, []) }, [])
const openCloudSettings = useCallback(() => { const openCloudSettings = useCallback(() => {
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } })) openSettings('cloud')
}, []) }, [])
return { return {

View file

@ -0,0 +1,59 @@
// src/renderer/navigation/settingsTabs.ts
// 설정 창 딥링크의 단일 진입점.
// 호출자는 SettingsModal 의 탭 순서(숫자 인덱스)가 아니라 이 탭 식별자에 의존한다.
// 'd3ro:open-settings' 이벤트를 보내는 곳(openSettings)과 읽는 곳(readOpenSettingsTab)이 모두 여기 있다.
/** SettingsModal 이 렌더하는 탭 순서 — 탭을 옮기면 이 배열만 바꾼다. */
export const SETTINGS_TAB_ORDER = [
'general',
'audio',
'stt',
'llm',
'input',
'license',
'cloud',
'about',
] as const
export type SettingsTabId = (typeof SETTINGS_TAB_ORDER)[number]
export const DEFAULT_SETTINGS_TAB: SettingsTabId = 'general'
export const OPEN_SETTINGS_EVENT = 'd3ro:open-settings'
export interface OpenSettingsDetail {
tab?: SettingsTabId
}
export function isSettingsTabId(value: unknown): value is SettingsTabId {
return typeof value === 'string' && (SETTINGS_TAB_ORDER as readonly string[]).includes(value)
}
export function settingsTabIndex(tab: SettingsTabId): number {
return SETTINGS_TAB_ORDER.indexOf(tab)
}
/**
* 이벤트 detail.tab 을 탭 식별자로 바꾼다. 타입 없는 DOM 이벤트 경계라 검증한다.
* 예전 호출부가 보내던 숫자 인덱스도 범위 안이면 받아 준다. 그 밖은 기본 탭.
*/
export function resolveSettingsTab(value: unknown): SettingsTabId {
if (isSettingsTabId(value)) return value
if (typeof value === 'number' && Number.isInteger(value)) {
return SETTINGS_TAB_ORDER[value] ?? DEFAULT_SETTINGS_TAB
}
return DEFAULT_SETTINGS_TAB
}
/** 'd3ro:open-settings' 이벤트에서 열 탭을 읽는다(detail 이 없으면 기본 탭). */
export function readOpenSettingsTab(event: Event): SettingsTabId {
const detail: unknown = event instanceof CustomEvent ? event.detail : undefined
if (typeof detail !== 'object' || detail === null) return DEFAULT_SETTINGS_TAB
return resolveSettingsTab((detail as { tab?: unknown }).tab)
}
/** 설정 창을 연다. 탭을 주지 않으면 기본 탭. */
export function openSettings(tab?: SettingsTabId, target: EventTarget = window): void {
const detail: OpenSettingsDetail = tab === undefined ? {} : { tab }
target.dispatchEvent(new CustomEvent<OpenSettingsDetail>(OPEN_SETTINGS_EVENT, { detail }))
}

View file

@ -49,6 +49,13 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment' import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared' import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard' import { isImeComposingEvent } from '../utils/keyboard'
import { openSettings } from '../navigation/settingsTabs'
import {
isActiveMeetingState,
recordingStartFromElapsed,
replaceSegmentText,
rollbackSegmentText,
} from './meeting/live-session'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n' import { useI18n } from '@d3ro/i18n'
import type { import type {
@ -98,6 +105,10 @@ export function MeetingModePage(): React.ReactElement {
// 녹음 뷰 상태 // 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null) const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([]) const [segments, setSegments] = useState<CaptionSegment[]>([])
const segmentsRef = useRef<CaptionSegment[]>(segments)
segmentsRef.current = segments
/** startRecording 응답의 세션 id — stateChanged 이벤트보다 먼저 편집이 와도 쓸 수 있게 */
const [liveSessionId, setLiveSessionId] = useState<string | null>(null)
const [memos, setMemos] = useState<MeetingMemo[]>([]) const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('') const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0) const [elapsedMs, setElapsedMs] = useState(0)
@ -134,6 +145,28 @@ export function MeetingModePage(): React.ReactElement {
checkPreflight() checkPreflight()
}, [checkPreflight]) }, [checkPreflight])
// ── 진행 중 세션 복원 ──
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
useEffect(() => {
let cancelled = false
window.electronAPI.meetingMode
.getState()
.then((resp) => {
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
const info = resp.data
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
setStateInfo(info)
setLiveSessionId(info.sessionId)
setElapsedMs(info.elapsedMs)
setView('recording')
})
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
return () => {
cancelled = true
}
}, [])
// ── 에러 파서 ── // ── 에러 파서 ──
const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => { const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => {
const msg = (errMessage ?? '').toLowerCase() const msg = (errMessage ?? '').toLowerCase()
@ -268,7 +301,8 @@ export function MeetingModePage(): React.ReactElement {
// ── 경과 시간 타이머 ── // ── 경과 시간 타이머 ──
useEffect(() => { useEffect(() => {
if (view === 'recording') { if (view === 'recording') {
recordingStartRef.current = Date.now() // 시작 시각은 뷰로 들어오는 쪽(새 녹음 / 세션 복원)이 정한다
if (!recordingStartRef.current) recordingStartRef.current = Date.now()
elapsedTimerRef.current = setInterval(() => { elapsedTimerRef.current = setInterval(() => {
setElapsedMs(Date.now() - recordingStartRef.current) setElapsedMs(Date.now() - recordingStartRef.current)
}, 1000) }, 1000)
@ -288,6 +322,8 @@ export function MeetingModePage(): React.ReactElement {
try { try {
const resp = await window.electronAPI.meetingMode.startRecording({ force }) const resp = await window.electronAPI.meetingMode.startRecording({ force })
if (resp.success) { if (resp.success) {
recordingStartRef.current = Date.now()
setLiveSessionId(resp.data.sessionId)
setSegments([]) setSegments([])
setMemos([]) setMemos([])
setElapsedMs(0) setElapsedMs(0)
@ -311,20 +347,33 @@ export function MeetingModePage(): React.ReactElement {
}, []) }, [])
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ── // ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
const liveSessionIdForEdit = stateInfo?.sessionId ?? liveSessionId
const handleLiveEditSegment = useCallback( const handleLiveEditSegment = useCallback(
async (segmentId: string, newText: string) => { async (segmentId: string, newText: string) => {
setSegments((prev) => const previousText = segmentsRef.current.find((s) => s.id === segmentId)?.text
prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)), if (previousText === undefined || previousText === newText) return
) setSegments((prev) => replaceSegmentText(prev, segmentId, newText))
if (stateInfo?.sessionId) { const rollback = (message: string): void => {
await window.electronAPI.meetingMode.editSegment({ setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText))
sessionId: stateInfo.sessionId, setSnackbarMsg(message)
}
if (!liveSessionIdForEdit) {
rollback(t('meeting.error'))
return
}
try {
const resp = await window.electronAPI.meetingMode.editSegment({
sessionId: liveSessionIdForEdit,
segmentId, segmentId,
text: newText, text: newText,
}) })
if (!resp.success) rollback(resp.error.message)
} catch (err: unknown) {
rollback(err instanceof Error ? err.message : String(err))
} }
}, },
[stateInfo?.sessionId], [liveSessionIdForEdit, t],
) )
const isAddingMemoRef = useRef(false) const isAddingMemoRef = useRef(false)
@ -507,7 +556,7 @@ export function MeetingModePage(): React.ReactElement {
size="small" size="small"
onClick={() => { onClick={() => {
setErrorInfo(null) setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } })) openSettings('stt')
}} }}
leadingIcon={<Settings size={13} />} leadingIcon={<Settings size={13} />}
> >
@ -521,7 +570,7 @@ export function MeetingModePage(): React.ReactElement {
size="small" size="small"
onClick={() => { onClick={() => {
setErrorInfo(null) setErrorInfo(null)
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } })) openSettings('audio')
}} }}
leadingIcon={<Mic size={13} />} leadingIcon={<Mic size={13} />}
> >
@ -706,6 +755,7 @@ export function MeetingModePage(): React.ReactElement {
</Box> </Box>
)} )}
{snackbar} {snackbar}
{errorDialog}
</Box> </Box>
) )
} }

View file

@ -0,0 +1,44 @@
// src/renderer/pages/meeting/live-session.ts
// 회의 녹음 뷰의 순수 정책 — 페이지가 다시 마운트될 때 진행 중 세션 복원, 실시간 세그먼트 편집.
import type { CaptionSegment, MeetingModeState } from '@d3ro/core/types'
/** main 에서 녹음 또는 후처리가 진행 중이면 녹음 뷰를 보여 줘야 한다(정지·진행률 표시). */
export function isActiveMeetingState(state: MeetingModeState | null | undefined): boolean {
return state === 'recording' || state === 'processing'
}
/** main 이 알려 준 경과 시간으로 녹음 시작 시각을 되짚는다(경과 타이머·세그먼트 상대 시각 기준). */
export function recordingStartFromElapsed(elapsedMs: number, now: number): number {
return now - Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0)
}
/** 세그먼트 하나의 텍스트를 바꾼 새 배열. 해당 세그먼트가 없으면 원래 배열 그대로. */
export function replaceSegmentText(
segments: readonly CaptionSegment[],
segmentId: string,
text: string,
): CaptionSegment[] {
let changed = false
const next = segments.map((s) => {
if (s.id !== segmentId || s.text === text) return s
changed = true
return { ...s, text }
})
return changed ? next : (segments as CaptionSegment[])
}
/**
* 낙관적 편집을 되돌린다 — 그 사이 같은 세그먼트를 다시 고쳤다면(현재 텍스트가 실패한 편집과 다르면)
* 최신 편집을 덮어쓰지 않는다.
*/
export function rollbackSegmentText(
segments: readonly CaptionSegment[],
segmentId: string,
failedText: string,
previousText: string,
): CaptionSegment[] {
const current = segments.find((s) => s.id === segmentId)
if (!current || current.text !== failedText) return segments as CaptionSegment[]
return replaceSegmentText(segments, segmentId, previousText)
}

View file

@ -0,0 +1,116 @@
import { EventEmitter } from 'events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import type { IPCResult } from '@d3ro/core/errors'
import type { CaptionSegment, MeetingModeStateInfo } from '@d3ro/core/types'
// 회의 전사 세그먼트 편집이 main 에 핸들러가 없는 채널을 호출하던 회귀
// (preload editSegment → 'meetingMode:editSegment' → No handler registered).
const invokeHandlers = vi.hoisted(
() => new Map<string, (event: unknown, ...args: unknown[]) => unknown>(),
)
type FakeMeetingService = EventEmitter & {
stateInfo: MeetingModeStateInfo
getStateInfo: () => MeetingModeStateInfo
updateTranscript: ReturnType<typeof vi.fn>
}
// vi.mock 팩토리는 모듈을 처음 import 할 때(beforeEach 안) 불리므로 이 상수는 이미 초기화되어 있다.
const service = Object.assign(new EventEmitter(), {
stateInfo: { state: 'recording', sessionId: 's1', elapsedMs: 0, memoCount: 0, segmentCount: 0 },
getStateInfo(this: FakeMeetingService): MeetingModeStateInfo {
return this.stateInfo
},
updateTranscript: vi.fn(),
}) as FakeMeetingService
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, ...args: unknown[]) => unknown) => {
invokeHandlers.set(channel, handler)
}),
on: vi.fn(),
},
}))
vi.mock('../../../src/main/services/MeetingModeService', () => ({
getMeetingModeService: () => service,
}))
async function invoke<T>(channel: string, ...args: unknown[]): Promise<IPCResult<T>> {
const handler = invokeHandlers.get(channel)
if (!handler) throw new Error(`No handler registered for '${channel}'`)
return (await handler({}, ...args)) as IPCResult<T>
}
function segment(id: string, text: string): CaptionSegment {
return { id, text, timestamp: 0, isFinal: true }
}
beforeEach(async () => {
vi.resetModules()
invokeHandlers.clear()
service.removeAllListeners()
service.updateTranscript.mockReset()
service.stateInfo = { state: 'recording', sessionId: 's1', elapsedMs: 0, memoCount: 0, segmentCount: 0 }
const mod = await import('../../../src/main/ipc/meeting-mode-handlers')
mod.registerMeetingModeHandlers()
})
describe('MEETING_MODE.EDIT_SEGMENT', () => {
it('핸들러가 등록되어 있다', () => {
expect(invokeHandlers.has(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT)).toBe(true)
})
it('녹음 중 수정이 세션 완료 시 editedTranscript 로 저장된다', async () => {
service.emit('segment', segment('a', '안녕하세요'))
service.emit('segment', segment('b', '오늘 안건'))
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'b',
text: '오늘 안건은 예산',
})
expect(res.success).toBe(true)
service.emit('session-completed', {
id: 's1',
rawTranscript: '[00:01] 안녕하세요\n[00:02] 오늘 안건',
})
expect(service.updateTranscript).toHaveBeenCalledWith(
's1',
'[00:01] 안녕하세요\n[00:02] 오늘 안건은 예산',
)
})
it('진행 중이 아닌 세션이면 MeetingNotRecording 으로 거부한다', async () => {
service.stateInfo = { ...service.stateInfo, state: 'idle', sessionId: null }
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'a',
text: 'x',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingNotRecording)
})
it('모르는 세그먼트는 MeetingTranscriptUpdateFailed 로 거부한다', async () => {
const res = await invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, {
sessionId: 's1',
segmentId: 'nope',
text: 'x',
})
expect(res.success).toBe(false)
if (!res.success) expect(res.error.code).toBe(ErrorCode.MeetingTranscriptUpdateFailed)
})
it('다시 등록해도 세션 완료 리스너가 중복되지 않는다', async () => {
const mod = await import('../../../src/main/ipc/meeting-mode-handlers')
mod.registerMeetingModeHandlers()
expect(service.listenerCount('session-completed')).toBe(1)
expect(service.listenerCount('segment')).toBe(1)
})
})

View file

@ -0,0 +1,156 @@
import { describe, expect, it, vi } from 'vitest'
import type {
CaptionSegment,
MeetingModeStateInfo,
MeetingSessionDetail,
} from '@d3ro/core/types'
import {
applyLiveSegmentEdits,
LiveSegmentEditor,
normalizeSegmentText,
} from '../../../src/main/services/meeting/live-segment-edits'
import type { LiveMeetingPort } from '../../../src/main/services/meeting/live-segment-edits'
function seg(id: string, text: string, timestamp = 0): CaptionSegment {
return { id, text, timestamp, isFinal: true }
}
function detail(id: string, rawTranscript: string | null): MeetingSessionDetail {
return { id, rawTranscript } as unknown as MeetingSessionDetail
}
function fakePort(initial: Partial<MeetingModeStateInfo> = {}) {
let state: MeetingModeStateInfo = {
state: 'recording',
sessionId: 's1',
elapsedMs: 0,
memoCount: 0,
segmentCount: 0,
...initial,
}
const segmentListeners = new Set<(s: CaptionSegment) => void>()
const completedListeners = new Set<(d: MeetingSessionDetail) => void>()
const saveEditedTranscript = vi.fn<(sessionId: string, text: string) => void>()
const port: LiveMeetingPort = {
getStateInfo: () => state,
onSegment: (l) => {
segmentListeners.add(l)
return () => segmentListeners.delete(l)
},
onSessionCompleted: (l) => {
completedListeners.add(l)
return () => completedListeners.delete(l)
},
saveEditedTranscript,
}
return {
port,
saveEditedTranscript,
setState: (next: Partial<MeetingModeStateInfo>) => { state = { ...state, ...next } },
emitSegment: (s: CaptionSegment) => segmentListeners.forEach((l) => l(s)),
emitCompleted: (d: MeetingSessionDetail) => completedListeners.forEach((l) => l(d)),
listenerCount: () => segmentListeners.size + completedListeners.size,
}
}
describe('applyLiveSegmentEdits', () => {
const segments = [seg('a', '안녕하세요'), seg('b', '오늘 안건은'), seg('c', '예산입니다')]
const raw = '[00:01] 안녕하세요\n[00:02] 오늘 안건은\n[00:03] 예산입니다'
it('고친 세그먼트 줄의 텍스트만 바꾸고 시각 접두사는 유지한다', () => {
const out = applyLiveSegmentEdits(raw, segments, new Map([['b', '오늘 안건은 두 가지']]))
expect(out).toBe('[00:01] 안녕하세요\n[00:02] 오늘 안건은 두 가지\n[00:03] 예산입니다')
})
it('접두사 형식과 무관하게 동작한다', () => {
const odd = '[29000000:01] 안녕하세요\n[29000000:02] 오늘 안건은\n[29000000:03] 예산입니다'
expect(applyLiveSegmentEdits(odd, segments, new Map([['c', '예산 확정']]))).toBe(
'[29000000:01] 안녕하세요\n[29000000:02] 오늘 안건은\n[29000000:03] 예산 확정',
)
})
it('줄바꿈이 든 세그먼트가 있어도 올바른 줄 묶음을 바꾼다', () => {
const multi = [seg('a', '첫 줄\n둘째 줄'), seg('b', '다음')]
const text = '[00:01] 첫 줄\n둘째 줄\n[00:02] 다음'
expect(applyLiveSegmentEdits(text, multi, new Map([['b', '다음 발화']]))).toBe(
'[00:01] 첫 줄\n둘째 줄\n[00:02] 다음 발화',
)
})
it('줄 수가 세그먼트와 맞지 않으면 덮어쓰지 않고 null', () => {
expect(applyLiveSegmentEdits('[00:01] 안녕하세요', segments, new Map([['a', 'x']]))).toBeNull()
})
it('수정이 없거나 원문과 같으면 null', () => {
expect(applyLiveSegmentEdits(raw, segments, new Map())).toBeNull()
expect(applyLiveSegmentEdits(raw, segments, new Map([['a', '안녕하세요']]))).toBeNull()
})
it('줄바꿈은 공백으로 접는다', () => {
expect(normalizeSegmentText(' 하나\n 둘 \r\n셋 ')).toBe('하나 둘 셋')
})
})
describe('LiveSegmentEditor', () => {
it('녹음 중 수정은 세션 완료 시 editedTranscript 로 저장된다(원문은 그대로)', () => {
const f = fakePort()
const editor = new LiveSegmentEditor(f.port)
f.emitSegment(seg('a', '안녕하세요'))
f.emitSegment(seg('b', '오늘 안건'))
expect(editor.edit('s1', 'b', '오늘 안건은 예산')).toBe('ok')
f.setState({ state: 'processing' })
f.emitCompleted(detail('s1', '[00:01] 안녕하세요\n[00:02] 오늘 안건'))
expect(f.saveEditedTranscript).toHaveBeenCalledWith('s1', '[00:01] 안녕하세요\n[00:02] 오늘 안건은 예산')
})
it('수정이 없으면 저장하지 않는다', () => {
const f = fakePort()
new LiveSegmentEditor(f.port)
f.emitSegment(seg('a', '안녕'))
f.emitCompleted(detail('s1', '[00:01] 안녕'))
expect(f.saveEditedTranscript).not.toHaveBeenCalled()
})
it('진행 중이 아닌 세션·모르는 세그먼트·빈 내용은 거부한다', () => {
const f = fakePort()
const editor = new LiveSegmentEditor(f.port)
f.emitSegment(seg('a', '안녕'))
expect(editor.edit('other', 'a', 'x')).toBe('not-live')
expect(editor.edit('s1', 'zzz', 'x')).toBe('unknown-segment')
expect(editor.edit('s1', 'a', ' \n ')).toBe('empty')
f.setState({ state: 'idle', sessionId: null })
expect(editor.edit('s1', 'a', 'x')).toBe('not-live')
})
it('다음 세션은 이전 세션의 세그먼트·수정을 물려받지 않는다', () => {
const f = fakePort()
const editor = new LiveSegmentEditor(f.port)
f.emitSegment(seg('a', '첫 회의'))
editor.edit('s1', 'a', '첫 회의 수정')
f.setState({ sessionId: 's2' })
f.emitSegment(seg('b', '둘째 회의'))
f.emitCompleted(detail('s2', '[00:01] 둘째 회의'))
expect(f.saveEditedTranscript).not.toHaveBeenCalled()
})
it('저장이 던져도 서비스 emit 으로 예외를 올리지 않는다', () => {
const f = fakePort()
f.saveEditedTranscript.mockImplementation(() => { throw new Error('db') })
const warn = vi.fn()
const editor = new LiveSegmentEditor(f.port, { warn, info: vi.fn() })
f.emitSegment(seg('a', '안녕'))
editor.edit('s1', 'a', '반가워요')
expect(() => f.emitCompleted(detail('s1', '[00:01] 안녕'))).not.toThrow()
expect(warn).toHaveBeenCalled()
})
it('dispose 하면 구독을 해지한다', () => {
const f = fakePort()
const editor = new LiveSegmentEditor(f.port)
expect(f.listenerCount()).toBe(2)
editor.dispose()
expect(f.listenerCount()).toBe(0)
})
})

View file

@ -0,0 +1,107 @@
import fs from 'fs'
import path from 'path'
import { describe, expect, it } from 'vitest'
import type { CaptionSegment } from '@d3ro/core/types'
import {
applyTranscriptSegmentEdit,
parseTranscriptSegments,
transcriptSegmentIndex,
} from '../../src/renderer/components/meeting/transcript-segments'
import {
isActiveMeetingState,
recordingStartFromElapsed,
replaceSegmentText,
rollbackSegmentText,
} from '../../src/renderer/pages/meeting/live-session'
describe('상세 뷰 세그먼트 편집 — 전사 재조립', () => {
const raw = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은\n[01:10] 예산입니다'
it('세그먼트 id 는 줄 순번이다', () => {
const segs = parseTranscriptSegments(raw, null)
expect(segs.map((s) => s.id)).toEqual(['seg-0', 'seg-1', 'seg-2'])
expect(segs[0].speaker).toBe('화자 1')
expect(transcriptSegmentIndex('seg-12')).toBe(12)
expect(transcriptSegmentIndex('x-1')).toBeNull()
expect(transcriptSegmentIndex('seg-')).toBeNull()
})
it('고친 줄만 바뀐 전사를 돌려준다(시각·화자 유지)', () => {
const next = applyTranscriptSegmentEdit(raw, 'seg-1', '오늘 안건은 두 가지')
expect(next).toBe('[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은 두 가지\n[01:10] 예산입니다')
// 재조립한 전사를 다시 나누면 수정이 그대로 보인다 — 원문으로 되돌아가지 않는다
expect(parseTranscriptSegments(raw, next)[1].text).toBe('오늘 안건은 두 가지')
})
it('기존 수정본 위에 이어서 고친다', () => {
const first = applyTranscriptSegmentEdit(raw, 'seg-0', '반갑습니다')
const second = applyTranscriptSegmentEdit(first, 'seg-2', '예산 확정')
const texts = parseTranscriptSegments(raw, second).map((s) => s.text)
expect(texts).toEqual(['반갑습니다', '오늘 안건은', '예산 확정'])
})
it('없는 세그먼트·빈 내용·같은 내용이면 null', () => {
expect(applyTranscriptSegmentEdit(raw, 'seg-9', 'x')).toBeNull()
expect(applyTranscriptSegmentEdit(raw, 'bogus', 'x')).toBeNull()
expect(applyTranscriptSegmentEdit(raw, 'seg-0', ' ')).toBeNull()
expect(applyTranscriptSegmentEdit(raw, 'seg-0', '안녕하세요')).toBeNull()
})
it('줄바꿈은 한 줄로 접어 다른 세그먼트를 만들지 않는다', () => {
const next = applyTranscriptSegmentEdit(raw, 'seg-1', '둘\n셋')
expect(parseTranscriptSegments(raw, next)).toHaveLength(3)
})
})
describe('녹음 뷰 — 세션 복원과 실시간 편집 롤백', () => {
const segs: CaptionSegment[] = [
{ id: 'a', text: '하나', timestamp: 0, isFinal: true },
{ id: 'b', text: '둘', timestamp: 0, isFinal: true },
]
it('녹음·후처리 중이면 녹음 뷰를 복원한다', () => {
expect(isActiveMeetingState('recording')).toBe(true)
expect(isActiveMeetingState('processing')).toBe(true)
expect(isActiveMeetingState('idle')).toBe(false)
expect(isActiveMeetingState(null)).toBe(false)
})
it('경과 시간으로 시작 시각을 되짚는다', () => {
expect(recordingStartFromElapsed(5_000, 100_000)).toBe(95_000)
expect(recordingStartFromElapsed(-1, 100_000)).toBe(100_000)
expect(recordingStartFromElapsed(Number.NaN, 100_000)).toBe(100_000)
})
it('실패한 편집만 되돌리고, 그 사이 다시 고친 내용은 덮어쓰지 않는다', () => {
const edited = replaceSegmentText(segs, 'b', '둘 수정')
expect(edited[1].text).toBe('둘 수정')
expect(rollbackSegmentText(edited, 'b', '둘 수정', '둘')[1].text).toBe('둘')
const reEdited = replaceSegmentText(edited, 'b', '둘 재수정')
expect(rollbackSegmentText(reEdited, 'b', '둘 수정', '둘')[1].text).toBe('둘 재수정')
})
})
describe('MeetingModePage 구조 회귀', () => {
const source = fs.readFileSync(
path.join(__dirname, '../../src/renderer/pages/MeetingModePage.tsx'),
'utf-8',
)
it('목록 뷰도 오류 다이얼로그(강제 초기화 버튼)를 렌더한다', () => {
const start = source.search(/if \(view === 'list'\) \{\s*return \(/)
expect(start).toBeGreaterThan(-1)
const end = source.indexOf("if (view === 'recording')", start)
expect(end).toBeGreaterThan(start)
const listBranch = source.slice(start, end)
expect(listBranch).toContain('{errorDialog}')
})
it('마운트 시 main 의 회의 상태를 물어 진행 중 녹음을 복원한다', () => {
expect(source).toMatch(/meetingMode\s*\.getState\(\)/)
})
it('설정 딥링크에 숫자 인덱스를 쓰지 않는다', () => {
expect(source).not.toMatch(/detail:\s*\{\s*tab:\s*\d/)
})
})

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
OPEN_SETTINGS_EVENT,
SETTINGS_TAB_ORDER,
isSettingsTabId,
openSettings,
readOpenSettingsTab,
resolveSettingsTab,
settingsTabIndex,
} from '../../src/renderer/navigation/settingsTabs'
import type { SettingsTabId } from '../../src/renderer/navigation/settingsTabs'
// 로그인 CTA 가 detail.tab='cloud' 를 보내면 AppLayout 이 그대로 숫자 인덱스 자리에 넣어
// 아무 패널도 없는 빈 설정 창이 열리던 회귀.
function capture(target: EventTarget): () => SettingsTabId | null {
let received: SettingsTabId | null = null
target.addEventListener(OPEN_SETTINGS_EVENT, (e) => {
received = readOpenSettingsTab(e)
})
return () => received
}
describe('settingsTabs', () => {
it('탭 순서는 SettingsModal 의 8개 탭과 같고 cloud 는 6번째(0 기준)다', () => {
expect(SETTINGS_TAB_ORDER).toEqual(['general', 'audio', 'stt', 'llm', 'input', 'license', 'cloud', 'about'])
expect(settingsTabIndex('cloud')).toBe(6)
expect(settingsTabIndex('stt')).toBe(2)
expect(settingsTabIndex('audio')).toBe(1)
})
it('알려진 id 는 그대로, 모르는 값은 general 로 바꾼다', () => {
expect(resolveSettingsTab('cloud')).toBe('cloud')
expect(resolveSettingsTab('nope')).toBe('general')
expect(resolveSettingsTab(undefined)).toBe('general')
expect(resolveSettingsTab(null)).toBe('general')
expect(resolveSettingsTab(99)).toBe('general')
expect(resolveSettingsTab(1.5)).toBe('general')
})
it('예전 숫자 인덱스 딥링크도 같은 탭으로 연다', () => {
expect(resolveSettingsTab(2)).toBe('stt')
expect(resolveSettingsTab(1)).toBe('audio')
})
it('isSettingsTabId 는 문자열 id 만 통과시킨다', () => {
expect(isSettingsTabId('about')).toBe(true)
expect(isSettingsTabId(0)).toBe(false)
expect(isSettingsTabId('Cloud')).toBe(false)
})
it("openSettings('cloud') 를 받은 쪽은 cloud 탭을 연다", () => {
const target = new EventTarget()
const received = capture(target)
openSettings('cloud', target)
expect(received()).toBe('cloud')
})
it('탭 없이 열거나 detail 없는 이벤트는 general', () => {
const target = new EventTarget()
const received = capture(target)
openSettings(undefined, target)
expect(received()).toBe('general')
target.dispatchEvent(new Event(OPEN_SETTINGS_EVENT))
expect(received()).toBe('general')
})
it("예전 문자열 'cloud' detail 을 보내던 이벤트도 빈 창이 아니라 cloud 로 읽힌다", () => {
const event = new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { tab: 'cloud' } })
expect(readOpenSettingsTab(event)).toBe('cloud')
expect(readOpenSettingsTab(new CustomEvent(OPEN_SETTINGS_EVENT, { detail: { tab: 'bogus' } }))).toBe('general')
})
})