feat(desktop+web): Phase 3.3 Auto push on write + transcript fallback + dashboard Link (빅뱅 Phase 5 Part 3)

Desktop — CloudSyncService:
- pushOne(table, id) 제네릭 단건 push 신규 (history/dictionary/meetings/meeting_memos/meeting_documents)
- 로그인 미상태 silent noop, 실패 시 warn만 (로컬 write 절대 차단 금지)
- 5개 row→payload 매퍼(_mapHistoryPayload 외 4개) private 메서드로 추출, pushAll과 공유 (DRY)

Desktop — write hook 8곳 fire-and-forget 연결:
- HistoryService.create / generateTitle
- MeetingSummaryService.generateSummary (summaryText 저장 후)
- DictionaryService.add / update
- MeetingModeService.startRecording (부모 meeting pre-push — 자식 memo/doc RLS 통과용)
- MeetingModeService.addMemo / generateDocument
- MeetingModeService._runPostProcessing (완료 상태 최종 저장 후)

Web — meeting detail page:
- transcripts 테이블이 비고 status!=recording인 경우 meeting.raw_transcript(또는 edited_transcript) 를 whiteSpace:pre-wrap으로 fallback 렌더
- 실시간 구독 경로(LiveTranscriptList)는 recording 상태 또는 transcripts 존재 시에만 마운트

Web — dashboard:
- RECENT MEETINGS의 MetalCard를 next/link Link로 감싸 /meetings/[id] 이동 가능
- cursor pointer + hover lift 효과

실증: 재기동 직후 pushAll 캐치업이 이전 세션 RLS 실패 메모 2건을 재시도 → pushed=4 성공.
새 녹음 → pushOne history/... ok 실시간 발화 → 웹에서 전사/메모 렌더 확인 (사용자 "잘 전사와 메모가 올라왔어")
This commit is contained in:
윤찬 2026-04-11 21:34:02 +09:00
parent 4e6280bc69
commit 046ac857cb
8 changed files with 344 additions and 85 deletions

View file

