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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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