fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -12,6 +12,7 @@ import type {
|
|||
FileTranscriptionResult,
|
||||
FileTranscriptionState,
|
||||
} from '@d3ro/core/types'
|
||||
import { StartRequestTracker, classifyStartFailure, resolveDroppedFilePath } from './file-drop-model'
|
||||
|
||||
const SUPPORTED_EXTENSIONS = [
|
||||
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
|
||||
|
|
@ -52,6 +53,7 @@ export function FileDropZone(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
const isBusyRef = useRef(false)
|
||||
const requestsRef = useRef(new StartRequestTracker())
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -68,34 +70,26 @@ export function FileDropZone(): React.ReactElement {
|
|||
return
|
||||
}
|
||||
|
||||
isBusyRef.current = true
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const filePath = (file as unknown as { path: string }).path
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath })
|
||||
if (!resp.success) {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
} finally {
|
||||
isBusyRef.current = false
|
||||
// Electron 32+ 에서는 File.path 가 없다 — preload 의 webUtils.getPathForFile 로 얻는다.
|
||||
// 경로를 못 얻으면 빈 경로로 시작하지 않는다(main 이 파일 선택 대화상자를 띄운다).
|
||||
const filePath = resolveDroppedFilePath(file, window.electronAPI.fileTranscription.getPathForFile)
|
||||
if (!filePath) {
|
||||
setError(t('fileTranscription.error.unknown'))
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
|
||||
isBusyRef.current = true
|
||||
const request = requestsRef.current.begin()
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath })
|
||||
if (!requestsRef.current.isCurrent(request)) return
|
||||
if (!resp.success) {
|
||||
if (resp.error.message.includes('cancelled') || resp.error.message.includes('already active')) {
|
||||
if (classifyStartFailure(resp.error) === 'cancelled') {
|
||||
setState('idle')
|
||||
} else {
|
||||
setError(resp.error.message)
|
||||
|
|
@ -103,11 +97,37 @@ export function FileDropZone(): React.ReactElement {
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
isBusyRef.current = false
|
||||
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
if (state === 'converting' || state === 'transcribing' || isBusyRef.current) return
|
||||
isBusyRef.current = true
|
||||
const request = requestsRef.current.begin()
|
||||
setState('converting')
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const resp = await window.electronAPI.fileTranscription.start({ filePath: '' })
|
||||
if (!requestsRef.current.isCurrent(request)) return
|
||||
if (!resp.success) {
|
||||
if (classifyStartFailure(resp.error) === 'cancelled') {
|
||||
setState('idle')
|
||||
} else {
|
||||
setError(resp.error.message)
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (requestsRef.current.isCurrent(request)) isBusyRef.current = false
|
||||
}
|
||||
}, [state])
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
// 취소된 요청의 늦은 응답은 무시한다(새로 드롭한 작업의 화면을 덮지 않게)
|
||||
requestsRef.current.invalidate()
|
||||
await window.electronAPI.fileTranscription.cancel()
|
||||
setState('idle')
|
||||
setProgress(null)
|
||||
|
|
|
|||
48
apps/desktop/src/renderer/components/file-drop-model.ts
Normal file
48
apps/desktop/src/renderer/components/file-drop-model.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// src/renderer/components/file-drop-model.ts
|
||||
// FileDropZone 의 순수 판단(드롭 경로 해석·시작 실패 분류·늦은 응답 무시) — React·Electron 없이 테스트한다.
|
||||
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
|
||||
/**
|
||||
* 드롭한 File 의 실제 경로. Electron 32+ 는 비표준 File.path 를 없앴으므로 preload 의
|
||||
* webUtils.getPathForFile 을 쓴다. 경로를 얻지 못하면 null — 빈 경로로 시작하면 main 이
|
||||
* 파일 선택 대화상자를 띄워, 드롭한 파일 대신 다른 파일을 고르게 만들었다.
|
||||
*/
|
||||
export function resolveDroppedFilePath(file: File, getPathForFile: (file: File) => string): string | null {
|
||||
try {
|
||||
const filePath = getPathForFile(file)
|
||||
return filePath && filePath.trim().length > 0 ? filePath : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type StartFailureKind = 'cancelled' | 'error'
|
||||
|
||||
/** 시작 IPC 실패 분류 — 사용자가 취소했거나 대화상자를 닫은 것은 오류로 보이지 않는다. */
|
||||
export function classifyStartFailure(error: { code: number; message: string }): StartFailureKind {
|
||||
if (error.code === ErrorCode.FileTranscriptionCancelled) return 'cancelled'
|
||||
if (error.message.includes('cancelled') || error.message.includes('already active')) return 'cancelled'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
* 시작 요청마다 번호를 매긴다. 취소하거나 새로 시작하면 이전 요청의 늦은 응답(예: 취소된 작업의
|
||||
* 'Transcription cancelled')이 새 작업의 화면을 덮지 않도록 무시한다.
|
||||
*/
|
||||
export class StartRequestTracker {
|
||||
private _current = 0
|
||||
|
||||
begin(): number {
|
||||
this._current += 1
|
||||
return this._current
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this._current += 1
|
||||
}
|
||||
|
||||
isCurrent(token: number): boolean {
|
||||
return token === this._current
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { isImeComposingEvent } from '../../utils/keyboard'
|
|||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { MeetingChatMessage } from '@d3ro/core/types'
|
||||
import { withRestoredHistory } from './meeting-chat-history'
|
||||
|
||||
interface MeetingChatPanelProps {
|
||||
sessionId: string
|
||||
|
|
@ -85,17 +86,27 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
scrollToBottom()
|
||||
}, [messages, streamingContent, scrollToBottom])
|
||||
|
||||
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버린다.
|
||||
// main 은 회의마다 히스토리를 따로 보관하므로 여기서 지우지 않는다.
|
||||
// 회의가 바뀌면(같은 패널 인스턴스 재사용 포함) 이전 회의의 대화 표시와 진행 중 답변을 버리고,
|
||||
// main 이 이 회의에 대해 기억하는 대화를 불러와 보여 준다 — 예전엔 빈 화면인데 모델은 보이지 않는
|
||||
// 이전 턴을 이어받아 답했다.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setMessages([])
|
||||
setStreaming(false)
|
||||
setStreamingContent('')
|
||||
setError(null)
|
||||
isSendingChatRef.current = false
|
||||
window.electronAPI.meetingChat
|
||||
.getHistory({ sessionId })
|
||||
.then((resp) => {
|
||||
if (cancelled || !resp.success || resp.data.length === 0) return
|
||||
setMessages((current) => withRestoredHistory(current, resp.data))
|
||||
})
|
||||
.catch(() => { /* 불러오지 못하면 빈 대화로 시작 */ })
|
||||
return () => {
|
||||
// 떠나는 회의의 답변 스트림을 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||
void window.electronAPI.meetingChat.cancel()
|
||||
cancelled = true
|
||||
// 떠나는 회의의 답변 스트림만 끊는다 — 다음 회의 패널에 섞이지 않게
|
||||
void window.electronAPI.meetingChat.cancel({ sessionId })
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
|
|
@ -167,7 +178,9 @@ export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.Re
|
|||
setMessages([])
|
||||
setStreamingContent('')
|
||||
setStreaming(false)
|
||||
window.electronAPI.meetingChat.clear()
|
||||
// 이 회의의 대화만 지운다 — 예전엔 인자 없이 불러 모든 회의의 대화가 지워졌다
|
||||
void window.electronAPI.meetingChat.cancel({ sessionId })
|
||||
void window.electronAPI.meetingChat.clear({ sessionId })
|
||||
}, [sessionId])
|
||||
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
// src/renderer/components/meeting/meeting-chat-history.ts
|
||||
// main 이 회의마다 기억하는 채팅 대화를 패널 메시지로 바꾼다(패널을 다시 열 때 표시).
|
||||
|
||||
import type { MeetingChatMessage } from '@d3ro/core/types'
|
||||
import type { MeetingChatHistoryTurn } from '@d3ro/core/meeting-live'
|
||||
|
||||
/**
|
||||
* 보관된 턴에는 시각이 없다 — 목록 key 가 겹치지 않도록 순서 번호를 timestamp 자리에 쓴다
|
||||
* (실제 메시지의 Date.now() 보다 항상 작아 순서도 유지된다).
|
||||
*/
|
||||
export function toPanelMessages(history: readonly MeetingChatHistoryTurn[]): MeetingChatMessage[] {
|
||||
return history.map((turn, index) => ({ role: turn.role, content: turn.content, timestamp: index + 1 }))
|
||||
}
|
||||
|
||||
/** 조회가 끝나기 전에 사용자가 새로 보낸 메시지가 있으면 그것을 지우지 않고 뒤에 둔다. */
|
||||
export function withRestoredHistory(
|
||||
current: readonly MeetingChatMessage[],
|
||||
history: readonly MeetingChatHistoryTurn[],
|
||||
): MeetingChatMessage[] {
|
||||
return [...toPanelMessages(history), ...current]
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ import {
|
|||
} from '@d3ro/ui/components/ds'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { INSTRUCTION_LIMITS } from '@d3ro/core/instruction-policy'
|
||||
import { commandSaveError } from './commands/command-form'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { TemplateSection } from '../components/TemplateSection'
|
||||
|
||||
|
|
@ -63,6 +65,8 @@ export function CommandsPage(): React.ReactElement {
|
|||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
/** 저장 거부 사유 — 대화상자 안에 보여 준다 */
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const isSavingRef = useRef(false)
|
||||
const isDeletingRef = useRef(false)
|
||||
|
||||
|
|
@ -123,6 +127,7 @@ export function CommandsPage(): React.ReactElement {
|
|||
setFormName('')
|
||||
setFormDesc('')
|
||||
setFormPrompt(t('commands.defaultPrompt'))
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -132,30 +137,36 @@ export function CommandsPage(): React.ReactElement {
|
|||
setFormName(inst.name)
|
||||
setFormDesc(inst.description)
|
||||
setFormPrompt(inst.prompt)
|
||||
setFormError(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// 저장이 거부되면(빈 값·길이 초과 등) 대화상자를 닫지 않고 이유를 보여 준다 — 예전엔 결과를 보지 않고
|
||||
// 닫아 입력한 내용이 조용히 사라졌다.
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim() || saving || isSavingRef.current) return
|
||||
const draft = { name: formName.trim(), description: formDesc.trim(), prompt: formPrompt.trim() }
|
||||
const invalid = commandSaveError({ kind: 'draft', draft })
|
||||
if (invalid) {
|
||||
setFormError(t(invalid.key))
|
||||
return
|
||||
}
|
||||
isSavingRef.current = true
|
||||
setSaving(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
if (editId) {
|
||||
await window.electronAPI.instruction.update({
|
||||
id: editId,
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
prompt: formPrompt.trim(),
|
||||
})
|
||||
} else {
|
||||
await window.electronAPI.instruction.create({
|
||||
name: formName.trim(),
|
||||
description: formDesc.trim(),
|
||||
prompt: formPrompt.trim(),
|
||||
})
|
||||
const res = editId
|
||||
? await window.electronAPI.instruction.update({ id: editId, ...draft })
|
||||
: await window.electronAPI.instruction.create(draft)
|
||||
if (!res.success) {
|
||||
const failure = commandSaveError({ kind: 'ipc', error: res.error })
|
||||
setFormError(failure ? t(failure.key) : res.error.message)
|
||||
return
|
||||
}
|
||||
setDialogOpen(false)
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
isSavingRef.current = false
|
||||
|
|
@ -601,34 +612,42 @@ export function CommandsPage(): React.ReactElement {
|
|||
<TextField
|
||||
label={t('commands.name')}
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
onChange={(e) => { setFormName(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
autoFocus
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.name }}
|
||||
sx={{ mt: 1.5 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('commands.description')}
|
||||
value={formDesc}
|
||||
onChange={(e) => setFormDesc(e.target.value)}
|
||||
onChange={(e) => { setFormDesc(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.description }}
|
||||
sx={{ mt: 2.5 }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('commands.promptTemplate')}
|
||||
value={formPrompt}
|
||||
onChange={(e) => setFormPrompt(e.target.value)}
|
||||
onChange={(e) => { setFormPrompt(e.target.value); setFormError(null) }}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
inputProps={{ maxLength: INSTRUCTION_LIMITS.prompt }}
|
||||
sx={{ mt: 2.5 }}
|
||||
helperText={t('commands.promptHelp')}
|
||||
helperText={`${t('commands.promptHelp')} · ${formPrompt.length}/${INSTRUCTION_LIMITS.prompt}`}
|
||||
/>
|
||||
{formError && (
|
||||
<PhosphorText variant="body" sx={{ mt: 1.5, color: d3roPalette.status.danger, display: 'block' }}>
|
||||
{formError}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
|
||||
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || saving}>
|
||||
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || !formPrompt.trim() || saving}>
|
||||
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
replaceSegmentText,
|
||||
rollbackSegmentText,
|
||||
} from './meeting/live-session'
|
||||
import { mergeRestoredItems } from './meeting/live-restore'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
|
|
@ -148,11 +149,13 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// ── 진행 중 세션 복원 ──
|
||||
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
|
||||
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
|
||||
// 그동안의 실시간 전사·메모도 main 에서 받아 되살린다(예전엔 뷰만 복원돼 이전 전사가 사라져 보이고
|
||||
// 편집할 수 없었다). 조회하는 사이 도착한 세그먼트와는 id 로 합친다.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.electronAPI.meetingMode
|
||||
.getState()
|
||||
.then((resp) => {
|
||||
.then(async (resp) => {
|
||||
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
|
||||
const info = resp.data
|
||||
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
|
||||
|
|
@ -160,6 +163,11 @@ export function MeetingModePage(): React.ReactElement {
|
|||
setLiveSessionId(info.sessionId)
|
||||
setElapsedMs(info.elapsedMs)
|
||||
setView('recording')
|
||||
const live = await window.electronAPI.meetingMode.getLiveSession()
|
||||
if (cancelled || !live.success || !live.data || live.data.sessionId !== info.sessionId) return
|
||||
const restored = live.data
|
||||
setSegments((arrived) => mergeRestoredItems(restored.segments, arrived, (s) => s.timestamp))
|
||||
setMemos((arrived) => mergeRestoredItems(restored.memos, arrived, (m) => m.timestampMs))
|
||||
})
|
||||
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
|
||||
return () => {
|
||||
|
|
@ -342,9 +350,24 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}
|
||||
}, [parseMeetingError])
|
||||
|
||||
// 정지는 마지막 문장 인식(flush)이 끝날 때까지 걸린다 — 그동안 다시 누를 수 없게 하고,
|
||||
// 실패하면 알린다(예전엔 응답을 버려 화면이 계속 '녹음 중'이었다).
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const isStoppingRef = useRef(false)
|
||||
const handleStopRecording = useCallback(async () => {
|
||||
await window.electronAPI.meetingMode.stopRecording()
|
||||
}, [])
|
||||
if (isStoppingRef.current) return
|
||||
isStoppingRef.current = true
|
||||
setIsStopping(true)
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.stopRecording()
|
||||
if (!resp.success) setSnackbarMsg(`${t('meeting.error')}: ${resp.error.message}`)
|
||||
} catch (err: unknown) {
|
||||
setSnackbarMsg(`${t('meeting.error')}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
isStoppingRef.current = false
|
||||
setIsStopping(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
|
||||
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
|
||||
|
|
@ -792,7 +815,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
</Box>
|
||||
|
||||
{!isProcessing && (
|
||||
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording}>
|
||||
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording} disabled={isStopping}>
|
||||
<Square size={14} style={{ marginRight: 4 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
|
|
|
|||
35
apps/desktop/src/renderer/pages/commands/command-form.ts
Normal file
35
apps/desktop/src/renderer/pages/commands/command-form.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// src/renderer/pages/commands/command-form.ts
|
||||
// 명령어 편집 폼의 저장 판단(순수 함수) — 서버·main 과 같은 규칙(@d3ro/core/instruction-policy)을 쓴다.
|
||||
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
import { validateInstructionDraft } from '@d3ro/core/instruction-policy'
|
||||
|
||||
/** 사용자에게 보일 메시지의 i18n 키 */
|
||||
export interface CommandSaveErrorMessage {
|
||||
key: 'mobile.commands.syncError.validation'
|
||||
}
|
||||
|
||||
/** "이름 1~80자, 설명 240자 이하, 프롬프트 1~4000자" — 서버 CHECK 와 같은 안내 */
|
||||
const VALIDATION_MESSAGE: CommandSaveErrorMessage = { key: 'mobile.commands.syncError.validation' }
|
||||
|
||||
/** main 검증 거부 메시지(instruction-handlers 가 코드를 ConfigWriteFailed 로 감싸므로 문구로도 판별) */
|
||||
const VALIDATION_REJECTION = /too long|is empty/i
|
||||
|
||||
export type CommandSaveCheck =
|
||||
| { kind: 'draft'; draft: { name: string; description: string; prompt: string } }
|
||||
| { kind: 'ipc'; error: { code: number; message: string } }
|
||||
|
||||
/**
|
||||
* 저장 전 초안 검사 또는 저장 실패 응답을 사용자 메시지로 바꾼다.
|
||||
* - draft: 빈 이름·빈 프롬프트·길이 초과면 검증 메시지, 문제 없으면 null
|
||||
* - ipc: 검증 거부(ConfigInvalidValue 또는 검증 문구)면 검증 메시지, 그 밖의 실패는 null(호출자가 원문을 보여 준다)
|
||||
*/
|
||||
export function commandSaveError(check: CommandSaveCheck): CommandSaveErrorMessage | null {
|
||||
if (check.kind === 'draft') {
|
||||
return validateInstructionDraft(check.draft) === null ? null : VALIDATION_MESSAGE
|
||||
}
|
||||
if (check.error.code === ErrorCode.ConfigInvalidValue || VALIDATION_REJECTION.test(check.error.message)) {
|
||||
return VALIDATION_MESSAGE
|
||||
}
|
||||
return null
|
||||
}
|
||||
26
apps/desktop/src/renderer/pages/meeting/live-restore.ts
Normal file
26
apps/desktop/src/renderer/pages/meeting/live-restore.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// src/renderer/pages/meeting/live-restore.ts
|
||||
// 녹음 화면을 다시 열 때(페이지 이동 후 복귀) main 이 보관한 실시간 전사·메모와,
|
||||
// 조회하는 사이 이벤트로 먼저 도착한 항목을 합치는 순수 함수.
|
||||
|
||||
interface HasId {
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* restored(main 사본)를 기준으로 하고, arrived(조회 중 onSegment/onMemo 로 먼저 온 항목) 중 없는 것만 덧붙인다.
|
||||
* id 로 중복을 없애고 order 기준으로 정렬한다.
|
||||
*/
|
||||
export function mergeRestoredItems<T extends HasId>(
|
||||
restored: readonly T[],
|
||||
arrived: readonly T[],
|
||||
order: (item: T) => number,
|
||||
): T[] {
|
||||
const seen = new Set(restored.map((item) => item.id))
|
||||
const merged = [...restored]
|
||||
for (const item of arrived) {
|
||||
if (seen.has(item.id)) continue
|
||||
seen.add(item.id)
|
||||
merged.push(item)
|
||||
}
|
||||
return merged.sort((a, b) => order(a) - order(b))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue