feat(desktop): send meeting transcript segments and preset prompt edits to the phone

The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.

Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
This commit is contained in:
Yun Chan 2026-09-27 16:24:25 +09:00
parent 08c6504589
commit 2aac10fc5d
14 changed files with 552 additions and 28 deletions

View file

@ -159,10 +159,11 @@ class CustomInstructionService {
const existing = instructions[index]
// 프리셋은 프롬프트만 수정 가능
// 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다
if (existing.isBuiltin) {
if (data.prompt !== undefined) {
instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() }
getCloudSyncService().pushOne('builtin_instructions', id)
}
} else {
instructions[index] = { ...existing, ...data, updatedAt: Date.now() }
@ -203,6 +204,15 @@ class CustomInstructionService {
saveInstructions()
}
/** 동기화: 다른 기기에서 바꾼 프리셋 프롬프트를 반영한다(outbox에 넣지 않는다). 바꿨으면 true */
applyRemoteBuiltinPrompt(id: string, prompt: string): boolean {
const index = instructions.findIndex((i) => i.id === id)
if (index === -1 || !instructions[index].isBuiltin || instructions[index].prompt === prompt) return false
instructions[index] = { ...instructions[index], prompt, updatedAt: Date.now() }
saveInstructions()
return true
}
/** 동기화: 다른 기기에서 지운 사용자 명령을 지운다. 프리셋은 건드리지 않는다. */
removeRemote(id: string): boolean {
const index = instructions.findIndex((i) => i.id === id)
@ -252,6 +262,7 @@ class CustomInstructionService {
instructions = [...builtins, ...userInstructions]
saveInstructions()
for (const builtin of builtins) getCloudSyncService().pushOne('builtin_instructions', builtin.id)
logger.info('Builtin instructions reset')
}
@ -260,6 +271,11 @@ class CustomInstructionService {
}
}
/** 데스크톱 프리셋의 기본 프롬프트(되돌리기·수정 여부 판정용). 프리셋이 아니면 null */
export function getBuiltinInstructionDefaultPrompt(id: string): string | null {
return BUILTIN_INSTRUCTIONS.find((b) => b.id === id)?.prompt ?? null
}
let instance: CustomInstructionService | null = null
export function getCustomInstructionService(): CustomInstructionService {

View file

@ -20,6 +20,12 @@ import {
} from './memo-tag-sync'
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync'
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync'
import {
applyRemoteBuiltins,
editedBuiltinsDifferingFrom,
fetchRemoteBuiltins,
pushBuiltinPrompts,
} from './builtin-instruction-sync'
import {
completeEntry,
dropEntry,
@ -223,6 +229,11 @@ export class SyncEngine extends EventEmitter {
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now)
queued++
}
// 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다).
for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) {
enqueueChange('builtin_instructions', id, 'upsert', now)
queued++
}
if (isAudioSyncEnabled()) {
for (const owner of ['history', 'meeting'] as const) {
const local = listLocalAudioOwners(owner)
@ -290,6 +301,7 @@ export class SyncEngine extends EventEmitter {
'meeting_memos',
'meeting_documents',
'custom_instructions',
'builtin_instructions',
'user_settings',
'user_templates',
'knowledge_documents',
@ -326,6 +338,8 @@ export class SyncEngine extends EventEmitter {
return pushMemoTagAdds(ctx, ids)
case 'user_settings':
return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }]
case 'builtin_instructions':
return pushBuiltinPrompts(ctx, ids)
case 'history_audio':
return pushAudio(ctx, 'history', ids)
case 'meeting_audio':
@ -416,6 +430,19 @@ export class SyncEngine extends EventEmitter {
logger.warn(`Pull user_settings failed: ${message}`)
}
try {
const builtins = await fetchRemoteBuiltins({ remote: this.remote, userId: this.userId })
const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys()))
if (applied > 0) {
changed.add('custom_instructions')
result.pulled += applied
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
result.errors.push(`builtin_instructions: ${message}`)
logger.warn(`Pull builtin_instructions failed: ${message}`)
}
result.changed = [...changed]
if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now()))
}

View file

