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

@ -8,14 +8,18 @@
// vitest run tests/integration/cross-device-sync.supabase.test.ts
// 환경변수가 없으면 건너뛴다(CI 기본).
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { nodeRealtimeTransport } from '../../src/main/services/sync/realtime-transport'
import { createTestDb } from '../helpers/createTestDb'
import { bindTestDatabase, unbindTestDatabase } from '../../src/main/db'
import { dictionary, history, meetingMemos, meetingSessions, memoTags } from '../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
import { dictionary, history, meetingMemos, meetingSessions, memoTags, ragChunks, ragDocuments } from '../../src/main/db/schema'
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../src/main/services/ConfigService'
import { resetRAGServiceForTests } from '../../src/main/services/RAGService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
@ -252,6 +256,82 @@ describe.skipIf(!enabled)('cross-device sync against local Supabase', () => {
expect(seen).toContain('tombstone')
}, 45_000)
it('지식 문서(원문 청크)·설정·녹음이 실제 RLS/저장소 정책을 통과해 오간다', async () => {
const db = testDb.db
resetRAGServiceForTests()
// 로컬 임베딩 서버(Ollama)는 없다고 보고 원문 저장 경로만 확인한다(Supabase 호출은 통과시킨다).
const realFetch = globalThis.fetch
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) =>
String(input instanceof Request ? input.url : input).includes('11434')
? Promise.reject(new TypeError('fetch failed'))
: realFetch(input, init)
)
// 데스크톱 지식 문서
const docId = crypto.randomUUID()
db.insert(ragDocuments).values({ id: docId, fileName: 'desk.md', filePath: '', fileType: 'md', chunkCount: 2, indexed: false, addedAt: Date.now() }).run()
;['alpha chunk', 'beta chunk'].forEach((content, chunkIndex) =>
db.insert(ragChunks).values({ id: crypto.randomUUID(), documentId: docId, content, embedding: '', chunkIndex }).run()
)
enqueueChange('knowledge_documents', docId, 'upsert')
// 모바일 지식 문서
const phoneDoc = crypto.randomUUID()
must(await mobile.from('knowledge_documents').insert({ id: phoneDoc, user_id: userId, title: 'Phone doc', file_name: 'phone.txt', file_type: 'txt', chunk_count: 1 }))
must(await mobile.from('knowledge_chunks').insert({ document_id: phoneDoc, chunk_index: 0, content: 'from the phone' }))
// 녹음이 있는 데스크톱 기록
const audioDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-it-audio-'))
const audioFile = path.join(audioDir, 'clip.wav')
fs.writeFileSync(audioFile, Buffer.from('RIFF----WAVEfmt integration audio bytes'))
const clipId = crypto.randomUUID()
db.insert(history).values({ id: clipId, originalText: 'with audio', duration: 1, audioLocalPath: audioFile, createdAt: Date.now(), updatedAt: Date.now() }).run()
enqueueChange('history', clipId, 'upsert')
enqueueChange('history_audio', clipId, 'upsert')
// 설정: 데스크톱 변경 → 서버, 이후 모바일 변경 → 데스크톱
configSet('theme', 'dark')
enqueueChange('user_settings', 'self', 'upsert')
const pushed = await engine.flush()
expect(pushed.errors).toEqual([])
const remoteChunks = must(await mobile.from('knowledge_chunks').select('content').eq('document_id', docId).order('chunk_index'))
expect(remoteChunks.map((c) => c.content)).toEqual(['alpha chunk', 'beta chunk'])
const audio = must(await mobile.from('audio_files').select('storage_key,upload_status,mime_type').eq('history_id', clipId))
expect(audio).toHaveLength(1)
expect(audio[0].upload_status).toBe('uploaded')
const signed = await mobile.storage.from('audio').createSignedUrl(String(audio[0].storage_key), 60)
expect(signed.error).toBeNull()
const settings = must(await mobile.from('user_settings').select('theme_mode,revision').eq('user_id', userId).single())
expect(settings.theme_mode).toBe('dark')
must(
await mobile
.from('user_settings')
.update({ locale: 'en', revision: Number(settings.revision) + 1 })
.eq('user_id', userId)
.eq('revision', settings.revision)
)
await engine.pull()
expect(configGet('language')).toBe('en')
const pulledChunks = db.select().from(ragChunks).where(eq(ragChunks.documentId, phoneDoc)).all()
expect(pulledChunks.map((c) => c.content)).toEqual(['from the phone'])
// 모바일이 지식 문서를 지우면 데스크톱에서도 사라진다(삭제 기록)
must(await mobile.from('knowledge_documents').delete().eq('id', phoneDoc))
await engine.pull()
expect(db.select().from(ragDocuments).where(eq(ragDocuments.id, phoneDoc)).all()).toEqual([])
// 데스크톱 기록 삭제 → 저장소 녹음도 정리
db.delete(history).where(eq(history.id, clipId)).run()
enqueueChange('history', clipId, 'delete')
const deleted = await engine.flush()
expect(deleted.errors).toEqual([])
expect(must(await mobile.from('audio_files').select('id').eq('user_id', userId).eq('storage_key', String(audio[0].storage_key)))).toEqual([])
fs.rmSync(audioDir, { recursive: true, force: true })
vi.restoreAllMocks()
}, 60_000)
it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => {
const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' }
const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null)