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:
parent
cee4ab9317
commit
9a8f7e6aa6
34 changed files with 1405 additions and 64 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue