feat(desktop): sync knowledge, recordings and shared settings; play any recording

Knowledge documents travel as source-text chunks; each surface embeds them
with its own model, the server index is requested through embed-chunks, and
documents from the phone are stored without a file and indexed from their
chunks. Chunk text is now kept when local embedding fails, so reindexing no
longer needs the original file.

Recordings upload to the mobile storage contract (audio bucket under the
user's folder plus an audio_files row, 50 MiB cap, a Settings > Cloud
toggle) and are removed with their record. The history card gains a play
button that uses the local file or, for phone recordings, a signed URL.

Language (ko/en), system/light/dark theme, auto-polish and the active user
command follow the phone's user_settings with its revision rule; changes that
arrive from the phone reach the open window.
This commit is contained in:
Yun Chan 2026-09-27 14:44:56 +09:00
parent cee4ab9317
commit 9a8f7e6aa6
34 changed files with 1405 additions and 64 deletions

View file

@ -6,6 +6,9 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode, D3ROError } from '@d3ro/core/errors'
import { getCloudSyncService } from '../services/CloudSyncService'
import { getLogger } from '../services/LoggerService'
import { onConfigChanged } from '../services/ConfigService'
import { isApplyingRemoteSettings } from '../services/sync/settings-sync'
import { reapplyThemeToAllPopups } from '../windows/WindowManager'
const logger = getLogger('cloud-sync-handlers')
@ -115,6 +118,13 @@ export function registerCloudSyncHandlers(): void {
broadcast(IPC_CHANNELS.CLOUD_SYNC.SYNC_ERROR, payload)
})
// 다른 기기의 변경이 반영되면 앱 공통 새로고침 신호로 알린다 (이력·사전·회의·명령 화면이 구독).
// 동기화가 메인에서 바꾼 설정(언어·테마 등)을 화면에도 알린다 — IPC로 바꿀 때만 방송되던 경로.
onConfigChanged((event) => {
if (!isApplyingRemoteSettings()) return
broadcast(IPC_CHANNELS.CONFIG.CHANGED, event)
if (event.key === 'theme') reapplyThemeToAllPopups()
})
sync.on('data-changed', (payload) => {
broadcast(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'cloud-sync', entities: payload.entities })
})

View file

@ -3,15 +3,22 @@
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import fs from 'fs'
import { getHistoryService } from '../services/HistoryService'
import { getCloudSyncService } from '../services/CloudSyncService'
import { audioMimeType } from '../services/sync/audio-sync'
import type {
HistoryQueryParams,
HistoryGetByIdParams,
HistoryDeleteParams,
HistorySearchParams,
HistorySetFavoriteParams
HistorySetFavoriteParams,
HistoryAudioSource
} from '@d3ro/core/types'
/** 렌더러로 넘기는 로컬 녹음 상한. 넘으면 재생하지 않는다(IPC 복사 비용). */
const MAX_LOCAL_AUDIO_BYTES = 100 * 1024 * 1024
export function registerHistoryHandlers(): void {
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_ALL, async (_event, params: HistoryQueryParams) => {
try {
@ -67,6 +74,27 @@ export function registerHistoryHandlers(): void {
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_AUDIO, async (_event, params: HistoryGetByIdParams) => {
try {
const entry = getHistoryService().getById(params.id)
if (!entry) return ipcError(ErrorCode.HistoryNotFound, `History entry not found: ${params.id}`)
const localPath = entry.audioLocalPath
if (localPath && fs.existsSync(localPath) && fs.statSync(localPath).size <= MAX_LOCAL_AUDIO_BYTES) {
const source: HistoryAudioSource = {
kind: 'local',
bytes: new Uint8Array(fs.readFileSync(localPath)),
mimeType: audioMimeType(localPath) ?? 'audio/wav',
}
return ipcSuccess(source)
}
const url = await getCloudSyncService().createHistoryAudioUrl(params.id)
return ipcSuccess(url ? ({ kind: 'remote', url } satisfies HistoryAudioSource) : null)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ipcError(ErrorCode.DBQueryFailed, `Failed to load audio: ${message}`)
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => {
try {
return ipcSuccess(getHistoryService().search(params))