@ -0,0 +1,118 @@
// src/main/services/sync/builtin-instruction-sync.ts
// 데스크톱 프리셋 명령의 프롬프트 수정을 서버 프리셋 행(builtin_key)에 맞춘다.
// 폰은 명령을 서버 행의 프롬프트로 실행하므로, 데스크톱에서 고친 프롬프트가 폰에서도 쓰인다.
// 서버 행은 RLS상 직접 못 고치므로 sync_set_builtin_instruction_prompt_v1 로만 바꾼다(null = 기본값 복원).
import { getBuiltinInstructionDefaultPrompt, getCustomInstructionService } from '../CustomInstructionService'
import type { PushContext } from './sync-adapters'
import { toSyncRemoteError, type PushOutcome } from './sync-types'
/** 데스크톱 프리셋 id → 서버 builtin_key. '자유 프롬프트'는 실행 때 입력받는 명령이라 폰에 대응이 없다. */
export const DESKTOP_TO_SERVER_BUILTIN: Readonly<Record<string, string>> = {
'builtin-translate': 'translate_en',
'builtin-summarize': 'summarize',
'builtin-formal': 'formal',
'builtin-explain-code': 'explain_code',
}
const SERVER_TO_DESKTOP_BUILTIN: Readonly<Record<string, string>> = Object.fromEntries(
Object.entries(DESKTOP_TO_SERVER_BUILTIN).map(([desktop, server]) => [server, desktop])
)
export function isSyncedBuiltin(id: string): boolean {
return id in DESKTOP_TO_SERVER_BUILTIN
}
/**
* 폰은 `{{text}}` 외의 자리표시자를 치환하지 않는다. 서버 번역 프리셋은 영어 고정(translate_en)이고
* 데스크톱 번역도 대상 언어 설정이 없어 영어로만 번역하므로 `{{targetLanguage}}` 를 English 로 바꿔 올린다.
*/
export function toServerPrompt(desktopId: string, prompt: string): string {
return desktopId === 'builtin-translate' ? prompt.replace(/\{\{targetLanguage\}\}/g, 'English') : prompt
}
function isEdited(desktopId: string, prompt: string): boolean {
const defaultPrompt = getBuiltinInstructionDefaultPrompt(desktopId)
return defaultPrompt !== null && prompt.trim() !== defaultPrompt.trim()
}
export async function pushBuiltinPrompts(ctx: PushContext, ids: string[]): Promise<PushOutcome[]> {
const service = getCustomInstructionService()
const outcomes: PushOutcome[] = []
for (const id of ids) {
const key = DESKTOP_TO_SERVER_BUILTIN[id]
const local = service.getById(id)
if (!key || !local) {
outcomes.push({ id, error: null })
continue
}
try {
await ctx.remote.rpc('sync_set_builtin_instruction_prompt_v1', {
p_builtin_key: key,
p_prompt: isEdited(id, local.prompt) ? toServerPrompt(id, local.prompt) : null,
})
outcomes.push({ id, error: null })
} catch (err) {
outcomes.push({ id, error: toSyncRemoteError(err) })
}
}
return outcomes
}
interface RemoteBuiltin {
key: string
prompt: string
isDefault: boolean
}
export async function fetchRemoteBuiltins(ctx: PushContext): Promise<RemoteBuiltin[]> {
const data = await ctx.remote.rpc('sync_list_builtin_instructions_v1', {})
if (!Array.isArray(data)) return []
const rows: RemoteBuiltin[] = []
for (const item of data) {
if (typeof item !== 'object' || item === null) continue
const row = item as Record<string, unknown>
if (typeof row.builtin_key === 'string' && typeof row.prompt === 'string') {
rows.push({ key: row.builtin_key, prompt: row.prompt, isDefault: row.is_default === true })
}
}
return rows
}
/** 데스크톱에서 고쳤지만 서버와 다른 프리셋 (최초 대조용) */
export function editedBuiltinsDifferingFrom(remote: RemoteBuiltin[]): string[] {
const service = getCustomInstructionService()
const byKey = new Map(remote.map((r) => [r.key, r]))
return Object.entries(DESKTOP_TO_SERVER_BUILTIN)
.filter(([id, key]) => {
const local = service.getById(id)
if (!local || !isEdited(id, local.prompt)) return false
const server = byKey.get(key)
return !server || server.prompt !== toServerPrompt(id, local.prompt)
})
.map(([id]) => id)
}
/**
* 서버 프리셋 프롬프트를 로컬에 반영한다. 기본값이면 데스크톱 기본값으로, 아니면 서버 문구로.
* 올린 적이 있는 같은 문구(번역의 English 치환 포함)는 그대로 둔다. 바꾼 개수를 돌려준다.
*/
export function applyRemoteBuiltins(remote: RemoteBuiltin[], pending: ReadonlySet<string>): number {
const service = getCustomInstructionService()
let changed = 0
for (const row of remote) {
const id = SERVER_TO_DESKTOP_BUILTIN[row.key]
if (!id || pending.has(id)) continue
const local = service.getById(id)
if (!local) continue
let next: string | null = null
if (row.isDefault) {
const defaultPrompt = getBuiltinInstructionDefaultPrompt(id)
if (defaultPrompt !== null && isEdited(id, local.prompt)) next = defaultPrompt
} else if (toServerPrompt(id, local.prompt) !== row.prompt) {
next = row.prompt
}
if (next !== null && service.applyRemoteBuiltinPrompt(id, next)) changed++
}
return changed
}

View file

@ -122,6 +122,17 @@ export class SupabaseSyncRemote implements SyncRemote {
if (error) throw toSyncRemoteError(error)
}
async deleteChildrenFrom(
table: string,
parentColumn: string,
parentId: string,
indexColumn: string,
fromIndex: number
): Promise<void> {
const { error } = await this.client.from(table).delete().eq(parentColumn, parentId).gte(indexColumn, fromIndex)
if (error) throw toSyncRemoteError(error)
}
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
const { data, error } = await this.client.functions.invoke(name, { body })
if (error) throw toSyncRemoteError(error)

