fix(meeting): stop transcript view toggle from overwriting edits and repair notes enhancement

This commit is contained in:
Yun Chan 2026-09-28 02:16:25 +09:00
parent ae2d923b9c
commit 6347115ae7
7 changed files with 607 additions and 149 deletions

View file

@ -1,8 +1,9 @@
// src/renderer/components/meeting/MeetingDetailTabs.tsx
// Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad)
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { parseTranscriptSegments, applyTranscriptSegmentEdit } from './transcript-segments'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useMeetingTranscript } from './useMeetingTranscript'
import { buildNotesEnhanceRequest } from './notes-enhancement'
import {
Box,
Tabs,
@ -87,7 +88,6 @@ export function MeetingDetailTabs({
const [dialogOpen, setDialogOpen] = useState(false)
const [generating, setGenerating] = useState(false)
const [diarizing, setDiarizing] = useState(false)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
const [viewMode, setViewMode] = useState<ViewMode>('split')
@ -97,11 +97,20 @@ export function MeetingDetailTabs({
const [actionItems, setActionItems] = useState<Array<{ id: string; text: string; done: boolean }>>([])
const [enhancingNotes, setEnhancingNotes] = useState(false)
// 세그먼트 파싱 (rawTranscript / editedTranscript 기반)
const segments = useMemo(
() => parseTranscriptSegments(detail.rawTranscript, detail.editedTranscript),
[detail.rawTranscript, detail.editedTranscript],
)
// 전사 보기 상태·편집 저장·AI 다듬기·화자 구분은 훅이 전담한다(보기 전환은 저장하지 않는다).
const transcript = useMeetingTranscript(initialDetail)
const [enhanceError, setEnhanceError] = useState<string | null>(null)
const transcriptErrorMessage = transcript.error
? transcript.error.kind === 'polishFailed'
? t('meeting.polishFailed')
: transcript.error.message
: null
const errorMessage = transcriptErrorMessage ?? enhanceError
const { clearError: clearTranscriptError } = transcript
const clearErrors = useCallback(() => {
clearTranscriptError()
setEnhanceError(null)
}, [clearTranscriptError])
// 템플릿 로드 (다른 기기에서 바뀐 템플릿이 반영되면 다시)
useEffect(() => {
@ -151,55 +160,6 @@ export function MeetingDetailTabs({
setEditingTitle(false)
}, [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) => {
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],
)
// ── 전사 저장 ──
const handleSaveTranscript = useCallback(
async (editedTranscript: string) => {
await window.electronAPI.meetingMode.updateTranscript({
sessionId: detail.id,
editedTranscript,
})
setDetail((prev) => ({ ...prev, editedTranscript }))
},
[detail.id],
)
// ── 문서 내용 자동저장 ──
const handleDocContentChange = useCallback(
async (docId: string, content: string) => {
@ -257,44 +217,40 @@ export function MeetingDetailTabs({
[],
)
// ── 화자 구분 ──
// ── 화자 구분 ── (전사 갱신은 훅이, 세션의 나머지 필드 갱신은 여기서)
const { diarize } = transcript
const handleDiarize = useCallback(async () => {
if (diarizing) return
setDiarizing(true)
try {
const resp = await window.electronAPI.meetingMode.diarize({ sessionId: detail.id })
if (resp.success) {
const updated = await window.electronAPI.meetingMode.getSession({ sessionId: detail.id })
if (updated.success) setDetail(updated.data)
}
} finally {
setDiarizing(false)
}
}, [detail.id, diarizing])
const updated = await diarize()
if (updated) setDetail(updated)
}, [diarize])
// ── Granola "Jot & Enhance" AI Note Enhancement ──
// main 의 generateDocument 한 번으로 생성한다(전사·메모는 main 이 직접 읽는다).
const handleEnhanceScratchpad = useCallback(async () => {
if (!scratchpadNotes.trim() || enhancingNotes) return
if (enhancingNotes) return
const request = buildNotesEnhanceRequest({
sessionId: detail.id,
notes: scratchpadNotes,
templates,
title: '📝 내 메모 기반 회의록 (AI 증강)',
})
if (!request) return
setEnhancingNotes(true)
setEnhanceError(null)
try {
const prompt = `다음은 회의 중 작성한 메모와 전체 회의 전사 내용입니다. 사용자 메모의 맥락을 살려 체계적인 핵심 요약 및 결정 사항, 실행 과제로 깔끔하게 확장 정리해주세요:\n\n[사용자 메모]:\n${scratchpadNotes}\n\n[회의 전사]:\n${detail.editedTranscript || detail.rawTranscript}`
const resp = await window.electronAPI.llm.generate({ prompt })
if (resp.success && resp.data.text) {
const newDocResult = await window.electronAPI.meetingMode.generateDocument({
sessionId: detail.id,
templateId: 'custom',
customTitle: '📝 내 메모 기반 회의록 (AI 증강)',
customPrompt: prompt,
})
if (newDocResult.success) {
setDocuments((prev) => [...prev, newDocResult.data])
setActiveTabKey(`doc-${newDocResult.data.id}`)
}
const resp = await window.electronAPI.meetingMode.generateDocument(request)
if (resp.success) {
setDocuments((prev) => [...prev, resp.data])
setActiveTabKey(`doc-${resp.data.id}`)
} else {
setEnhanceError(resp.error.message)
}
} catch (err: unknown) {
setEnhanceError(err instanceof Error ? err.message : String(err))
} finally {
setEnhancingNotes(false)
}
}, [scratchpadNotes, detail.editedTranscript, detail.rawTranscript, detail.id, enhancingNotes])
}, [enhancingNotes, detail.id, scratchpadNotes, templates])
// ── 새 문서 생성 다이얼로그 ──
const handleGenerate = useCallback(
@ -436,21 +392,23 @@ export function MeetingDetailTabs({
</PhosphorText>
</Box>
<TactileBadge mono tone="mono">
{segments.length} UTTERANCES
{transcript.segments.length} UTTERANCES
</TactileBadge>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
<TranscriptTab
sessionId={detail.id}
segments={segments}
rawTranscript={detail.rawTranscript}
editedTranscript={detail.editedTranscript}
segments={transcript.segments}
view={transcript.view}
onViewChange={transcript.setView}
selectedText={transcript.selectedText}
editable={transcript.editable}
memos={detail.memos ?? []}
onEditSegment={handleEditSegment}
onSaveTranscript={handleSaveTranscript}
onDiarize={handleDiarize}
diarizing={diarizing}
onEditSegment={transcript.editSegment}
onPolish={() => void transcript.polish()}
polishing={transcript.polishing}
onDiarize={() => void handleDiarize()}
diarizing={transcript.diarizing}
/>
</Box>
</Box>
@ -652,13 +610,13 @@ export function MeetingDetailTabs({
/>
<Snackbar
open={segmentEditError !== null}
open={errorMessage !== null}
autoHideDuration={4000}
onClose={() => setSegmentEditError(null)}
onClose={clearErrors}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setSegmentEditError(null)} sx={{ width: '100%' }}>
{segmentEditError}
<Alert severity="error" onClose={clearErrors} sx={{ width: '100%' }}>
{errorMessage}
</Alert>
</Snackbar>
</Box>

View file

@ -1,24 +1,31 @@
// src/renderer/components/meeting/TranscriptTab.tsx
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
import { useState, useCallback } from 'react'
import { Box, Switch, FormControlLabel, LinearProgress, Snackbar, Alert } from '@mui/material'
import { useCallback } from 'react'
import { Box, Switch, FormControlLabel, LinearProgress } from '@mui/material'
import { EditableSegment } from './EditableSegment'
import { PhysicalButton } from '@d3ro/ui/components/ds'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { Download, WandSparkles, Speech } from 'lucide-react'
import type { TranscriptSegmentView, TranscriptView } from './transcript-segments'
// 표시 전용 컴포넌트: 보기 상태·저장·AI 다듬기·화자 구분은 useMeetingTranscript 가 가진다.
// 보기 스위치는 onViewChange 만 부르고 어떤 저장도 하지 않는다.
interface TranscriptTabProps {
sessionId: string
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }>
rawTranscript: string | null
editedTranscript: string | null
segments: TranscriptSegmentView[]
view: TranscriptView
onViewChange: (view: TranscriptView) => void
/** 현재 보기의 전사 텍스트 — 다운로드 내용. */
selectedText: string
/** false 면 세그먼트를 읽기 전용으로 보여 준다(원문 보기). */
editable: boolean
memos: Array<{ id: string; timestampMs: number; content: string }>
onEditSegment: (segmentId: string, newText: string) => void
onSaveTranscript: (editedTranscript: string) => void
onDiarize?: () => Promise<void>
onPolish: () => void
polishing: boolean
onDiarize?: () => void
diarizing?: boolean
}
@ -27,43 +34,30 @@ type TimelineItem =
| { kind: 'memo'; id: string; timestamp: number; content: string }
export function TranscriptTab({
sessionId,
segments,
rawTranscript,
editedTranscript,
view,
onViewChange,
selectedText,
editable,
memos,
onEditSegment,
onSaveTranscript,
onPolish,
polishing,
onDiarize,
diarizing = false,
}: TranscriptTabProps): React.ReactElement {
const { t } = useI18n()
const [showEdited, setShowEdited] = useState(true)
const [polishing, setPolishing] = useState(false)
const [polishError, setPolishError] = useState<string | null>(null)
const handlePolish = useCallback(async () => {
setPolishing(true)
setPolishError(null)
const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId })
if (resp.success) {
onSaveTranscript(resp.data)
} else {
setPolishError(t('meeting.polishFailed'))
}
setPolishing(false)
}, [sessionId, onSaveTranscript, t])
const showEdited = view === 'edited'
const handleDownloadTxt = useCallback(() => {
const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '')
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const blob = new Blob([selectedText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transcript.txt'
a.click()
URL.revokeObjectURL(url)
}, [showEdited, editedTranscript, rawTranscript])
}, [selectedText])
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
const timeline: TimelineItem[] = [
@ -85,16 +79,6 @@ export function TranscriptTab({
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Snackbar
open={polishError !== null}
autoHideDuration={4000}
onClose={() => setPolishError(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="error" onClose={() => setPolishError(null)} sx={{ width: '100%' }}>
{polishError}
</Alert>
</Snackbar>
{/* 상단 컨트롤 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<FormControlLabel
@ -102,12 +86,7 @@ export function TranscriptTab({
<Switch
size="small"
checked={showEdited}
onChange={(e) => {
setShowEdited(e.target.checked)
if (!e.target.checked && rawTranscript) {
onSaveTranscript(rawTranscript)
}
}}
onChange={(e) => onViewChange(e.target.checked ? 'edited' : 'raw')}
sx={{
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.main },
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.dim },
@ -123,7 +102,7 @@ export function TranscriptTab({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<PhysicalButton
size="small"
onClick={handlePolish}
onClick={onPolish}
disabled={polishing || diarizing}
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
>
@ -188,6 +167,7 @@ export function TranscriptTab({
text={item.text}
edited={item.edited}
onEdit={onEditSegment}
readOnly={!editable}
speaker={item.speaker}
/>
)

View file

@ -0,0 +1,115 @@
// src/renderer/components/meeting/meeting-transcript-model.ts
// 회의 상세 전사의 "보기 상태"와 "수정본 저장"을 한곳에서 다루는 React 없는 모델.
//
// - 조회(보기 전환)는 상태만 바꾸고 절대 저장하지 않는다(CQS).
// - 저장은 세그먼트 편집 한 경로뿐이며, 낙관적으로 먼저 반영하고 실패하면 되돌린다.
// - AI 다듬기·화자 구분처럼 main 이 이미 저장한 결과는 replaceTranscript 로 반영만 한다.
// 영속화는 SaveEditedTranscript 포트로 주입받아 IPC 없이도 검증할 수 있다.
import type { IPCResult } from '@d3ro/core/errors'
import {
applyTranscriptSegmentEdit,
isTranscriptViewEditable,
selectTranscript,
type TranscriptView,
} from './transcript-segments'
export interface TranscriptSnapshot {
raw: string | null
edited: string | null
}
export interface MeetingTranscriptState extends TranscriptSnapshot {
view: TranscriptView
}
export type TranscriptSaveOutcome = { ok: true } | { ok: false; message: string }
/** 수정본 영속화 포트. 렌더러에서는 meetingMode.updateTranscript IPC 가 구현한다. */
export type SaveEditedTranscript = (editedTranscript: string) => Promise<TranscriptSaveOutcome>
export type TranscriptCommandResult =
| { status: 'saved' }
| { status: 'skipped'; reason: 'read-only-view' | 'no-change' }
| { status: 'failed'; message: string }
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/** meetingMode.updateTranscript 형태의 IPC 호출을 저장 포트로 감싼다. */
export function saveViaUpdateTranscript(
updateTranscript: (params: { sessionId: string; editedTranscript: string }) => Promise<IPCResult<void>>,
sessionId: string,
): SaveEditedTranscript {
return async (editedTranscript) => {
const resp = await updateTranscript({ sessionId, editedTranscript })
return resp.success ? { ok: true } : { ok: false, message: resp.error.message }
}
}
export class MeetingTranscriptModel {
private state: MeetingTranscriptState
private readonly listeners = new Set<() => void>()
constructor(
initial: TranscriptSnapshot,
private readonly save: SaveEditedTranscript,
view: TranscriptView = 'edited',
) {
this.state = { raw: initial.raw, edited: initial.edited, view }
}
/** 불변 스냅샷 — 바뀔 때만 새 객체가 된다(useSyncExternalStore 호환). */
readonly getState = (): MeetingTranscriptState => this.state
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
/** 보기만 바꾼다. 저장하지 않는다. */
setView(view: TranscriptView): void {
if (this.state.view === view) return
this.setState({ ...this.state, view })
}
/** main 이 이미 저장한 전사(AI 다듬기·화자 구분 결과)를 반영한다. 저장하지 않는다. */
replaceTranscript(snapshot: TranscriptSnapshot): void {
if (this.state.raw === snapshot.raw && this.state.edited === snapshot.edited) return
this.setState({ ...this.state, raw: snapshot.raw, edited: snapshot.edited })
}
/**
* 세그먼트 하나를 고쳐 수정본 전체를 저장한다. 수정본 보기에서만 동작한다.
* 화면은 먼저 바꾸고, 저장이 실패하면 그 사이 다시 고치지 않은 경우에만 이전 수정본으로 되돌린다.
*/
async editSegment(segmentId: string, newText: string): Promise<TranscriptCommandResult> {
if (!isTranscriptViewEditable(this.state.view)) {
return { status: 'skipped', reason: 'read-only-view' }
}
const { raw, edited: previous } = this.state
const next = applyTranscriptSegmentEdit(selectTranscript('edited', raw, previous), segmentId, newText)
if (next === null) return { status: 'skipped', reason: 'no-change' }
this.setState({ ...this.state, edited: next })
let outcome: TranscriptSaveOutcome
try {
outcome = await this.save(next)
} catch (err: unknown) {
outcome = { ok: false, message: errorMessage(err) }
}
if (outcome.ok) return { status: 'saved' }
if (this.state.edited === next) {
this.setState({ ...this.state, edited: previous })
}
return { status: 'failed', message: outcome.message }
}
private setState(next: MeetingTranscriptState): void {
this.state = next
for (const listener of this.listeners) listener()
}
}

View file

@ -0,0 +1,52 @@
// src/renderer/components/meeting/notes-enhancement.ts
// 회의 노트패드 "AI 증강" 요청을 만드는 순수 함수.
//
// 증강은 main 의 generateDocument 유스케이스 한 번으로 끝난다. main 이 세션의 전사
// (수정본 우선)와 참석자 메모를 LLM 사용자 메시지로 넣으므로, 렌더러는 사용자 노트를
// 담은 시스템 프롬프트만 넘긴다. 템플릿은 실제로 존재하는 id 여야 한다 — 'custom' 은
// templateType 값일 뿐 id 가 아니어서 MeetingDocTemplateNotFound 로 실패했다.
import type { MeetingDocTemplate, MeetingGenerateDocParams } from '@d3ro/core/types'
/** 삭제할 수 없는 빌트인 회의록 템플릿 id(MeetingDocTemplateService BUILTIN_TEMPLATES). */
export const NOTES_ENHANCE_FALLBACK_TEMPLATE_ID = 'builtin-minutes'
/** 불러온 템플릿 중 빌트인 회의록을 고르고, 목록이 아직 없으면 빌트인 id 로 폴백한다. */
export function pickNotesEnhanceTemplateId(templates: readonly MeetingDocTemplate[]): string {
const builtinMinutes =
templates.find((t) => t.id === NOTES_ENHANCE_FALLBACK_TEMPLATE_ID) ??
templates.find((t) => t.isBuiltin && t.templateType === 'minutes')
return builtinMinutes?.id ?? NOTES_ENHANCE_FALLBACK_TEMPLATE_ID
}
/** LLM 시스템 프롬프트(모델 지시문 — UI 문자열이 아니다). */
export function buildNotesEnhancePrompt(notes: string): string {
return [
'당신은 회의록 작성 비서입니다. 사용자가 회의 중 직접 적은 메모의 맥락을 살려,',
'함께 제공되는 회의 전사록과 참석자 메모를 근거로 핵심 요약, 결정 사항, 실행 과제를 체계적으로 확장 정리하세요.',
'',
'규칙:',
'- 전사록에 없는 내용을 지어내지 마세요',
'- Markdown 으로 작성하세요',
'- 실행 과제는 "- [ ] 할 일" 체크리스트 형식으로 쓰세요',
'',
'[사용자 메모]',
notes.trim(),
].join('\n')
}
/** 노트가 비었으면 null — 호출할 것이 없다. */
export function buildNotesEnhanceRequest(input: {
sessionId: string
notes: string
templates: readonly MeetingDocTemplate[]
title?: string
}): MeetingGenerateDocParams | null {
if (!input.notes.trim()) return null
return {
sessionId: input.sessionId,
templateId: pickNotesEnhanceTemplateId(input.templates),
customPrompt: buildNotesEnhancePrompt(input.notes),
customTitle: input.title,
}
}

View file

@ -1,7 +1,7 @@
// src/renderer/components/meeting/transcript-segments.ts
// 회의 상세 뷰의 전사 ↔ 편집 가능한 세그먼트 변환.
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙과
// "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
// 줄 형식 자체는 core `meeting-transcript` 가 정본이고, 여기서는 세그먼트 id 규칙,
// 보기(수정본/원문)별 전사 선택, "한 세그먼트를 고친 전사 텍스트 재조립"만 담당한다.
import { parseTranscriptLines, formatTranscriptLines } from '@d3ro/core/meeting-transcript'
@ -27,12 +27,36 @@ export function transcriptSegmentIndex(segmentId: string): number | null {
return Number(raw)
}
/** 수정본이 있으면 수정본을, 없으면 원문을 세그먼트로 나눈다. */
/** 회의 상세에서 보여 줄 전사 버전. 'edited' = 수정본(없으면 원문), 'raw' = 원문. */
export type TranscriptView = 'edited' | 'raw'
/**
* 보기에 해당하는 전사 텍스트 — "수정본이 없으면 원문" 규칙의 유일한 정본.
* 목록·다운로드·세그먼트 편집이 모두 이 함수로 텍스트를 고른다.
*/
export function selectTranscript(
view: TranscriptView,
rawTranscript: string | null,
editedTranscript: string | null,
): string | null {
return view === 'edited' ? editedTranscript || rawTranscript : rawTranscript
}
/**
* 세그먼트 편집은 수정본 보기에서만 허용한다. 원문 보기의 줄 번호로 수정본을 고치면
* 두 버전의 줄 구성이 다를 때 엉뚱한 줄이 바뀐다.
*/
export function isTranscriptViewEditable(view: TranscriptView): boolean {
return view === 'edited'
}
/** 보기(기본: 수정본, 없으면 원문)에 해당하는 전사를 세그먼트로 나눈다. */
export function parseTranscriptSegments(
rawTranscript: string | null,
editedTranscript: string | null,
view: TranscriptView = 'edited',
): TranscriptSegmentView[] {
return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({
return parseTranscriptLines(selectTranscript(view, rawTranscript, editedTranscript)).map((line, idx) => ({
id: transcriptSegmentId(idx),
timestamp: line.timestampMs,
text: line.text,

View file

@ -0,0 +1,131 @@
// src/renderer/components/meeting/useMeetingTranscript.ts
// 회의 상세 전사의 보기 상태·편집 저장·AI 다듬기·화자 구분을 묶는 얇은 React 훅.
// 규칙은 MeetingTranscriptModel(React 없음)에 있고, 여기서는 IPC 포트 연결과 진행 상태만 다룬다.
import { useCallback, useMemo, useState, useSyncExternalStore } from 'react'
import type { MeetingSessionDetail } from '@d3ro/core/types'
import {
MeetingTranscriptModel,
saveViaUpdateTranscript,
type MeetingTranscriptState,
} from './meeting-transcript-model'
import {
isTranscriptViewEditable,
parseTranscriptSegments,
selectTranscript,
type TranscriptSegmentView,
type TranscriptView,
} from './transcript-segments'
export type MeetingTranscriptError =
| { kind: 'polishFailed' }
| { kind: 'saveFailed'; message: string }
export interface MeetingTranscriptSource {
id: string
rawTranscript: string | null
editedTranscript: string | null
}
export interface UseMeetingTranscriptResult {
view: TranscriptView
setView: (view: TranscriptView) => void
/** 현재 보기에서 파생한 세그먼트. */
segments: TranscriptSegmentView[]
/** 현재 보기의 전사 텍스트(다운로드용). */
selectedText: string
/** 원문 보기에서는 false — 세그먼트는 읽기 전용. */
editable: boolean
editSegment: (segmentId: string, newText: string) => void
polish: () => Promise<void>
polishing: boolean
/** 화자 구분 후 갱신된 세션을 돌려준다(실패하면 null). */
diarize: () => Promise<MeetingSessionDetail | null>
diarizing: boolean
error: MeetingTranscriptError | null
clearError: () => void
}
export function useMeetingTranscript(source: MeetingTranscriptSource): UseMeetingTranscriptResult {
const sessionId = source.id
const [model] = useState(
() =>
new MeetingTranscriptModel(
{ raw: source.rawTranscript, edited: source.editedTranscript },
saveViaUpdateTranscript((params) => window.electronAPI.meetingMode.updateTranscript(params), sessionId),
),
)
const state: MeetingTranscriptState = useSyncExternalStore(model.subscribe, model.getState)
const [polishing, setPolishing] = useState(false)
const [diarizing, setDiarizing] = useState(false)
const [error, setError] = useState<MeetingTranscriptError | null>(null)
const segments = useMemo(
() => parseTranscriptSegments(state.raw, state.edited, state.view),
[state.raw, state.edited, state.view],
)
const selectedText = selectTranscript(state.view, state.raw, state.edited) ?? ''
const setView = useCallback((view: TranscriptView) => model.setView(view), [model])
const editSegment = useCallback(
(segmentId: string, newText: string) => {
void model.editSegment(segmentId, newText).then((result) => {
if (result.status === 'failed') setError({ kind: 'saveFailed', message: result.message })
})
},
[model],
)
// main 의 polishTranscript 가 수정본을 저장·동기화까지 하므로 여기서는 반영만 한다.
const polish = useCallback(async () => {
if (polishing) return
setPolishing(true)
setError(null)
try {
const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId })
if (resp.success) {
model.replaceTranscript({ raw: model.getState().raw, edited: resp.data })
} else {
setError({ kind: 'polishFailed' })
}
} catch {
setError({ kind: 'polishFailed' })
} finally {
setPolishing(false)
}
}, [model, polishing, sessionId])
const diarize = useCallback(async (): Promise<MeetingSessionDetail | null> => {
if (diarizing) return null
setDiarizing(true)
try {
const resp = await window.electronAPI.meetingMode.diarize({ sessionId })
if (!resp.success) return null
const updated = await window.electronAPI.meetingMode.getSession({ sessionId })
if (!updated.success) return null
model.replaceTranscript({ raw: updated.data.rawTranscript, edited: updated.data.editedTranscript })
return updated.data
} finally {
setDiarizing(false)
}
}, [diarizing, model, sessionId])
const clearError = useCallback(() => setError(null), [])
return {
view: state.view,
setView,
segments,
selectedText,
editable: isTranscriptViewEditable(state.view),
editSegment,
polish,
polishing,
diarize,
diarizing,
error,
clearError,
}
}

View file

@ -0,0 +1,198 @@
import fs from 'fs'
import path from 'path'
import { describe, expect, it, vi } from 'vitest'
import type { MeetingDocTemplate, MeetingDocTemplateType } from '@d3ro/core/types'
import {
isTranscriptViewEditable,
parseTranscriptSegments,
selectTranscript,
} from '../../src/renderer/components/meeting/transcript-segments'
import {
MeetingTranscriptModel,
saveViaUpdateTranscript,
type SaveEditedTranscript,
} from '../../src/renderer/components/meeting/meeting-transcript-model'
import {
NOTES_ENHANCE_FALLBACK_TEMPLATE_ID,
buildNotesEnhanceRequest,
pickNotesEnhanceTemplateId,
} from '../../src/renderer/components/meeting/notes-enhancement'
const RAW = '[00:01] [화자 1] 음 안녕하세요\n[00:05] 어 오늘 안건은\n[01:10] 예산입니다'
const EDITED = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은\n[01:10] 예산 확정입니다'
function okSave() {
return vi.fn<SaveEditedTranscript>(async () => ({ ok: true }))
}
describe('selectTranscript — 보기별 전사 선택 규칙의 정본', () => {
it('수정본 보기는 수정본, 없으면 원문', () => {
expect(selectTranscript('edited', RAW, EDITED)).toBe(EDITED)
expect(selectTranscript('edited', RAW, null)).toBe(RAW)
expect(selectTranscript('edited', RAW, '')).toBe(RAW)
})
it('원문 보기는 수정본이 있어도 원문', () => {
expect(selectTranscript('raw', RAW, EDITED)).toBe(RAW)
expect(selectTranscript('raw', null, EDITED)).toBeNull()
})
it('세그먼트도 보기에 따라 파생된다(기본은 수정본)', () => {
expect(parseTranscriptSegments(RAW, EDITED).map((s) => s.text)[2]).toBe('예산 확정입니다')
expect(parseTranscriptSegments(RAW, EDITED, 'raw').map((s) => s.text)[0]).toBe('음 안녕하세요')
})
it('원문 보기는 읽기 전용', () => {
expect(isTranscriptViewEditable('edited')).toBe(true)
expect(isTranscriptViewEditable('raw')).toBe(false)
})
})
describe('MeetingTranscriptModel — 보기 전환은 저장하지 않는다(CQS)', () => {
it('원문 보기로 바꿔도 저장하지 않고, 다시 켜면 수정본이 그대로 있다', () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
const listener = vi.fn()
model.subscribe(listener)
model.setView('raw')
expect(model.getState()).toEqual({ raw: RAW, edited: EDITED, view: 'raw' })
model.setView('edited')
model.setView('edited') // 같은 보기는 알리지 않는다
expect(save).not.toHaveBeenCalled()
expect(model.getState().edited).toBe(EDITED)
expect(listener).toHaveBeenCalledTimes(2)
})
it('원문 보기에서는 세그먼트 편집을 저장하지 않는다', async () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save, 'raw')
const result = await model.editSegment('seg-0', '다른 내용')
expect(result).toEqual({ status: 'skipped', reason: 'read-only-view' })
expect(save).not.toHaveBeenCalled()
expect(model.getState().edited).toBe(EDITED)
})
it('수정본 보기의 편집은 수정본 위에 고쳐 저장한다', async () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
const result = await model.editSegment('seg-1', '오늘 안건은 두 가지')
const expected = '[00:01] [화자 1] 안녕하세요\n[00:05] 오늘 안건은 두 가지\n[01:10] 예산 확정입니다'
expect(result).toEqual({ status: 'saved' })
expect(save).toHaveBeenCalledTimes(1)
expect(save).toHaveBeenCalledWith(expected)
expect(model.getState().edited).toBe(expected)
})
it('수정본이 없으면 원문에서 시작해 수정본을 만든다', async () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save)
await model.editSegment('seg-0', '안녕하세요')
expect(model.getState().raw).toBe(RAW)
expect(parseTranscriptSegments(RAW, model.getState().edited)[0].text).toBe('안녕하세요')
})
it('바뀐 것이 없으면 저장하지 않는다', async () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: EDITED }, save)
expect(await model.editSegment('seg-0', '안녕하세요')).toEqual({ status: 'skipped', reason: 'no-change' })
expect(await model.editSegment('seg-9', 'x')).toEqual({ status: 'skipped', reason: 'no-change' })
expect(save).not.toHaveBeenCalled()
})
it('저장이 실패하거나 던지면 이전 수정본으로 되돌린다', async () => {
const failing = new MeetingTranscriptModel(
{ raw: RAW, edited: EDITED },
async () => ({ ok: false, message: 'db locked' }),
)
expect(await failing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'db locked' })
expect(failing.getState().edited).toBe(EDITED)
const throwing = new MeetingTranscriptModel({ raw: RAW, edited: null }, async () => {
throw new Error('ipc down')
})
expect(await throwing.editSegment('seg-0', 'x')).toEqual({ status: 'failed', message: 'ipc down' })
expect(throwing.getState().edited).toBeNull()
})
it('실패를 기다리는 사이 다시 반영된 전사는 덮어쓰지 않는다', async () => {
let reject: (value: { ok: false; message: string }) => void = () => undefined
const model = new MeetingTranscriptModel(
{ raw: RAW, edited: EDITED },
() => new Promise((resolve) => { reject = resolve }),
)
const pending = model.editSegment('seg-0', 'x')
model.replaceTranscript({ raw: RAW, edited: 'polished' })
reject({ ok: false, message: 'late failure' })
await pending
expect(model.getState().edited).toBe('polished')
})
it('main 이 저장한 결과(다듬기·화자 구분) 반영은 저장을 부르지 않는다', () => {
const save = okSave()
const model = new MeetingTranscriptModel({ raw: RAW, edited: null }, save)
model.replaceTranscript({ raw: RAW, edited: EDITED })
expect(model.getState().edited).toBe(EDITED)
expect(save).not.toHaveBeenCalled()
})
it('updateTranscript IPC 결과를 저장 결과로 옮긴다', async () => {
const update = vi.fn()
.mockResolvedValueOnce({ success: true, data: undefined })
.mockResolvedValueOnce({ success: false, error: { code: 1, message: 'nope' } })
const save = saveViaUpdateTranscript(update, 'sess-1')
expect(await save('t')).toEqual({ ok: true })
expect(await save('t')).toEqual({ ok: false, message: 'nope' })
expect(update).toHaveBeenCalledWith({ sessionId: 'sess-1', editedTranscript: 't' })
})
})
describe('노트패드 AI 증강 요청', () => {
const template = (id: string, templateType: string, isBuiltin: boolean): MeetingDocTemplate => ({
id,
name: id,
description: '',
templateType: templateType as MeetingDocTemplateType,
systemPrompt: '',
isBuiltin,
createdAt: 0,
updatedAt: 0,
})
it('실제로 존재하는 빌트인 회의록 템플릿 id 를 쓴다("custom" 아님)', () => {
const templates = [template('user-1', 'custom', false), template('builtin-minutes', 'minutes', true)]
expect(pickNotesEnhanceTemplateId(templates)).toBe('builtin-minutes')
expect(pickNotesEnhanceTemplateId([])).toBe(NOTES_ENHANCE_FALLBACK_TEMPLATE_ID)
expect(pickNotesEnhanceTemplateId([template('user-1', 'custom', false)])).not.toBe('custom')
})
it('노트를 시스템 프롬프트에 담아 generateDocument 한 번으로 요청한다', () => {
const req = buildNotesEnhanceRequest({ sessionId: 's1', notes: ' - 예산 재검토 ', templates: [], title: 'T' })
expect(req).not.toBeNull()
expect(req?.sessionId).toBe('s1')
expect(req?.templateId).toBe('builtin-minutes')
expect(req?.customTitle).toBe('T')
expect(req?.customPrompt).toContain('- 예산 재검토')
})
it('노트가 비었으면 요청하지 않는다', () => {
expect(buildNotesEnhanceRequest({ sessionId: 's1', notes: ' ', templates: [] })).toBeNull()
})
})
describe('회의 상세 구조 회귀', () => {
const dir = path.join(__dirname, '../../src/renderer/components/meeting')
const transcriptTab = fs.readFileSync(path.join(dir, 'TranscriptTab.tsx'), 'utf-8')
const detailTabs = fs.readFileSync(path.join(dir, 'MeetingDetailTabs.tsx'), 'utf-8')
it('TranscriptTab 은 저장 명령을 받지 않는다(보기 스위치가 수정본을 덮어쓰지 않는다)', () => {
expect(transcriptTab).not.toMatch(/onSaveTranscript/)
expect(transcriptTab).not.toMatch(/updateTranscript|polishTranscript/)
})
it('노트 증강은 없는 llm.generate 나 가짜 템플릿 id 를 쓰지 않는다', () => {
expect(detailTabs).not.toMatch(/llm\.generate/)
expect(detailTabs).not.toMatch(/templateId:\s*'custom'/)
})
})