@ -546,26 +546,7 @@ class CloudSyncService extends EventEmitter {
const historyRows = await db.select().from(history).where(gt(history.updatedAt, since))
result.pushed += await this._pushTable('history', historyRows.length, async () => {
if (historyRows.length === 0) return 0
const payload = historyRows.map((r) => ({
id: r.id,
user_id: userId,
original_text: r.originalText,
polished_text: r.polishedText,
mode: r.mode,
status: r.status,
duration: r.duration,
detected_language: r.detectedLanguage,
mic_device: r.micDevice,
word_count: r.wordCount,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString(),
app_version: r.appVersion,
summary_text: r.summaryText
}))
const payload = historyRows.map((r) => this._mapHistoryPayload(r, userId))
const { error } = await this._client!.from('history').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return historyRows.length
@ -575,17 +556,7 @@ class CloudSyncService extends EventEmitter {
const dictRows = await db.select().from(dictionary).where(gt(dictionary.updatedAt, since))
result.pushed += await this._pushTable('dictionary', dictRows.length, async () => {
if (dictRows.length === 0) return 0
const payload = dictRows.map((r) => ({
id: r.id,
user_id: userId,
word: r.word,
pronunciation: r.pronunciation,
category: r.category,
usage_count: r.usageCount,
last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}))
const payload = dictRows.map((r) => this._mapDictionaryPayload(r, userId))
const { error } = await this._client!.from('dictionary').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return dictRows.length
@ -598,27 +569,7 @@ class CloudSyncService extends EventEmitter {
.where(gt(meetingSessions.updatedAt, since))
result.pushed += await this._pushTable('meetings', meetingRows.length, async () => {
if (meetingRows.length === 0) return 0
const payload = meetingRows.map((r) => ({
id: r.id,
user_id: userId,
team_id: null,
title: r.title,
status: r.status,
started_at: new Date(r.startedAt).toISOString(),
ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null,
duration_ms: r.durationMs,
raw_transcript: r.rawTranscript,
edited_transcript: r.editedTranscript,
minutes_markdown: r.minutesMarkdown,
minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
error_message: r.errorMessage,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}))
const payload = meetingRows.map((r) => this._mapMeetingPayload(r, userId))
const { error } = await this._client!.from('meetings').upsert(payload, { onConflict: 'id' })
if (error) throw new Error(error.message)
return meetingRows.length
@ -633,14 +584,7 @@ class CloudSyncService extends EventEmitter {
.where(this._inArray(meetingMemos.sessionId, meetingIds))
result.pushed += await this._pushTable('meeting_memos', memoRows.length, async () => {
if (memoRows.length === 0) return 0
const payload = memoRows.map((r) => ({
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
content: r.content,
timestamp_ms: r.timestampMs,
created_at: new Date(r.createdAt).toISOString()
}))
const payload = memoRows.map((r) => this._mapMemoPayload(r, userId))
const { error } = await this._client!.from('meeting_memos').upsert(payload, {
onConflict: 'id'
})
@ -655,19 +599,7 @@ class CloudSyncService extends EventEmitter {
.where(this._inArray(meetingDocuments.sessionId, meetingIds))
result.pushed += await this._pushTable('meeting_documents', docRows.length, async () => {
if (docRows.length === 0) return 0
const payload = docRows.map((r) => ({
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
template_type: r.templateType,
title: r.title,
content: r.content,
prompt_used: r.promptUsed,
llm_model: r.llmModel,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}))
const payload = docRows.map((r) => this._mapDocumentPayload(r, userId))
const { error } = await this._client!.from('meeting_documents').upsert(payload, {
onConflict: 'id'
})
@ -1038,6 +970,205 @@ class CloudSyncService extends EventEmitter {
// ── 내부 헬퍼 ──────────────────────────────────────────
// ── 단건 자동 push (Phase 3.3) ─────────────────────────
/**
* row push.
* - silent noop ( write는 )
* - warn만 swallow fire-and-forget로
* - pushAll과 mapper (DRY)
*/
async pushOne(
table: 'history' | 'dictionary' | 'meetings' | 'meeting_memos' | 'meeting_documents',
id: string
): Promise<void> {
if (!this._client || !this._session) {
// 로그아웃 / 미로그인 — 로컬 모드에서는 push 대상이 아님. 조용히 종료.
return
}
const userId = this._session.user.id
const db = getDatabase()
const client = this._client
try {
switch (table) {
case 'history': {
const rows = await db.select().from(history).where(eq(history.id, id)).limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('history')
.upsert(this._mapHistoryPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'dictionary': {
const rows = await db.select().from(dictionary).where(eq(dictionary.id, id)).limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('dictionary')
.upsert(this._mapDictionaryPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meetings': {
const rows = await db
.select()
.from(meetingSessions)
.where(eq(meetingSessions.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meetings')
.upsert(this._mapMeetingPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meeting_memos': {
const rows = await db
.select()
.from(meetingMemos)
.where(eq(meetingMemos.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meeting_memos')
.upsert(this._mapMemoPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
case 'meeting_documents': {
const rows = await db
.select()
.from(meetingDocuments)
.where(eq(meetingDocuments.id, id))
.limit(1)
const row = rows[0]
if (!row) return
const { error } = await client
.from('meeting_documents')
.upsert(this._mapDocumentPayload(row, userId), { onConflict: 'id' })
if (error) throw new Error(error.message)
break
}
}
logger.debug(`pushOne ${table}/${id} ok`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// 로컬 write는 절대 차단하지 않는다 — warn만.
logger.warn(`pushOne ${table}/${id} failed: ${message}`)
}
}
// ── row → Supabase payload 매퍼 (pushAll + pushOne 공통) ─
private _mapHistoryPayload(
r: typeof history.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
original_text: r.originalText,
polished_text: r.polishedText,
mode: r.mode,
status: r.status,
duration: r.duration,
detected_language: r.detectedLanguage,
mic_device: r.micDevice,
word_count: r.wordCount,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString(),
app_version: r.appVersion,
summary_text: r.summaryText
}
}
private _mapDictionaryPayload(
r: typeof dictionary.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
word: r.word,
pronunciation: r.pronunciation,
category: r.category,
usage_count: r.usageCount,
last_used_at: r.lastUsedAt ? new Date(r.lastUsedAt).toISOString() : null,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private _mapMeetingPayload(
r: typeof meetingSessions.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
user_id: userId,
team_id: null,
title: r.title,
status: r.status,
started_at: new Date(r.startedAt).toISOString(),
ended_at: r.endedAt ? new Date(r.endedAt).toISOString() : null,
duration_ms: r.durationMs,
raw_transcript: r.rawTranscript,
edited_transcript: r.editedTranscript,
minutes_markdown: r.minutesMarkdown,
minutes_json: r.minutesJson ? JSON.parse(r.minutesJson) : null,
stt_model: r.sttModel,
llm_model: r.llmModel,
stt_latency_ms: r.sttLatencyMs,
llm_latency_ms: r.llmLatencyMs,
error_message: r.errorMessage,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private _mapMemoPayload(
r: typeof meetingMemos.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
content: r.content,
timestamp_ms: r.timestampMs,
created_at: new Date(r.createdAt).toISOString()
}
}
private _mapDocumentPayload(
r: typeof meetingDocuments.$inferSelect,
userId: string
): Record<string, unknown> {
return {
id: r.id,
meeting_id: r.sessionId,
user_id: userId,
template_type: r.templateType,
title: r.title,
content: r.content,
prompt_used: r.promptUsed,
llm_model: r.llmModel,
llm_latency_ms: r.llmLatencyMs,
created_at: new Date(r.createdAt).toISOString(),
updated_at: new Date(r.updatedAt).toISOString()
}
}
private async _pullTable(
table: string,
runner: () => Promise<number>,

View file

@ -6,6 +6,7 @@ import { getDatabase } from '../db'
import { dictionary } from '../db/schema'
import type { Dictionary, NewDictionary } from '../db/schema'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import type {
DictionaryEntry,
DictionaryQueryParams,
@ -36,6 +37,8 @@ class DictionaryService {
db.insert(dictionary).values(entry).run()
logger.info(`Dictionary entry added: "${params.word}"`)
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('dictionary', id)
return this._toEntry(entry as Dictionary)
}
@ -52,6 +55,8 @@ class DictionaryService {
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('dictionary', params.id)
return updated ? this._toEntry(updated) : null
}

View file

@ -6,6 +6,7 @@ import { getDatabase } from '../db'
import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import type {
HistoryEntry,
HistoryQueryParams,
@ -36,6 +37,9 @@ class HistoryService {
logger.info(`History entry created: ${id}`)
// Phase 3.3: 로그인 상태면 자동 push (fire-and-forget, 로컬 write는 차단하지 않음)
void getCloudSyncService().pushOne('history', id)
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
this.generateTitle(id).catch(() => { /* ignore */ })
@ -209,6 +213,8 @@ class HistoryService {
const db = getDatabase()
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
logger.info(`Auto title generated: ${id} → "${title}"`)
// Phase 3.3: 타이틀 업데이트 후 자동 push
void getCloudSyncService().pushOne('history', id)
return title
}
} catch (err) {

View file

@ -8,6 +8,7 @@ import fs from 'fs'
import { eq, desc, sql } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet, configSet } from './ConfigService'
import { getCloudSyncService } from './CloudSyncService'
import { getMainWindow } from '../windows/WindowManager'
import { getDatabase } from '../db'
import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
@ -197,6 +198,11 @@ class MeetingModeService extends EventEmitter {
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
this._sendStateToRenderer()
// Phase 3.3: 부모 meeting row를 즉시 push (빈 상태로).
// 자식 meeting_memos/meeting_documents의 RLS/FK가 부모 존재를 요구하므로,
// 녹음 중에 들어오는 addMemo/generateDocument 푸시가 통과하려면 필수.
void getCloudSyncService().pushOne('meetings', sessionId)
return { sessionId }
}
@ -230,6 +236,9 @@ class MeetingModeService extends EventEmitter {
this.emit('memo-added', memo)
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meeting_memos', memo.id)
return memo
}
@ -311,6 +320,9 @@ class MeetingModeService extends EventEmitter {
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
// Phase 3.3: 최종 save 후 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meetings', sessionId)
// Step 2.5: 오디오 WAV 파일 저장 (화자 구분용)
if (this._audioBuffersForFile.length > 0) {
try {
@ -654,6 +666,9 @@ class MeetingModeService extends EventEmitter {
sendProgress(100)
logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`)
// Phase 3.3: 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('meeting_documents', docId)
return {
id: docId,
sessionId: params.sessionId,

View file

@ -9,6 +9,7 @@ import { app, dialog } from 'electron'
import { eq } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { getLocalLLMService } from './LocalLLMService'
import { getCloudSyncService } from './CloudSyncService'
import { getDatabase } from '../db'
import { history } from '../db/schema'
import { getMainWindow } from '../windows/WindowManager'
@ -97,6 +98,9 @@ class MeetingSummaryService extends EventEmitter {
.where(eq(history.id, historyId))
.run()
// Phase 3.3: 요약 저장 후 자동 push (fire-and-forget)
void getCloudSyncService().pushOne('history', historyId)
this._sendProgress(historyId, 'done')
this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult)
this.emit('summary-ready', summaryResult)

View file

@ -1,6 +1,7 @@
// apps/web/src/app/dashboard/page.tsx
// 대시보드 — 요약 카드 4개 + 최근 회의
import Link from 'next/link'
import { Box, Grid, Stack } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
@ -154,12 +155,25 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
</MetalCard>
) : (
recentMeetings.map((meeting) => (
<MetalCard key={meeting.id} sx={{ p: 3 }}>
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')}
</Box>
</MetalCard>
<Link
key={meeting.id}
href={`/meetings/${meeting.id}`}
style={{ textDecoration: 'none' }}
>
<MetalCard
sx={{
p: 3,
cursor: 'pointer',
transition: 'transform 120ms ease, box-shadow 120ms ease',
'&:hover': { transform: 'translateY(-1px)' }
}}
>
<Box sx={{ color: d3roPalette.text.primary, ...typoSx("body") }}>{meeting.title}</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 12, mt: 0.5 }}>
{new Date(meeting.started_at).toLocaleString('ko-KR')}
</Box>
</MetalCard>
</Link>
))
)}
</Stack>

View file

@ -68,15 +68,33 @@ export default async function MeetingDetailPage({ params }: PageProps): Promise<
<MeetingAudioPlayer storageKey={meeting.audio_storage_key ?? null} />
</MetalCard>
{/* Transcript — Realtime 구독 */}
{/* Transcript — Realtime 구독 or raw_transcript fallback */}
<MetalCard sx={{ p: 3 }}>
<PhosphorText variant="heading" sx={{ mb: 2 }}>
TRANSCRIPT (LIVE)
TRANSCRIPT {meeting.status === 'recording' ? '(LIVE)' : ''}
</PhosphorText>
<LiveTranscriptList
meetingId={id}
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
/>
{(transcripts ?? []).length > 0 || meeting.status === 'recording' ? (
<LiveTranscriptList
meetingId={id}
initial={(transcripts ?? []) as unknown as TranscriptRow[]}
/>
) : meeting.edited_transcript || meeting.raw_transcript ? (
<Box
sx={{
whiteSpace: 'pre-wrap',
color: d3roPalette.text.primary,
fontSize: 13,
lineHeight: 1.6,
fontFamily: 'inherit'
}}
>
{meeting.edited_transcript ?? meeting.raw_transcript}
</Box>
) : (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>
.
</Box>
)}
</MetalCard>
{/* Memos */}