View file

@ -18,6 +18,7 @@ import { getDictationTemplateService } from '../DictationTemplateService'
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
import { getRAGService } from '../RAGService'
import { purgeRemoteAudio } from './audio-sync'
import { pushMeetingSegments } from './transcript-sync'
import { dropEntry } from './sync-outbox'
import {
SyncRemoteError,
@ -441,6 +442,17 @@ const meetingsAdapter: SyncAdapter = {
'meetings',
rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) }))
)
// 회의 행이 올라간 뒤 전사 구간을 맞춘다 — 폰은 구간이 있으면 구간을 그린다.
const byId = new Map(rows.map((r) => [r.id, r]))
for (const outcome of outcomes) {
const row = byId.get(outcome.id)
if (outcome.error !== null || !row) continue
try {
await pushMeetingSegments(ctx, row)
} catch (err) {
outcome.error = toSyncRemoteError(err)
}
}
return [...outcomes, ...missingAsDone(ids, found)]
},
async pushDeletes(ctx, ids) {

View file

@ -17,6 +17,8 @@ export const SYNC_ENTITIES = [
/** 녹음 파일 업로드(row_id = history id / meeting id) */
'history_audio',
'meeting_audio',
/** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */
'builtin_instructions',
] as const
export type SyncEntity = (typeof SYNC_ENTITIES)[number]
@ -68,6 +70,8 @@ export interface SyncRemote {
updateMatching(table: string, userId: string, match: Record<string, string | number>, patch: RemoteRow): Promise<number>
deleteByIds(table: string, userId: string, ids: string[]): Promise<void>
deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void>
/** 부모의 자식 중 indexColumn >= fromIndex 인 행을 지운다 (줄어든 구간 정리) */
deleteChildrenFrom(table: string, parentColumn: string, parentId: string, indexColumn: string, fromIndex: number): Promise<void>
rpc(name: string, params: Record<string, unknown>): Promise<unknown>
invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown>
uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise<void>

View file

@ -0,0 +1,44 @@
// src/main/services/sync/transcript-sync.ts
// 데스크톱 회의 전사를 Supabase `transcripts` 구간으로 맞춘다.
// 모바일은 구간이 하나라도 있으면 edited_transcript 대신 구간을 그리므로, 데스크톱이 전사를 바꾸면
// (녹음 완료·직접 수정·자동 다듬기·화자 구분) 구간도 같이 바꿔야 폰에 보인다.
import { parseTranscriptLines } from '@d3ro/core/meeting-transcript'
import type { meetingSessions } from '../../db/schema'
import type { PushContext } from './sync-adapters'
import type { RemoteRow } from './sync-types'
const SEGMENT_BATCH = 500
/** 로컬 회의 행 → 서버 구간 행. 전사가 없으면(녹음 중 등) null — 서버 구간을 건드리지 않는다. */
export function meetingSegmentsToRemote(row: typeof meetingSessions.$inferSelect): RemoteRow[] | null {
const edited = row.editedTranscript !== null && row.editedTranscript.trim() !== ''
const lines = parseTranscriptLines(edited ? row.editedTranscript : row.rawTranscript)
if (lines.length === 0) return null
return lines.map((line, index) => {
const next = lines[index + 1]
const duration = next ? next.timestampMs - line.timestampMs : null
return {
meeting_id: row.id,
segment_index: index,
timestamp_ms: line.timestampMs,
duration_ms: duration !== null && duration > 0 ? duration : null,
text: line.text,
speaker: line.speaker,
edited,
}
})
}
/** 구간을 upsert 하고, 줄 수가 줄었으면 뒤쪽 구간을 지운다(한 번에 비우지 않아 폰 화면이 깜빡이지 않는다). */
export async function pushMeetingSegments(
ctx: PushContext,
row: typeof meetingSessions.$inferSelect
): Promise<void> {
const segments = meetingSegmentsToRemote(row)
if (!segments) return
for (let i = 0; i < segments.length; i += SEGMENT_BATCH) {
await ctx.remote.upsert('transcripts', segments.slice(i, i + SEGMENT_BATCH), 'meeting_id,segment_index')
}
await ctx.remote.deleteChildrenFrom('transcripts', 'meeting_id', row.id, 'segment_index', segments.length)
}

View file

@ -2,6 +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 {
Box,
Tabs,
@ -54,33 +55,14 @@ function parseSegments(
rawTranscript: string | null,
editedTranscript: string | null,
): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> {
const source = editedTranscript || rawTranscript || ''
const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/
return source
.split('\n')
.filter((line) => line.trim())
.map((line, idx) => {
const m = regex.exec(line.trim())
if (m) {
const min = parseInt(m[1], 10)
const sec = parseInt(m[2], 10)
const speaker = m[3] || undefined
const text = m[4]
return {
id: `seg-${idx}`,
timestamp: (min * 60 + sec) * 1000,
text,
edited: false,
speaker,
}
}
return {
id: `seg-${idx}`,
timestamp: idx * 4000,
text: line.trim(),
edited: false,
}
})
// 줄 ↔ 구간 변환은 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