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

@ -0,0 +1,203 @@
// 동기화 확장 — 지식베이스(원문 청크), 모바일 user_settings, 녹음 파일 업로드.
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { history, ragChunks, ragDocuments } from '../../../src/main/db/schema'
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange, outboxCounts, pendingOps } from '../../../src/main/services/sync/sync-outbox'
import { remoteToLocalPatch, toRemoteSettings } from '../../../src/main/services/sync/settings-sync'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
let tmpDir: string
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests()
getCustomInstructionService().initialize()
// 로컬 임베딩 서버(Ollama)는 테스트에 없다 — 즉시 실패시켜 원문만 저장되는 경로를 탄다.
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-sync-audio-'))
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
})
function insertLocalDocument(chunks: string[]): string {
const id = crypto.randomUUID()
testDb.db
.insert(ragDocuments)
.values({ id, fileName: 'notes.md', filePath: '', fileType: 'md', chunkCount: chunks.length, indexed: false, addedAt: Date.now() })
.run()
chunks.forEach((content, chunkIndex) => {
testDb.db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: id, content, embedding: '', chunkIndex }).run()
})
return id
}
describe('지식베이스', () => {
it('데스크톱 문서를 원문 청크로 올리고 서버 임베딩을 요청한다', async () => {
const id = insertLocalDocument(['first chunk text', 'second chunk text'])
const result = await engine.runFullSync()
expect(result.errors).toEqual([])
expect(remote.find('knowledge_documents', id)?.chunk_count).toBe(2)
expect(remote.rows('knowledge_chunks').filter((c) => c.document_id === id).map((c) => c.content)).toEqual([
'first chunk text',
'second chunk text',
])
expect(remote.invoked).toContain(`embed-chunks:${id}`)
})
it('모바일 문서를 받아 원문을 저장하고, 모바일 삭제를 반영한다', async () => {
const id = crypto.randomUUID()
remote.mobileInsert('knowledge_documents', { id, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 2 })
remote.rows('knowledge_chunks').push(
{ id: crypto.randomUUID(), document_id: id, chunk_index: 1, content: 'second' },
{ id: crypto.randomUUID(), document_id: id, chunk_index: 0, content: 'first' }
)
await engine.runFullSync()
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
expect(doc?.fileName).toBe('phone.txt')
expect(doc?.filePath).toBe('')
const chunks = testDb.db.select().from(ragChunks).where(eq(ragChunks.documentId, id)).all()
expect(chunks.sort((a, b) => a.chunkIndex - b.chunkIndex).map((c) => c.content)).toEqual(['first', 'second'])
remote.mobileDelete('knowledge_documents', id)
await engine.pull()
expect(testDb.db.select().from(ragDocuments).all()).toEqual([])
expect(testDb.db.select().from(ragChunks).all()).toEqual([])
})
})
describe('설정 (user_settings)', () => {
it('서버에 행이 없으면 데스크톱 값을 올린다', async () => {
configSet('language', 'en')
configSet('theme', 'dark')
configSet('defaultLLMAction', 'none')
await engine.runFullSync()
const row = remote.rows('user_settings')[0]
expect(row).toMatchObject({ locale: 'en', theme_mode: 'dark', auto_polish_enabled: false })
})
it('서버에 행이 있으면 모바일 값이 이기고, 반영이 다시 올라가지 않는다', async () => {
configSet('language', 'en')
configSet('theme', 'auto')
configSet('defaultLLMAction', 'none')
remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'light', auto_polish_enabled: true, revision: 4, active_instruction_id: null })
const result = await engine.runFullSync()
expect(result.changed).toContain('user_settings')
expect(configGet('language')).toBe('ko')
expect(configGet('theme')).toBe('light')
expect(configGet('defaultLLMAction')).toBe('refine')
expect(pendingOps('user_settings').size).toBe(0)
expect(remote.rows('user_settings')[0].revision).toBe(4)
})
it('데스크톱 변경은 revision 조건부로 올리고, 같은 값이면 쓰지 않는다', async () => {
remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, revision: 2, active_instruction_id: null })
await engine.runFullSync()
configSet('theme', 'dark')
enqueueChange('user_settings', 'self', 'upsert')
await engine.flush()
expect(remote.rows('user_settings')[0]).toMatchObject({ theme_mode: 'dark', revision: 3 })
enqueueChange('user_settings', 'self', 'upsert')
await engine.flush()
expect(remote.rows('user_settings')[0].revision).toBe(3)
expect(outboxCounts().pending).toBe(0)
})
it('양쪽에 있는 사용자 명령만 활성 명령으로 맞춘다', async () => {
const command = getCustomInstructionService().create({ name: 'Calm', description: '', prompt: 'Make it calm' })
await engine.runFullSync()
const row = remote.rows('user_settings')[0]
Object.assign(row, { active_instruction_id: command.id, revision: Number(row.revision) + 1 })
await engine.pull()
expect(configGet('activeInstructionId')).toBe(command.id)
// 데스크톱 프리셋이 켜져 있으면 모바일의 '없음'이 덮지 않는다
const local = { language: 'ko', theme: 'auto' as const, defaultLLMAction: 'refine' as const, activeInstructionId: 'builtin-translate' }
expect(remoteToLocalPatch({ active_instruction_id: null }, local, new Set([command.id]))).toEqual({})
})
it('데스크톱 전용 테마·언어는 서버로 보내지 않는다', () => {
expect(toRemoteSettings({ language: 'ja', theme: 'nord', defaultLLMAction: 'translate', activeInstructionId: '' })).toEqual({
auto_polish_enabled: true,
})
})
})
describe('녹음 파일', () => {
function insertHistoryWithAudio(): { id: string; file: string } {
const id = crypto.randomUUID()
const file = path.join(tmpDir, `${id}.wav`)
fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake audio'))
const now = Date.now()
testDb.db.insert(history).values({ id, originalText: 'spoken', duration: 2, audioLocalPath: file, createdAt: now, updatedAt: now }).run()
return { id, file }
}
it('기록 녹음을 모바일과 같은 경로·행으로 올리고, 기록을 지우면 파일도 지운다', async () => {
const { id } = insertHistoryWithAudio()
const result = await engine.runFullSync()
expect(result.errors).toEqual([])
const key = `${USER}/desktop/history/${id}.wav`
expect(remote.objects.has(`audio/${key}`)).toBe(true)
expect(remote.rows('audio_files')[0]).toMatchObject({
history_id: id,
storage_key: key,
mime_type: 'audio/wav',
upload_status: 'uploaded',
duration_ms: 2000,
})
// 같은 파일은 다시 올리지 않는다
enqueueChange('history_audio', id, 'upsert')
await engine.flush()
expect(remote.rows('audio_files')).toHaveLength(1)
testDb.db.delete(history).where(eq(history.id, id)).run()
enqueueChange('history', id, 'delete')
await engine.flush()
expect(remote.objects.size).toBe(0)
expect(remote.rows('audio_files')).toHaveLength(0)
})
it('녹음 동기화를 끄면 올리지 않는다', async () => {
configSet('cloudSyncAudio', false)
insertHistoryWithAudio()
await engine.runFullSync()
expect(remote.objects.size).toBe(0)
expect(outboxCounts().pending).toBe(0)
})
})