fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id
This commit is contained in:
parent
a8983c583a
commit
a85b24d385
15 changed files with 974 additions and 69 deletions
|
|
@ -24,6 +24,8 @@ import { VoiceConversationPage } from '../pages/VoiceConversationPage'
|
|||
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
|
||||
import { MeetingModePage } from '../pages/MeetingModePage'
|
||||
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 { OnboardingModal } from './OnboardingModal'
|
||||
import { AdBanner } from './ads/AdBanner'
|
||||
|
|
@ -74,7 +76,7 @@ export function AppLayout(): React.ReactElement {
|
|||
const { t } = useI18n()
|
||||
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||
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 [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
||||
const [supportModalOpen, setSupportModalOpen] = useState(false)
|
||||
|
|
@ -107,16 +109,12 @@ export function AppLayout(): React.ReactElement {
|
|||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
|
||||
const handleOpenSettings = (e?: Event) => {
|
||||
const customEvent = e as CustomEvent<{ tab?: number }> | undefined
|
||||
if (customEvent?.detail?.tab !== undefined) {
|
||||
setSettingsInitialTab(customEvent.detail.tab)
|
||||
} else {
|
||||
setSettingsInitialTab(0)
|
||||
}
|
||||
// detail.tab 은 타입 없는 DOM 경계라 검증한다 — 모르는 값이면 기본 탭(빈 설정 창 방지)
|
||||
const handleOpenSettings = (e: Event) => {
|
||||
setSettingsInitialTab(readOpenSettingsTab(e))
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
window.addEventListener('d3ro:open-settings', handleOpenSettings as EventListener)
|
||||
window.addEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
|
||||
|
||||
const unsubVoiceError = window.electronAPI.voice.onError((e) => {
|
||||
const severity = e.severity ?? 'error'
|
||||
|
|
@ -146,7 +144,7 @@ export function AppLayout(): React.ReactElement {
|
|||
unsubUpgradeReq()
|
||||
unsubVoiceError()
|
||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
|
||||
window.removeEventListener(OPEN_SETTINGS_EVENT, handleOpenSettings)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
|
|
|
|||
|
|
@ -38,21 +38,23 @@ import type { KeyBindingActionGroup } 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 { useKeyBindingMap } from '../hooks/useKeyBindingMap'
|
||||
import { DEFAULT_SETTINGS_TAB, SETTINGS_TAB_ORDER } from '../navigation/settingsTabs'
|
||||
import type { SettingsTabId } from '../navigation/settingsTabs'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
initialTab?: number
|
||||
initialTab?: SettingsTabId
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children: React.ReactNode
|
||||
value: number
|
||||
index: number
|
||||
value: SettingsTabId
|
||||
tab: SettingsTabId
|
||||
}
|
||||
|
||||
function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null {
|
||||
if (value !== index) return null
|
||||
function TabPanel({ children, value, tab }: TabPanelProps): React.ReactElement | null {
|
||||
if (value !== tab) return null
|
||||
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']
|
||||
|
||||
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 [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 [loading, setLoading] = useState(true)
|
||||
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 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(_, v: number) => setActiveTab(v)}
|
||||
onChange={(_, v: SettingsTabId) => setActiveTab(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons={false}
|
||||
sx={{
|
||||
|
|
@ -243,19 +258,14 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label={t('settings.tabs.general')} icon={<Keyboard size={14} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} 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')} />
|
||||
{SETTINGS_TAB_ORDER.map((tab) => (
|
||||
<Tab key={tab} value={tab} label={tabMeta[tab].label} icon={tabMeta[tab].icon} iconPosition="start" />
|
||||
))}
|
||||
</Tabs>
|
||||
<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', alignItems: 'center', gap: 1 }}>
|
||||
<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 value={activeTab} index={1}>
|
||||
<TabPanel value={activeTab} tab="audio">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.microphone')}
|
||||
|
|
@ -539,12 +549,12 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
</TabPanel>
|
||||
|
||||
{/* ── STT 탭 ───────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<TabPanel value={activeTab} tab="stt">
|
||||
<STTTab config={config} updateConfig={updateConfig} />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── LLM 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<TabPanel value={activeTab} tab="llm">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* LLM 백엔드 선택 */}
|
||||
<Box>
|
||||
|
|
@ -802,21 +812,21 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
</TabPanel>
|
||||
|
||||
{/* ── 라이선스 탭 ────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<TabPanel value={activeTab} tab="input">
|
||||
<InputConsentPanel />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<TabPanel value={activeTab} tab="license">
|
||||
<LicenseTab />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Cloud Sync 탭 ──────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={6}>
|
||||
<TabPanel value={activeTab} tab="cloud">
|
||||
<CloudSyncSection />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── 정보 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={7}>
|
||||
<TabPanel value={activeTab} tab="about">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
D3RO-VOICE
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { Lock, CheckCircle2 } from 'lucide-react'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { openSettings } from '../navigation/settingsTabs'
|
||||
import type { UpgradePromptEvent, UsageQuota } from '@d3ro/core/types'
|
||||
|
||||
export function UpgradePromptModal(): React.ReactElement {
|
||||
|
|
@ -49,7 +50,7 @@ export function UpgradePromptModal(): React.ReactElement {
|
|||
|
||||
const openCloudSyncSettings = (): void => {
|
||||
setOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
|
||||
openSettings('cloud')
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
|
||||
import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments'
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
|
|
@ -12,6 +12,8 @@ import {
|
|||
Tooltip,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Snackbar,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -50,21 +52,6 @@ interface MeetingDetailTabsProps {
|
|||
|
||||
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
|
||||
function extractActionItems(content: string): Array<{ id: string; text: string; done: boolean }> {
|
||||
const lines = content.split('\n')
|
||||
|
|
@ -112,7 +99,7 @@ export function MeetingDetailTabs({
|
|||
|
||||
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
|
||||
const segments = useMemo(
|
||||
() => parseSegments(detail.rawTranscript, detail.editedTranscript),
|
||||
() => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript),
|
||||
[detail.rawTranscript, detail.editedTranscript],
|
||||
)
|
||||
|
||||
|
|
@ -165,14 +152,38 @@ export function MeetingDetailTabs({
|
|||
}, [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(
|
||||
async (segmentId: string, newText: string) => {
|
||||
await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: detail.id,
|
||||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
|
||||
const { edited: previous, raw } = latestTranscriptRef.current
|
||||
const next = applyTranscriptSegmentEdit(previous || raw, segmentId, newText)
|
||||
if (next === null) return
|
||||
latestTranscriptRef.current = { edited: next, raw }
|
||||
setDetail((prev) => ({ ...prev, editedTranscript: next }))
|
||||
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],
|
||||
)
|
||||
|
|
@ -639,6 +650,17 @@ export function MeetingDetailTabs({
|
|||
generating={generating}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
UsageQuota,
|
||||
TierComparison,
|
||||
} from '@d3ro/core/types'
|
||||
import { openSettings } from '../navigation/settingsTabs'
|
||||
|
||||
interface CloudState {
|
||||
authenticated: boolean
|
||||
|
|
@ -69,7 +70,7 @@ export function useLicenseState(): LicenseState {
|
|||
}, [])
|
||||
|
||||
const openCloudSettings = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
|
||||
openSettings('cloud')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
|
|
|
|||
59
apps/desktop/src/renderer/navigation/settingsTabs.ts
Normal file
59
apps/desktop/src/renderer/navigation/settingsTabs.ts
Normal 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 }))
|
||||
}
|
||||
|
|
@ -49,6 +49,13 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
|
|||
import { EditableSegment } from '../components/meeting/EditableSegment'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
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 { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
|
|
@ -98,6 +105,10 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// 녹음 뷰 상태
|
||||
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
|
||||
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 [memoInput, setMemoInput] = useState('')
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
|
@ -134,6 +145,28 @@ export function MeetingModePage(): React.ReactElement {
|
|||
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 msg = (errMessage ?? '').toLowerCase()
|
||||
|
|
@ -268,7 +301,8 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// ── 경과 시간 타이머 ──
|
||||
useEffect(() => {
|
||||
if (view === 'recording') {
|
||||
recordingStartRef.current = Date.now()
|
||||
// 시작 시각은 뷰로 들어오는 쪽(새 녹음 / 세션 복원)이 정한다
|
||||
if (!recordingStartRef.current) recordingStartRef.current = Date.now()
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedMs(Date.now() - recordingStartRef.current)
|
||||
}, 1000)
|
||||
|
|
@ -288,6 +322,8 @@ export function MeetingModePage(): React.ReactElement {
|
|||
try {
|
||||
const resp = await window.electronAPI.meetingMode.startRecording({ force })
|
||||
if (resp.success) {
|
||||
recordingStartRef.current = Date.now()
|
||||
setLiveSessionId(resp.data.sessionId)
|
||||
setSegments([])
|
||||
setMemos([])
|
||||
setElapsedMs(0)
|
||||
|
|
@ -311,20 +347,33 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
|
||||
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
|
||||
const liveSessionIdForEdit = stateInfo?.sessionId ?? liveSessionId
|
||||
const handleLiveEditSegment = useCallback(
|
||||
async (segmentId: string, newText: string) => {
|
||||
setSegments((prev) =>
|
||||
prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)),
|
||||
)
|
||||
if (stateInfo?.sessionId) {
|
||||
await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: stateInfo.sessionId,
|
||||
const previousText = segmentsRef.current.find((s) => s.id === segmentId)?.text
|
||||
if (previousText === undefined || previousText === newText) return
|
||||
setSegments((prev) => replaceSegmentText(prev, segmentId, newText))
|
||||
const rollback = (message: string): void => {
|
||||
setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText))
|
||||
setSnackbarMsg(message)
|
||||
}
|
||||
if (!liveSessionIdForEdit) {
|
||||
rollback(t('meeting.error'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: liveSessionIdForEdit,
|
||||
segmentId,
|
||||
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)
|
||||
|
|
@ -507,7 +556,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } }))
|
||||
openSettings('stt')
|
||||
}}
|
||||
leadingIcon={<Settings size={13} />}
|
||||
>
|
||||
|
|
@ -521,7 +570,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } }))
|
||||
openSettings('audio')
|
||||
}}
|
||||
leadingIcon={<Mic size={13} />}
|
||||
>
|
||||
|
|
@ -706,6 +755,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
</Box>
|
||||
)}
|
||||
{snackbar}
|
||||
{errorDialog}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
44
apps/desktop/src/renderer/pages/meeting/live-session.ts
Normal file
44
apps/desktop/src/renderer/pages/meeting/live-session.ts
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue