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
|
|
@ -10,11 +10,14 @@ import {
|
|||
meetingMemos,
|
||||
meetingSessions,
|
||||
memoTags,
|
||||
ragDocuments,
|
||||
} from '../../db/schema'
|
||||
import type { CustomInstruction, DictationTemplate, MeetingDocTemplate, TemplateField } from '@d3ro/core/types'
|
||||
import { getCustomInstructionService } from '../CustomInstructionService'
|
||||
import { getDictationTemplateService } from '../DictationTemplateService'
|
||||
import { getMeetingDocTemplateService } from '../MeetingDocTemplateService'
|
||||
import { getRAGService } from '../RAGService'
|
||||
import { purgeRemoteAudio } from './audio-sync'
|
||||
import { dropEntry } from './sync-outbox'
|
||||
import {
|
||||
SyncRemoteError,
|
||||
|
|
@ -43,8 +46,8 @@ export interface SyncAdapter {
|
|||
listLocalVersions(): LocalVersion[]
|
||||
push(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
pushDeletes(ctx: PushContext, ids: string[]): Promise<PushOutcome[]>
|
||||
/** 원격 행을 로컬에 반영. 반영했으면 true */
|
||||
applyRemote(row: RemoteRow): boolean
|
||||
/** 원격 행을 로컬에 반영. 반영했으면 true. 자식 행을 더 읽어야 하는 엔티티는 비동기다 */
|
||||
applyRemote(row: RemoteRow, ctx: PushContext): boolean | Promise<boolean>
|
||||
/** 원격 삭제를 로컬에 반영. 지운 행이 있으면 true */
|
||||
deleteLocal(id: string): boolean
|
||||
}
|
||||
|
|
@ -209,7 +212,9 @@ const historyAdapter: SyncAdapter = {
|
|||
)
|
||||
return [...outcomes, ...missingAsDone(ids, found)]
|
||||
},
|
||||
pushDeletes(ctx, ids) {
|
||||
async pushDeletes(ctx, ids) {
|
||||
// 지운 기록의 녹음 파일도 저장소에서 지운다(행은 SET NULL로 남아 고아가 된다).
|
||||
await purgeRemoteAudio(ctx, 'history_id', ids)
|
||||
return deleteRemote(ctx, 'history', ids)
|
||||
},
|
||||
applyRemote(row) {
|
||||
|
|
@ -296,11 +301,9 @@ async function adoptRemoteDictionaryRow(
|
|||
const match = matches.find((m) => isUuid(m.id) && m.id !== localId)
|
||||
if (!match || !isUuid(match.id)) return error
|
||||
const remoteId = match.id
|
||||
db.transaction((tx) => {
|
||||
tx.delete(dictionary).where(eq(dictionary.id, localId)).run()
|
||||
})
|
||||
db.delete(dictionary).where(eq(dictionary.id, localId)).run()
|
||||
dropEntry('dictionary', localId)
|
||||
dictionaryAdapter.applyRemote(match)
|
||||
void dictionaryAdapter.applyRemote(match, ctx)
|
||||
const merged = db.select().from(dictionary).where(eq(dictionary.id, remoteId)).get()
|
||||
if (merged && local.usageCount > merged.usageCount) {
|
||||
db.update(dictionary)
|
||||
|
|
@ -440,7 +443,8 @@ const meetingsAdapter: SyncAdapter = {
|
|||
)
|
||||
return [...outcomes, ...missingAsDone(ids, found)]
|
||||
},
|
||||
pushDeletes(ctx, ids) {
|
||||
async pushDeletes(ctx, ids) {
|
||||
await purgeRemoteAudio(ctx, 'meeting_id', ids)
|
||||
return deleteRemote(ctx, 'meetings', ids)
|
||||
},
|
||||
applyRemote(row) {
|
||||
|
|
@ -841,6 +845,95 @@ const userTemplatesAdapter: SyncAdapter = {
|
|||
},
|
||||
}
|
||||
|
||||
// ── knowledge_documents ─────────────────────────────────
|
||||
// 원문 청크만 옮긴다. 임베딩은 기기마다 모델·차원이 달라(데스크톱 nomic 768 vs 서버 1536) 각자 만든다.
|
||||
|
||||
const KNOWLEDGE_TYPES = ['txt', 'md', 'pdf', 'docx'] as const
|
||||
const CHUNK_BATCH = 200
|
||||
|
||||
async function pushKnowledgeDocument(ctx: PushContext, id: string): Promise<SyncRemoteError | null> {
|
||||
const db = getDatabase()
|
||||
const doc = db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
if (!doc) return null
|
||||
const chunks = getRAGService().getStoredChunks(id)
|
||||
if (chunks.length === 0) return null
|
||||
try {
|
||||
await ctx.remote.upsert('knowledge_documents', [
|
||||
{
|
||||
id: doc.id,
|
||||
user_id: ctx.userId,
|
||||
title: doc.fileName.slice(0, 300),
|
||||
file_name: doc.fileName,
|
||||
file_type: doc.fileType,
|
||||
chunk_count: chunks.length,
|
||||
indexed: false,
|
||||
indexed_at: null,
|
||||
created_at: iso(doc.addedAt),
|
||||
},
|
||||
])
|
||||
await ctx.remote.deleteChildren('knowledge_chunks', 'document_id', doc.id)
|
||||
for (let i = 0; i < chunks.length; i += CHUNK_BATCH) {
|
||||
await ctx.remote.insert(
|
||||
'knowledge_chunks',
|
||||
chunks.slice(i, i + CHUNK_BATCH).map((content, offset) => ({
|
||||
document_id: doc.id,
|
||||
chunk_index: i + offset,
|
||||
content,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
return toSyncRemoteError(err)
|
||||
}
|
||||
// 모바일·웹 검색용 서버 임베딩. 공급자 키가 없거나 한도에 걸려도 원문은 이미 올라갔다 —
|
||||
// 모바일에서 "색인" 을 다시 누를 수 있으므로 여기서 실패로 되돌리지 않는다.
|
||||
try {
|
||||
await ctx.remote.invokeFunction('embed-chunks', { document_id: doc.id })
|
||||
} catch {
|
||||
// 의도적 무시(위 주석)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const knowledgeAdapter: SyncAdapter = {
|
||||
entity: 'knowledge_documents',
|
||||
pull: { columns: 'id,title,file_name,file_type,chunk_count,created_at,updated_at' },
|
||||
listLocalVersions() {
|
||||
return getDatabase()
|
||||
.select({ id: ragDocuments.id, addedAt: ragDocuments.addedAt })
|
||||
.from(ragDocuments)
|
||||
.all()
|
||||
.filter((r) => isUuid(r.id))
|
||||
.map((r) => ({ id: r.id, updatedAt: r.addedAt }))
|
||||
},
|
||||
async push(ctx, ids) {
|
||||
const outcomes: PushOutcome[] = []
|
||||
for (const id of ids) outcomes.push({ id, error: await pushKnowledgeDocument(ctx, id) })
|
||||
return outcomes
|
||||
},
|
||||
pushDeletes(ctx, ids) {
|
||||
return deleteRemote(ctx, 'knowledge_documents', ids)
|
||||
},
|
||||
async applyRemote(row, ctx) {
|
||||
const id = row.id
|
||||
if (!isUuid(id)) return false
|
||||
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
if (exists) return false
|
||||
const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index')
|
||||
const chunks = chunkRows.map((c) => str(c.content)).filter((c): c is string => c !== null)
|
||||
return getRAGService().applyRemoteDocument({
|
||||
id,
|
||||
fileName: str(row.file_name) ?? str(row.title) ?? 'document',
|
||||
fileType: oneOf(row.file_type, KNOWLEDGE_TYPES) ?? 'txt',
|
||||
chunks,
|
||||
addedAt: ms(row.created_at, Date.now()),
|
||||
})
|
||||
},
|
||||
deleteLocal(id) {
|
||||
return getRAGService().removeRemote(id)
|
||||
},
|
||||
}
|
||||
|
||||
/** push 순서: 부모(이력·회의)가 자식(태그·메모·문서)보다 먼저. 삭제는 역순. */
|
||||
export const TABLE_ADAPTERS: readonly SyncAdapter[] = [
|
||||
historyAdapter,
|
||||
|
|
@ -850,6 +943,7 @@ export const TABLE_ADAPTERS: readonly SyncAdapter[] = [
|
|||
meetingDocumentsAdapter,
|
||||
customInstructionsAdapter,
|
||||
userTemplatesAdapter,
|
||||
knowledgeAdapter,
|
||||
]
|
||||
|
||||
export function adapterFor(entity: SyncEntity): SyncAdapter | null {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue