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
|
|
@ -17,6 +17,9 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
private clock = Date.parse('2026-09-27T00:00:00.000Z')
|
||||
private tombstoneSeq = 0
|
||||
networkDown = false
|
||||
/** 업로드된 저장소 객체: `${bucket}/${key}` → 바이트 */
|
||||
readonly objects = new Map<string, Uint8Array>()
|
||||
readonly invoked: string[] = []
|
||||
/** 특정 행 upsert를 거부시키는 훅 (재시도 불가 오류 시뮬레이션) */
|
||||
rejectRow: ((table: string, row: RemoteRow) => SyncRemoteError | null) | null = null
|
||||
readonly calls: string[] = []
|
||||
|
|
@ -106,7 +109,71 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async upsert(table: string, rows: RemoteRow[]): Promise<void> {
|
||||
async selectChildren(
|
||||
table: string,
|
||||
parentColumn: string,
|
||||
parentId: string,
|
||||
_columns: string,
|
||||
orderBy: string
|
||||
): Promise<RemoteRow[]> {
|
||||
this.guard()
|
||||
return this.rows(table)
|
||||
.filter((r) => r[parentColumn] === parentId)
|
||||
.sort((a, b) => Number(a[orderBy]) - Number(b[orderBy]))
|
||||
.map((r) => ({ ...r }))
|
||||
}
|
||||
|
||||
async insert(table: string, rows: RemoteRow[]): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`insert:${table}:${rows.length}`)
|
||||
for (const row of rows) {
|
||||
if (table === 'user_settings' && this.rows(table).some((r) => r.user_id === row.user_id)) {
|
||||
throw new SyncRemoteError('duplicate key value', '23505', false)
|
||||
}
|
||||
const at = this.now()
|
||||
this.rows(table).push({ id: crypto.randomUUID(), revision: 1, created_at: at, updated_at: at, ...row })
|
||||
}
|
||||
}
|
||||
|
||||
async updateMatching(
|
||||
table: string,
|
||||
userId: string,
|
||||
match: Record<string, string | number>,
|
||||
patch: RemoteRow
|
||||
): Promise<number> {
|
||||
this.guard()
|
||||
this.calls.push(`update:${table}`)
|
||||
const rows = this.rows(table).filter(
|
||||
(r) => r.user_id === userId && Object.entries(match).every(([k, v]) => r[k] === v)
|
||||
)
|
||||
for (const row of rows) Object.assign(row, patch, { updated_at: this.now() })
|
||||
return rows.length
|
||||
}
|
||||
|
||||
async deleteChildren(table: string, parentColumn: string, parentId: string): Promise<void> {
|
||||
this.guard()
|
||||
const rows = this.rows(table)
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1)
|
||||
}
|
||||
|
||||
async invokeFunction(name: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
this.guard()
|
||||
this.invoked.push(`${name}:${String(body.document_id ?? '')}`)
|
||||
return { indexed: true }
|
||||
}
|
||||
|
||||
async uploadObject(bucket: string, key: string, bytes: Uint8Array): Promise<void> {
|
||||
this.guard()
|
||||
if (!key.startsWith(`${this.userId}/`)) throw new SyncRemoteError('row-level security', '42501', false)
|
||||
this.objects.set(`${bucket}/${key}`, bytes)
|
||||
}
|
||||
|
||||
async removeObjects(bucket: string, keys: string[]): Promise<void> {
|
||||
this.guard()
|
||||
for (const key of keys) this.objects.delete(`${bucket}/${key}`)
|
||||
}
|
||||
|
||||
async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise<void> {
|
||||
this.guard()
|
||||
this.calls.push(`upsert:${table}:${rows.length}`)
|
||||
// 배치는 원자적이다: 하나라도 거부되면 전체 실패
|
||||
|
|
@ -126,15 +193,19 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
throw new SyncRemoteError('violates foreign key', '23503', true)
|
||||
}
|
||||
}
|
||||
const conflictColumns = onConflict.split(',')
|
||||
for (const row of rows) {
|
||||
const existing = this.find(table, String(row.id))
|
||||
const existing =
|
||||
onConflict === 'id'
|
||||
? this.find(table, String(row.id))
|
||||
: this.rows(table).find((r) => conflictColumns.every((c) => r[c] === row[c]))
|
||||
if (existing) {
|
||||
const changed = Object.keys(row).some((k) => k !== 'updated_at' && existing[k] !== row[k])
|
||||
Object.assign(existing, row, { updated_at: this.now() })
|
||||
if (table === 'history' && changed) existing.revision = Number(existing.revision ?? 1) + 1
|
||||
} else {
|
||||
const at = this.now()
|
||||
this.rows(table).push({ revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at })
|
||||
this.rows(table).push({ id: crypto.randomUUID(), revision: 1, created_at: at, ...row, updated_at: row.updated_at ?? at })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +239,10 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
if (table === 'history') {
|
||||
for (const t of this.rows('memo_tags').filter((r) => r.history_id === id)) this.removeRow('memo_tags', String(t.id))
|
||||
}
|
||||
if (table === 'knowledge_documents') {
|
||||
const chunks = this.rows('knowledge_chunks')
|
||||
for (let i = chunks.length - 1; i >= 0; i--) if (chunks[i].document_id === id) chunks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
async rpc(name: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
|
|
@ -213,6 +288,16 @@ export class FakeSyncRemote implements SyncRemote {
|
|||
}
|
||||
return this.mobileInsert('user_templates', { id, revision: 1, ...fields })
|
||||
}
|
||||
if (name === 'set_active_custom_instruction') {
|
||||
const instructionId = params.instruction_id
|
||||
if (instructionId !== null && !this.find('custom_instructions', String(instructionId))) {
|
||||
throw new SyncRemoteError('instruction_not_found', 'P0002', true)
|
||||
}
|
||||
const row = this.rows('user_settings').find((r) => r.user_id === this.userId)
|
||||
if (row) Object.assign(row, { active_instruction_id: instructionId, revision: Number(row.revision) + 1, updated_at: this.now() })
|
||||
else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 })
|
||||
return row
|
||||
}
|
||||
if (name === 'sync_delete_user_template_v1') {
|
||||
const id = String(params.p_id)
|
||||
if (!this.find('user_templates', id)) return false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue