diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c61fb2..43767c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Additional Whisper model management UI - Plugin system for custom pipelines +## [1.9.0] - 2026-09-27 + +> Published from an annotated tag. Installer and update metadata are served by the +> canonical Forgejo feed; no binaries are committed to this repository. + +### Added +- **Preset commands you edit on the desktop are used on the phone.** Changing the + prompt of Translate, Summarize, Professional rewrite or Explain code updates the same + command on your phone; restoring the default restores it there too. + +### Fixed +- **Meeting transcripts edited on the desktop now show on the phone.** Edits, + auto-polish and speaker labels were kept on the desktop only because the phone + shows a meeting from its transcript segments; those are now updated with every + change. + ## [1.8.0] - 2026-09-27 > Published from an annotated tag. Installer and update metadata are served by the diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index 9ae4aa7..7fbe54b 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.8.0" + "version": "1.9.0" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 2cead7d..24f9a7a 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index 39d21c0..8b477cc 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.8.0 + 1.9.0 enable enable diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 32952ab..6264ed5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.8.0", + "version": "1.9.0", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/services/CustomInstructionService.ts b/apps/desktop/src/main/services/CustomInstructionService.ts index 37f49e9..72a6dd3 100644 --- a/apps/desktop/src/main/services/CustomInstructionService.ts +++ b/apps/desktop/src/main/services/CustomInstructionService.ts @@ -159,10 +159,11 @@ class CustomInstructionService { const existing = instructions[index] - // 프리셋은 프롬프트만 수정 가능 + // 프리셋은 프롬프트만 수정 가능 — 폰과 짝이 있는 프리셋은 서버 프리셋 행으로도 올린다 if (existing.isBuiltin) { if (data.prompt !== undefined) { instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() } + getCloudSyncService().pushOne('builtin_instructions', id) } } else { instructions[index] = { ...existing, ...data, updatedAt: Date.now() } @@ -203,6 +204,15 @@ class CustomInstructionService { saveInstructions() } + /** 동기화: 다른 기기에서 바꾼 프리셋 프롬프트를 반영한다(outbox에 넣지 않는다). 바꿨으면 true */ + applyRemoteBuiltinPrompt(id: string, prompt: string): boolean { + const index = instructions.findIndex((i) => i.id === id) + if (index === -1 || !instructions[index].isBuiltin || instructions[index].prompt === prompt) return false + instructions[index] = { ...instructions[index], prompt, updatedAt: Date.now() } + saveInstructions() + return true + } + /** 동기화: 다른 기기에서 지운 사용자 명령을 지운다. 프리셋은 건드리지 않는다. */ removeRemote(id: string): boolean { const index = instructions.findIndex((i) => i.id === id) @@ -252,6 +262,7 @@ class CustomInstructionService { instructions = [...builtins, ...userInstructions] saveInstructions() + for (const builtin of builtins) getCloudSyncService().pushOne('builtin_instructions', builtin.id) logger.info('Builtin instructions reset') } @@ -260,6 +271,11 @@ class CustomInstructionService { } } +/** 데스크톱 프리셋의 기본 프롬프트(되돌리기·수정 여부 판정용). 프리셋이 아니면 null */ +export function getBuiltinInstructionDefaultPrompt(id: string): string | null { + return BUILTIN_INSTRUCTIONS.find((b) => b.id === id)?.prompt ?? null +} + let instance: CustomInstructionService | null = null export function getCustomInstructionService(): CustomInstructionService { diff --git a/apps/desktop/src/main/services/sync/SyncEngine.ts b/apps/desktop/src/main/services/sync/SyncEngine.ts index 8dd3cb1..48b2471 100644 --- a/apps/desktop/src/main/services/sync/SyncEngine.ts +++ b/apps/desktop/src/main/services/sync/SyncEngine.ts @@ -20,6 +20,12 @@ import { } from './memo-tag-sync' import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners, pushAudio } from './audio-sync' import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings, pushSettings } from './settings-sync' +import { + applyRemoteBuiltins, + editedBuiltinsDifferingFrom, + fetchRemoteBuiltins, + pushBuiltinPrompts, +} from './builtin-instruction-sync' import { completeEntry, dropEntry, @@ -223,6 +229,11 @@ export class SyncEngine extends EventEmitter { enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', now) queued++ } + // 데스크톱에서 고친 프리셋 프롬프트가 서버와 다르면 올린다(폰은 프리셋을 고칠 수 없다). + for (const id of editedBuiltinsDifferingFrom(await fetchRemoteBuiltins(ctx))) { + enqueueChange('builtin_instructions', id, 'upsert', now) + queued++ + } if (isAudioSyncEnabled()) { for (const owner of ['history', 'meeting'] as const) { const local = listLocalAudioOwners(owner) @@ -290,6 +301,7 @@ export class SyncEngine extends EventEmitter { 'meeting_memos', 'meeting_documents', 'custom_instructions', + 'builtin_instructions', 'user_settings', 'user_templates', 'knowledge_documents', @@ -326,6 +338,8 @@ export class SyncEngine extends EventEmitter { return pushMemoTagAdds(ctx, ids) case 'user_settings': return [{ id: SETTINGS_ROW_ID, error: await pushSettings(ctx) }] + case 'builtin_instructions': + return pushBuiltinPrompts(ctx, ids) case 'history_audio': return pushAudio(ctx, 'history', ids) case 'meeting_audio': @@ -416,6 +430,19 @@ export class SyncEngine extends EventEmitter { logger.warn(`Pull user_settings failed: ${message}`) } + try { + const builtins = await fetchRemoteBuiltins({ remote: this.remote, userId: this.userId }) + const applied = applyRemoteBuiltins(builtins, new Set(pendingOps('builtin_instructions').keys())) + if (applied > 0) { + changed.add('custom_instructions') + result.pulled += applied + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + result.errors.push(`builtin_instructions: ${message}`) + logger.warn(`Pull builtin_instructions failed: ${message}`) + } + result.changed = [...changed] if (result.errors.length === 0) setSyncState('lastPullAt', String(this.now())) } diff --git a/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts b/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts new file mode 100644 index 0000000..743c5fd --- /dev/null +++ b/apps/desktop/src/main/services/sync/builtin-instruction-sync.ts @@ -0,0 +1,118 @@ +// src/main/services/sync/builtin-instruction-sync.ts +// 데스크톱 프리셋 명령의 프롬프트 수정을 서버 프리셋 행(builtin_key)에 맞춘다. +// 폰은 명령을 서버 행의 프롬프트로 실행하므로, 데스크톱에서 고친 프롬프트가 폰에서도 쓰인다. +// 서버 행은 RLS상 직접 못 고치므로 sync_set_builtin_instruction_prompt_v1 로만 바꾼다(null = 기본값 복원). + +import { getBuiltinInstructionDefaultPrompt, getCustomInstructionService } from '../CustomInstructionService' +import type { PushContext } from './sync-adapters' +import { toSyncRemoteError, type PushOutcome } from './sync-types' + +/** 데스크톱 프리셋 id → 서버 builtin_key. '자유 프롬프트'는 실행 때 입력받는 명령이라 폰에 대응이 없다. */ +export const DESKTOP_TO_SERVER_BUILTIN: Readonly> = { + 'builtin-translate': 'translate_en', + 'builtin-summarize': 'summarize', + 'builtin-formal': 'formal', + 'builtin-explain-code': 'explain_code', +} + +const SERVER_TO_DESKTOP_BUILTIN: Readonly> = Object.fromEntries( + Object.entries(DESKTOP_TO_SERVER_BUILTIN).map(([desktop, server]) => [server, desktop]) +) + +export function isSyncedBuiltin(id: string): boolean { + return id in DESKTOP_TO_SERVER_BUILTIN +} + +/** + * 폰은 `{{text}}` 외의 자리표시자를 치환하지 않는다. 서버 번역 프리셋은 영어 고정(translate_en)이고 + * 데스크톱 번역도 대상 언어 설정이 없어 영어로만 번역하므로 `{{targetLanguage}}` 를 English 로 바꿔 올린다. + */ +export function toServerPrompt(desktopId: string, prompt: string): string { + return desktopId === 'builtin-translate' ? prompt.replace(/\{\{targetLanguage\}\}/g, 'English') : prompt +} + +function isEdited(desktopId: string, prompt: string): boolean { + const defaultPrompt = getBuiltinInstructionDefaultPrompt(desktopId) + return defaultPrompt !== null && prompt.trim() !== defaultPrompt.trim() +} + +export async function pushBuiltinPrompts(ctx: PushContext, ids: string[]): Promise { + const service = getCustomInstructionService() + const outcomes: PushOutcome[] = [] + for (const id of ids) { + const key = DESKTOP_TO_SERVER_BUILTIN[id] + const local = service.getById(id) + if (!key || !local) { + outcomes.push({ id, error: null }) + continue + } + try { + await ctx.remote.rpc('sync_set_builtin_instruction_prompt_v1', { + p_builtin_key: key, + p_prompt: isEdited(id, local.prompt) ? toServerPrompt(id, local.prompt) : null, + }) + outcomes.push({ id, error: null }) + } catch (err) { + outcomes.push({ id, error: toSyncRemoteError(err) }) + } + } + return outcomes +} + +interface RemoteBuiltin { + key: string + prompt: string + isDefault: boolean +} + +export async function fetchRemoteBuiltins(ctx: PushContext): Promise { + const data = await ctx.remote.rpc('sync_list_builtin_instructions_v1', {}) + if (!Array.isArray(data)) return [] + const rows: RemoteBuiltin[] = [] + for (const item of data) { + if (typeof item !== 'object' || item === null) continue + const row = item as Record + if (typeof row.builtin_key === 'string' && typeof row.prompt === 'string') { + rows.push({ key: row.builtin_key, prompt: row.prompt, isDefault: row.is_default === true }) + } + } + return rows +} + +/** 데스크톱에서 고쳤지만 서버와 다른 프리셋 (최초 대조용) */ +export function editedBuiltinsDifferingFrom(remote: RemoteBuiltin[]): string[] { + const service = getCustomInstructionService() + const byKey = new Map(remote.map((r) => [r.key, r])) + return Object.entries(DESKTOP_TO_SERVER_BUILTIN) + .filter(([id, key]) => { + const local = service.getById(id) + if (!local || !isEdited(id, local.prompt)) return false + const server = byKey.get(key) + return !server || server.prompt !== toServerPrompt(id, local.prompt) + }) + .map(([id]) => id) +} + +/** + * 서버 프리셋 프롬프트를 로컬에 반영한다. 기본값이면 데스크톱 기본값으로, 아니면 서버 문구로. + * 올린 적이 있는 같은 문구(번역의 English 치환 포함)는 그대로 둔다. 바꾼 개수를 돌려준다. + */ +export function applyRemoteBuiltins(remote: RemoteBuiltin[], pending: ReadonlySet): number { + const service = getCustomInstructionService() + let changed = 0 + for (const row of remote) { + const id = SERVER_TO_DESKTOP_BUILTIN[row.key] + if (!id || pending.has(id)) continue + const local = service.getById(id) + if (!local) continue + let next: string | null = null + if (row.isDefault) { + const defaultPrompt = getBuiltinInstructionDefaultPrompt(id) + if (defaultPrompt !== null && isEdited(id, local.prompt)) next = defaultPrompt + } else if (toServerPrompt(id, local.prompt) !== row.prompt) { + next = row.prompt + } + if (next !== null && service.applyRemoteBuiltinPrompt(id, next)) changed++ + } + return changed +} diff --git a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts index fce6e72..faa821e 100644 --- a/apps/desktop/src/main/services/sync/supabase-sync-remote.ts +++ b/apps/desktop/src/main/services/sync/supabase-sync-remote.ts @@ -122,6 +122,17 @@ export class SupabaseSyncRemote implements SyncRemote { if (error) throw toSyncRemoteError(error) } + async deleteChildrenFrom( + table: string, + parentColumn: string, + parentId: string, + indexColumn: string, + fromIndex: number + ): Promise { + const { error } = await this.client.from(table).delete().eq(parentColumn, parentId).gte(indexColumn, fromIndex) + if (error) throw toSyncRemoteError(error) + } + async invokeFunction(name: string, body: Record): Promise { const { data, error } = await this.client.functions.invoke(name, { body }) if (error) throw toSyncRemoteError(error) diff --git a/apps/desktop/src/main/services/sync/sync-adapters.ts b/apps/desktop/src/main/services/sync/sync-adapters.ts index 57099c2..0ec6a6c 100644 --- a/apps/desktop/src/main/services/sync/sync-adapters.ts +++ b/apps/desktop/src/main/services/sync/sync-adapters.ts @@ -18,6 +18,7 @@ import { getDictationTemplateService } from '../DictationTemplateService' import { getMeetingDocTemplateService } from '../MeetingDocTemplateService' import { getRAGService } from '../RAGService' import { purgeRemoteAudio } from './audio-sync' +import { pushMeetingSegments } from './transcript-sync' import { dropEntry } from './sync-outbox' import { SyncRemoteError, @@ -441,6 +442,17 @@ const meetingsAdapter: SyncAdapter = { 'meetings', rows.map((r) => ({ id: r.id, row: meetingToRemote(r, ctx.userId) })) ) + // 회의 행이 올라간 뒤 전사 구간을 맞춘다 — 폰은 구간이 있으면 구간을 그린다. + const byId = new Map(rows.map((r) => [r.id, r])) + for (const outcome of outcomes) { + const row = byId.get(outcome.id) + if (outcome.error !== null || !row) continue + try { + await pushMeetingSegments(ctx, row) + } catch (err) { + outcome.error = toSyncRemoteError(err) + } + } return [...outcomes, ...missingAsDone(ids, found)] }, async pushDeletes(ctx, ids) { diff --git a/apps/desktop/src/main/services/sync/sync-types.ts b/apps/desktop/src/main/services/sync/sync-types.ts index 5b24fa6..54f643e 100644 --- a/apps/desktop/src/main/services/sync/sync-types.ts +++ b/apps/desktop/src/main/services/sync/sync-types.ts @@ -17,6 +17,8 @@ export const SYNC_ENTITIES = [ /** 녹음 파일 업로드(row_id = history id / meeting id) */ 'history_audio', 'meeting_audio', + /** 데스크톱 프리셋 명령 프롬프트(row_id = 데스크톱 프리셋 id) → 서버 프리셋 행 */ + 'builtin_instructions', ] as const export type SyncEntity = (typeof SYNC_ENTITIES)[number] @@ -68,6 +70,8 @@ export interface SyncRemote { updateMatching(table: string, userId: string, match: Record, patch: RemoteRow): Promise deleteByIds(table: string, userId: string, ids: string[]): Promise deleteChildren(table: string, parentColumn: string, parentId: string): Promise + /** 부모의 자식 중 indexColumn >= fromIndex 인 행을 지운다 (줄어든 구간 정리) */ + deleteChildrenFrom(table: string, parentColumn: string, parentId: string, indexColumn: string, fromIndex: number): Promise rpc(name: string, params: Record): Promise invokeFunction(name: string, body: Record): Promise uploadObject(bucket: string, key: string, bytes: Uint8Array, contentType: string): Promise diff --git a/apps/desktop/src/main/services/sync/transcript-sync.ts b/apps/desktop/src/main/services/sync/transcript-sync.ts new file mode 100644 index 0000000..6aec8d5 --- /dev/null +++ b/apps/desktop/src/main/services/sync/transcript-sync.ts @@ -0,0 +1,44 @@ +// src/main/services/sync/transcript-sync.ts +// 데스크톱 회의 전사를 Supabase `transcripts` 구간으로 맞춘다. +// 모바일은 구간이 하나라도 있으면 edited_transcript 대신 구간을 그리므로, 데스크톱이 전사를 바꾸면 +// (녹음 완료·직접 수정·자동 다듬기·화자 구분) 구간도 같이 바꿔야 폰에 보인다. + +import { parseTranscriptLines } from '@d3ro/core/meeting-transcript' +import type { meetingSessions } from '../../db/schema' +import type { PushContext } from './sync-adapters' +import type { RemoteRow } from './sync-types' + +const SEGMENT_BATCH = 500 + +/** 로컬 회의 행 → 서버 구간 행. 전사가 없으면(녹음 중 등) null — 서버 구간을 건드리지 않는다. */ +export function meetingSegmentsToRemote(row: typeof meetingSessions.$inferSelect): RemoteRow[] | null { + const edited = row.editedTranscript !== null && row.editedTranscript.trim() !== '' + const lines = parseTranscriptLines(edited ? row.editedTranscript : row.rawTranscript) + if (lines.length === 0) return null + return lines.map((line, index) => { + const next = lines[index + 1] + const duration = next ? next.timestampMs - line.timestampMs : null + return { + meeting_id: row.id, + segment_index: index, + timestamp_ms: line.timestampMs, + duration_ms: duration !== null && duration > 0 ? duration : null, + text: line.text, + speaker: line.speaker, + edited, + } + }) +} + +/** 구간을 upsert 하고, 줄 수가 줄었으면 뒤쪽 구간을 지운다(한 번에 비우지 않아 폰 화면이 깜빡이지 않는다). */ +export async function pushMeetingSegments( + ctx: PushContext, + row: typeof meetingSessions.$inferSelect +): Promise { + const segments = meetingSegmentsToRemote(row) + if (!segments) return + for (let i = 0; i < segments.length; i += SEGMENT_BATCH) { + await ctx.remote.upsert('transcripts', segments.slice(i, i + SEGMENT_BATCH), 'meeting_id,segment_index') + } + await ctx.remote.deleteChildrenFrom('transcripts', 'meeting_id', row.id, 'segment_index', segments.length) +} diff --git a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx index 56bc073..9e08f7f 100644 --- a/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/apps/desktop/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -2,6 +2,7 @@ // Modern AI Meeting Studio: Dual-Canvas Split View (Transcript + AI Minutes & Granola Notepad) import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { parseTranscriptLines } from '@d3ro/core/meeting-transcript' import { Box, Tabs, @@ -54,33 +55,14 @@ function parseSegments( rawTranscript: string | null, editedTranscript: string | null, ): Array<{ id: string; timestamp: number; text: string; edited: boolean; speaker?: string }> { - const source = editedTranscript || rawTranscript || '' - const regex = /^\[(\d{2}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.+)$/ - return source - .split('\n') - .filter((line) => line.trim()) - .map((line, idx) => { - const m = regex.exec(line.trim()) - if (m) { - const min = parseInt(m[1], 10) - const sec = parseInt(m[2], 10) - const speaker = m[3] || undefined - const text = m[4] - return { - id: `seg-${idx}`, - timestamp: (min * 60 + sec) * 1000, - text, - edited: false, - speaker, - } - } - return { - id: `seg-${idx}`, - timestamp: idx * 4000, - text: line.trim(), - edited: false, - } - }) + // 줄 ↔ 구간 변환은 core 정본을 쓴다 — 서버 transcripts 구간도 같은 규칙으로 만들어진다. + return parseTranscriptLines(editedTranscript || rawTranscript).map((line, idx) => ({ + id: `seg-${idx}`, + timestamp: line.timestampMs, + text: line.text, + edited: false, + speaker: line.speaker ?? undefined, + })) } // Extract Action items from documents diff --git a/apps/desktop/tests/helpers/fakeSyncRemote.ts b/apps/desktop/tests/helpers/fakeSyncRemote.ts index 774f64e..8b73939 100644 --- a/apps/desktop/tests/helpers/fakeSyncRemote.ts +++ b/apps/desktop/tests/helpers/fakeSyncRemote.ts @@ -156,6 +156,36 @@ export class FakeSyncRemote implements SyncRemote { for (let i = rows.length - 1; i >= 0; i--) if (rows[i][parentColumn] === parentId) rows.splice(i, 1) } + async deleteChildrenFrom( + table: string, + parentColumn: string, + parentId: string, + indexColumn: string, + fromIndex: number + ): Promise { + this.guard() + const rows = this.rows(table) + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i][parentColumn] === parentId && Number(rows[i][indexColumn]) >= fromIndex) rows.splice(i, 1) + } + } + + /** 서버 프리셋 기본 프롬프트 (마이그레이션 0036 과 같은 문구) */ + static readonly BUILTIN_DEFAULTS: Record = { + translate_en: 'Translate the following text into natural English. Return only the translation.', + summarize: 'Summarize the following text in no more than three concise lines.', + formal: 'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.', + explain_code: 'Explain the following code in Korean, including its responsibilities and important caveats.', + } + + private ensureBuiltins(): void { + for (const [key, prompt] of Object.entries(FakeSyncRemote.BUILTIN_DEFAULTS)) { + if (!this.rows('custom_instructions').some((r) => r.builtin_key === key)) { + this.mobileInsert('custom_instructions', { id: crypto.randomUUID(), builtin_key: key, name: key, prompt, icon: 'x', sort_order: 10 }) + } + } + } + async invokeFunction(name: string, body: Record): Promise { this.guard() this.invoked.push(`${name}:${String(body.document_id ?? '')}`) @@ -298,6 +328,26 @@ export class FakeSyncRemote implements SyncRemote { else this.mobileInsert('user_settings', { active_instruction_id: instructionId, revision: 1 }) return row } + if (name === 'sync_set_builtin_instruction_prompt_v1') { + this.ensureBuiltins() + const key = String(params.p_builtin_key) + const fallback = FakeSyncRemote.BUILTIN_DEFAULTS[key] + if (!fallback) throw new SyncRemoteError('invalid_builtin_key', '22023', false) + const next = typeof params.p_prompt === 'string' && params.p_prompt.trim() ? params.p_prompt.trim() : fallback + const row = this.rows('custom_instructions').find((r) => r.builtin_key === key)! + if (row.prompt !== next) Object.assign(row, { prompt: next, revision: Number(row.revision) + 1, updated_at: this.now() }) + return row + } + if (name === 'sync_list_builtin_instructions_v1') { + return this.rows('custom_instructions') + .filter((r) => typeof r.builtin_key === 'string') + .map((r) => ({ + builtin_key: r.builtin_key, + prompt: r.prompt, + is_default: r.prompt === FakeSyncRemote.BUILTIN_DEFAULTS[String(r.builtin_key)], + updated_at: r.updated_at, + })) + } if (name === 'sync_delete_user_template_v1') { const id = String(params.p_id) if (!this.find('user_templates', id)) return false diff --git a/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts index 4d519a9..4df760f 100644 --- a/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts +++ b/apps/desktop/tests/integration/cross-device-sync.supabase.test.ts @@ -332,6 +332,40 @@ describe.skipIf(!enabled)('cross-device sync against local Supabase', () => { vi.restoreAllMocks() }, 60_000) + it('데스크톱 전사가 폰이 그리는 구간으로 올라가고, 고친 프리셋 프롬프트가 폰 명령에 쓰인다', async () => { + const db = testDb.db + const meetingId = crypto.randomUUID() + const now = Date.now() + db.insert(meetingSessions).values({ + id: meetingId, + title: 'segments', + status: 'completed', + startedAt: now, + rawTranscript: '[00:00] one\n[00:04] two\n[00:09] three', + createdAt: now, + updatedAt: now, + }).run() + enqueueChange('meetings', meetingId, 'upsert') + getCustomInstructionService().update('builtin-summarize', { prompt: '두 줄로 요약' }) + enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert') + const first = await engine.flush() + expect(first.errors).toEqual([]) + const segments = must(await mobile.from('transcripts').select('segment_index,text,edited').eq('meeting_id', meetingId).order('segment_index')) + expect(segments.map((s) => s.text)).toEqual(['one', 'two', 'three']) + + db.update(meetingSessions).set({ editedTranscript: '[00:00] [화자 1] one fixed', updatedAt: Date.now() }).where(eq(meetingSessions.id, meetingId)).run() + enqueueChange('meetings', meetingId, 'upsert') + expect((await engine.flush()).errors).toEqual([]) + const edited = must(await mobile.from('transcripts').select('text,speaker,edited').eq('meeting_id', meetingId)) + expect(edited).toEqual([{ text: 'one fixed', speaker: '화자 1', edited: true }]) + + const summarize = must(await mobile.from('custom_instructions').select('prompt').eq('user_id', userId).eq('builtin_key', 'summarize').single()) + expect(summarize.prompt).toBe('두 줄로 요약') + // 폰은 여전히 프리셋 행을 직접 고칠 수 없다(RLS) + const blocked = await mobile.from('custom_instructions').update({ prompt: 'hack' }).eq('user_id', userId).eq('builtin_key', 'summarize').select('id') + expect(blocked.data ?? []).toEqual([]) + }) + it('데스크톱이 기기 목록에 나타나고, 모바일에서 해제하면 revoked가 된다', async () => { const info = { deviceName: 'IT-DESKTOP', appVersion: '9.9.9', osVersion: 'test' } const first = await checkInDesktopDevice(desktop, userId, 'signin', info, null) diff --git a/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts b/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts new file mode 100644 index 0000000..c3e28e6 --- /dev/null +++ b/apps/desktop/tests/main/sync/SyncTranscriptsPresets.test.ts @@ -0,0 +1,135 @@ +// 회의 전사 구간(transcripts)과 프리셋 명령 프롬프트 동기화. + +import { afterEach, beforeEach, describe, expect, it } 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 { meetingSessions } from '../../../src/main/db/schema' +import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService' +import { + getBuiltinInstructionDefaultPrompt, + getCustomInstructionService, + resetCustomInstructionServiceForTests, +} from '../../../src/main/services/CustomInstructionService' +import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService' +import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService' +import { SyncEngine } from '../../../src/main/services/sync/SyncEngine' +import { enqueueChange, outboxCounts } from '../../../src/main/services/sync/sync-outbox' + +const USER = '11111111-1111-4111-8111-111111111111' + +let testDb: ReturnType +let remote: FakeSyncRemote +let engine: SyncEngine + +beforeEach(() => { + testDb = createTestDb() + bindTestDatabase(testDb.db, USER) + initInMemoryConfig() + resetCustomInstructionServiceForTests() + resetDictationTemplateServiceForTests() + resetMeetingDocTemplateServiceForTests() + getCustomInstructionService().initialize() + remote = new FakeSyncRemote(USER) + engine = new SyncEngine({ remote, userId: USER }) +}) + +afterEach(() => { + engine.dispose() + unbindTestDatabase() + resetInMemoryConfig() + testDb.close() +}) + +function segmentsOf(meetingId: string): Array> { + return remote + .rows('transcripts') + .filter((r) => r.meeting_id === meetingId) + .sort((a, b) => Number(a.segment_index) - Number(b.segment_index)) +} + +describe('회의 전사 구간', () => { + it('데스크톱 전사를 구간으로 올리고, 수정·화자 구분을 반영하며 줄어든 구간은 지운다', async () => { + const id = crypto.randomUUID() + const now = Date.now() + testDb.db + .insert(meetingSessions) + .values({ + id, + title: 'desk', + status: 'completed', + startedAt: now, + rawTranscript: '[00:00] 시작합니다\n[00:07] 첫 안건\n[00:15] 마무리', + createdAt: now, + updatedAt: now, + }) + .run() + await engine.runFullSync() + expect(segmentsOf(id).map((s) => [s.segment_index, s.timestamp_ms, s.duration_ms, s.text, s.edited])).toEqual([ + [0, 0, 7000, '시작합니다', false], + [1, 7000, 8000, '첫 안건', false], + [2, 15000, null, '마무리', false], + ]) + + testDb.db + .update(meetingSessions) + .set({ editedTranscript: '[00:00] [화자 1] 시작합니다\n[00:15] [화자 2] 마무리', updatedAt: Date.now() }) + .where(eq(meetingSessions.id, id)) + .run() + enqueueChange('meetings', id, 'upsert') + await engine.flush() + expect(segmentsOf(id).map((s) => [s.segment_index, s.speaker, s.text, s.edited])).toEqual([ + [0, '화자 1', '시작합니다', true], + [1, '화자 2', '마무리', true], + ]) + }) + + it('전사가 없는 회의(녹음 중)는 서버 구간을 건드리지 않는다', async () => { + const id = crypto.randomUUID() + remote.mobileInsert('meetings', { id, title: 'phone', status: 'completed', started_at: remote.now() }) + remote.rows('transcripts').push({ id: crypto.randomUUID(), meeting_id: id, segment_index: 0, timestamp_ms: 0, text: 'phone text' }) + await engine.runFullSync() + testDb.db.update(meetingSessions).set({ title: 'renamed', rawTranscript: null, updatedAt: Date.now() }).where(eq(meetingSessions.id, id)).run() + enqueueChange('meetings', id, 'upsert') + await engine.flush() + expect(segmentsOf(id).map((s) => s.text)).toEqual(['phone text']) + }) +}) + +describe('프리셋 명령 프롬프트', () => { + it('데스크톱에서 고친 프리셋만 서버 프리셋 행으로 올리고, 번역 자리표시자는 English 로 바꾼다', async () => { + const service = getCustomInstructionService() + service.update('builtin-summarize', { prompt: '핵심만 두 줄로' }) + service.update('builtin-translate', { prompt: '{{targetLanguage}}로 자연스럽게 번역' }) + await engine.runFullSync() + const byKey = (key: string) => remote.rows('custom_instructions').find((r) => r.builtin_key === key) + expect(byKey('summarize')?.prompt).toBe('핵심만 두 줄로') + expect(byKey('translate_en')?.prompt).toBe('English로 자연스럽게 번역') + expect(byKey('formal')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.formal) + expect(outboxCounts().pending).toBe(0) + + // 다시 받아도 로컬 문구({{targetLanguage}})를 바꾸지 않는다 + await engine.pull() + expect(service.getById('builtin-translate')?.prompt).toBe('{{targetLanguage}}로 자연스럽게 번역') + + // 되돌리기 → 서버도 기본값 + service.update('builtin-summarize', { prompt: getBuiltinInstructionDefaultPrompt('builtin-summarize') ?? '' }) + enqueueChange('builtin_instructions', 'builtin-summarize', 'upsert') + await engine.flush() + expect(byKey('summarize')?.prompt).toBe(FakeSyncRemote.BUILTIN_DEFAULTS.summarize) + }) + + it('다른 데스크톱에서 고친 프리셋 프롬프트를 받고, 기본값 복원도 반영한다', async () => { + await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: '회사 공문 말투로' }) + const service = getCustomInstructionService() + await engine.runFullSync() + expect(service.getById('builtin-formal')?.prompt).toBe('회사 공문 말투로') + + await remote.rpc('sync_set_builtin_instruction_prompt_v1', { p_builtin_key: 'formal', p_prompt: null }) + await engine.pull() + expect(service.getById('builtin-formal')?.prompt).toBe(getBuiltinInstructionDefaultPrompt('builtin-formal')) + // '자유 프롬프트'는 폰에 대응이 없어 건드리지 않는다 + expect(service.getById('builtin-free-prompt')?.prompt).toBe('{{userPrompt}}') + }) +}) diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index bbda70d..42acba4 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.8.0" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1080000 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.9.0" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1090000 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index b7f555d..014dbad 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1080000; + CURRENT_PROJECT_VERSION = 1090000; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8.0; + MARKETING_VERSION = 1.9.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1080000; + CURRENT_PROJECT_VERSION = 1090000; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.8.0; + MARKETING_VERSION = 1.9.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1090000.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1090000.txt new file mode 100644 index 0000000..24e31e9 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1090000.txt @@ -0,0 +1 @@ +Meeting transcripts and speaker labels edited on the desktop now show on your phone, and preset command prompts changed on the desktop are used on the phone too. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1090000.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1090000.txt new file mode 100644 index 0000000..8bf876b --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1090000.txt @@ -0,0 +1 @@ +데스크톱에서 고친 회의 전사와 화자 구분이 휴대폰에도 보이고, 데스크톱에서 바꾼 기본 명령 프롬프트가 휴대폰 명령에도 적용됩니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index ec0d1b9..f0e0bd2 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index da841e2..692d347 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.8.0", + "version": "1.9.0", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index c4f31cd..ca0ed10 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 6208c3f..d829cf3 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.8.0 + v1.9.0 diff --git a/docs/map/00-index.md b/docs/map/00-index.md index 2a5eeb0..f13fdb3 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,14 +2,14 @@ > Status: ACTIVE > Last full audit: 2026-09-13 -> Last update: 2026-09-27 — **v1.7.0·v1.8.0 릴리스 + 기기 간 동기화 재작성 + 운영 DB 정렬.** 데스크톱↔모바일·웹 동기화를 outbox·서버시각 커서·삭제 기록(`sync_tombstones`)·실시간(`ws`) 엔진으로 다시 만들고, 이력·사전·회의·메모 태그·명령·템플릿·지식베이스·녹음·공통 설정·기기 등록까지 넓혔다(MEM-13, ACC-08/09, MEM-05). 운영 Supabase가 `0028`에 멈춰 있던 것을 발견해 `0029`–`0035`를 적용하고 함수 23개를 재배포했다(GAP-OPS-01 — 사용자가 자기 tier를 올릴 수 있던 권한 구멍 포함). 모바일 기기 목록·데이터 내보내기 결함도 고쳤다(GAP-SYNC-03/04). 무서명 로컬 게시 예외(GAP-REL-06) 유지. +> Last update: 2026-09-27 — **v1.7.0·v1.8.0·v1.9.0 릴리스 + 기기 간 동기화 재작성 + 운영 DB 정렬.** 데스크톱↔모바일·웹 동기화를 outbox·서버시각 커서·삭제 기록(`sync_tombstones`)·실시간(`ws`) 엔진으로 다시 만들고, 이력·사전·회의·메모 태그·명령·템플릿·지식베이스·녹음·공통 설정·기기 등록까지 넓혔다(MEM-13, ACC-08/09, MEM-05). 운영 Supabase가 `0028`에 멈춰 있던 것을 발견해 `0029`–`0035`를 적용하고 함수 23개를 재배포했다(GAP-OPS-01 — 사용자가 자기 tier를 올릴 수 있던 권한 구멍 포함). 모바일 기기 목록·데이터 내보내기 결함도 고쳤다(GAP-SYNC-03/04). 무서명 로컬 게시 예외(GAP-REL-06) 유지. > > Previous update: 2026-09-24 — **v1.6.0 릴리스.** 버전 SSOT를 1.6.0(android/iOS 1060000)으로 올렸다. 다음 문장 제안을 1개씩 순차 생성해 최대 12개·3개씩 페이지로 보여 주고(Ctrl+Alt+↑↓ 이동, ←→ 페이지, Enter 수락, Esc 닫기; 기존 기본 단축키는 revision 5에서 이관), 오버레이를 재설계했다(`INPUT-06`/`INPUT-07`). 개인 문구 코퍼스는 터미널·에디터·코딩 에이전트와 비문장·플레이스홀더를 학습하지 않고 기존 데이터도 같은 규칙으로 정리한다(`INPUT-04`/`INPUT-05`). 제안 단축키가 받아쓰기를 켜던 버그, 구버전 사이드카가 `/uia/focus` 없이 남던 버그(런타임 최소 버전 1.5.0), Ollama 러너 콘솔 창, 사이드카 이중 기동을 고쳤다. 실시간 자막 창은 끌어서 옮길 수 있고 첫 자막 전까지 준비 안내를 보인다. 무서명 로컬 게시 예외(GAP-REL-06)를 유지한다. > > Previous update: 2026-09-22 — **Gemma/Ollama 폭주 방어 계약을 기록했다.** 19:14:12 부팅 워밍업이 `keep_alive: 30m`으로 `gemma4:e4b`를 19:44:12까지 VRAM 3,226,342,521 bytes / context 4096으로 강제 상주시킨 것이 관측됐으며, 같은 시점 Windows GPU Engine PID 표본에는 Ollama의 활성 compute가 없었다. 즉 당시 상태는 무한 추론이 아니라 강제 residency였다. 19:11:17~19:11:58의 자동 제안 연속 생성은 기존 900 ms·12/min·5 candidates·128 tokens·한 글자 재생성 정책이 허용한 burst였다. 현재 구현 계약은 부팅 warmup 제거, `keep_alive: 2m`, 제안 600 ms / 최소 5 s 간격 / 기본 6회·hard max 12회 per min / 3 candidates / 64 tokens / 12-char growth / 8 s timeout, 그리고 요청별 취소·상한·종료 정리다. 독립 표적 검증은 6 test files / 69 tests passed / 0 failed, 변경 코드·테스트 ESLint와 `git diff --check`도 exit 0이다. Raw Ollama에서는 cold bounded 요청이 client hard timeout 15.044 s에 취소된 뒤 `/api/ps`가 비었고 `/api/version`은 80 ms에 회복했다. 명시 warmup은 HTTP 200 / 16.639 s, 후속 warm 요청은 body `options.num_predict=1`, `keep_alive='2m'`로 553 ms HTTP 200 / `done:true` / `eval_count:1` / `done_reason:length`였고 `/api/ps` expiry는 약 119.9 s였다. **19:48:59 +09:00에는 새 generate/unload/kill/retry 없이 충분히 지난 뒤 한 번의 `/api/ps`가 HTTP 200 / 45.8 ms / `{models:[]}`였고 `/api/version`은 HTTP 200 / 7.3 ms / `0.32.13`이었다.** 이는 raw API 수준의 expiry 뒤 unload 확인일 뿐 앱 재시작·GUI·실제 타이핑 증거는 아니므로 상태는 `[~]`로 유지한다 (`11` GAP-LLM-04, GAP-INPUT-06). > > Previous update: 2026-09-21 — **Input intelligence (입력 레메트리 + 다음 문장 제안) 신규**. 데스크톱에 입력 수집기(`InputTelemetryService`), UIA 컨텍스트 브리지(사이드카 `GET /uia/focus`), 제안 서비스(`SuggestionService`), 어렛 커 오버레이, 설정 > 입력 탭(동의·정책·주간 인사이트·개인 문구)을 추가했다. 카탈로그에 `INPUT-01`~`INPUT-08`(전부 데스크톱 `[~]` — 유닛 48건은 GREEN 이지만 **실앱 타이핑 검증 전**), 백로그에 GAP-INPUT-01~05 + GAP-LLM-03, §7 에 CONSTRAINT-INPUT-01(키 내용 미저장 — ActivityWatch 정책 채택)을 기록. 설계 근거는 조사 기반이다: 어렛은 `GetGUIThreadInfo` 가 아니라 UIA `TextPattern.GetSelection`(Chromium 은 `TextPattern2` 미구현), 타이핑 스트는 키코드 복원이 아니라 UIA 스냅샷 diff(한/일 IME 대응), 디바운스/토큰 한도는 인라인 컴플리션 실측값(Continue 350 / Tabby 250 / twinny 300 ms, 출력 64~256 토큰). 의존성: `koffi` 3.3.1(포그라운드 창 FFI), 사이드카 `uiautomation` 2.0.29 + `comtypes`. 데스크톱 유닛 총계 1409(+49), Electron ABI 실행에서 신규 실패 0건. 당시의 24.7초/4.9초 지연 설명과 `keep_alive: 30m`·부팅 워밍업 처방은 **현재 상태가 아닌 과거 가설/완화 이력**이며, 최신 운영 결론은 위 2026-09-22 항목과 `11` GAP-LLM-04를 따른다. 직전: LLM instruction-prompt fix (`9c2b4d4`): the custom-instruction path inserted the instruction's own wording instead of the processed result and had **never worked in any shipped release** (`v0.1.0-alpha`..`v1.4.0`, introduced `fea923d` 2026-04-05, not a regression). `llm-prompts.ts` is now the SSOT for prompt resolution and placeholder substitution, shared by `VoiceModeService` / `ChainService` / `LLM.PROCESS`. AI-04/05/06/07 are demoted to `[~]` on desktop — fixed with unit tests, but **not verified in a running app** and the four related `tests/red/*.usecase.test.ts` could not execute (`better-sqlite3` ABI). New: GAP-LLM-01 (no target-language setting), GAP-LLM-02 (this fix unverified); GAP-INFRA-06 amended (the ABI masks verification, not just dev-env switching cost); GAP-I18N-01 amended (`popup.error.default` missing in 10 locales). Earlier the same day: CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT — multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group), verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed. Still open: GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06, GAP-LLM-01/02, GAP-INPUT-01~05; `11` §7 holds accepted design constraints (things deliberately kept, not gaps) -> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.8.0` (`release/product-version.json`, released 2026-09-27) +> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.9.0` (`release/product-version.json`, released 2026-09-27) > Purpose: let any agent (or human) answer two questions in under a minute: > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) > 2. **How far is each feature developed?** (per surface, with file anchors and status) diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 1fe8d66..8a76dcd 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -191,7 +191,7 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md). | File | Purpose | |---|---| -| `release/product-version.json` | version `1.8.0`, `androidVersionCode`/`iosBuildNumber` `1080000`, releaseDate `2026-09-27`, desktop license keyId | +| `release/product-version.json` | version `1.9.0`, `androidVersionCode`/`iosBuildNumber` `1090000`, releaseDate `2026-09-27`, desktop license keyId | | `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs | | `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | diff --git a/docs/map/04-desktop-app.md b/docs/map/04-desktop-app.md index dfb1ea5..7b30264 100644 --- a/docs/map/04-desktop-app.md +++ b/docs/map/04-desktop-app.md @@ -97,7 +97,7 @@ The existing `input-telemetry-handlers` and `suggestion-handlers` IPC extensions | `ConfigService` | electron-store `AppConfig` (`configGet/Set`, defaults) | | `LicenseService` | Freemium tiers, quotas (`daily_usage`), activation, upgrade prompts | | `CloudSyncService` | Supabase auth + lifecycle: per-user DB switching, local-mode import on first sign-in, Realtime (`ws` transport) + 5-min heartbeat, device check-in, debounced flush/pull triggers. The sync itself lives in `services/sync/` | -| `services/sync/` | `SyncEngine` (backfill once per user DB → push outbox → pull by server-clock keyset cursor → tombstones → memo-tag reconcile), `sync-outbox` (`sync_outbox`/`sync_state` tables), `sync-adapters` (history, dictionary, meetings, meeting memos/documents, custom commands, templates), `memo-tag-sync`, `supabase-sync-remote`, `device-registration`, `realtime-transport`, `settings-sync` (mobile `user_settings` ↔ language/theme/defaultLLMAction/activeInstructionId), `audio-sync` (recording upload to the `audio` bucket, remote cleanup on delete), knowledge adapter (source-text chunks). Services record changes with `getCloudSyncService().pushOne/pushDelete(entity, id)`; pulled rows are applied without re-queuing | +| `services/sync/` | `SyncEngine` (backfill once per user DB → push outbox → pull by server-clock keyset cursor → tombstones → memo-tag reconcile), `sync-outbox` (`sync_outbox`/`sync_state` tables), `sync-adapters` (history, dictionary, meetings, meeting memos/documents, custom commands, templates), `memo-tag-sync`, `supabase-sync-remote`, `device-registration`, `realtime-transport`, `settings-sync` (mobile `user_settings` ↔ language/theme/defaultLLMAction/activeInstructionId), `audio-sync` (recording upload to the `audio` bucket, remote cleanup on delete), knowledge adapter (source-text chunks), `transcript-sync` (meeting segments from the desktop transcript), `builtin-instruction-sync` (preset prompt edits ↔ server preset rows). Services record changes with `getCloudSyncService().pushOne/pushDelete(entity, id)`; pulled rows are applied without re-queuing | | `CloudSTTService` | Thin cloud STT wrapper over `D3ROCloudDriver` | | `UpdateService` | electron-updater (canonical Forgejo feed, channels, mandatory/full-vs-delta policy, staged rollout, restart dialog) | | `AutoLaunchService` | OS login-item auto-start | diff --git a/docs/map/09-supabase-backend.md b/docs/map/09-supabase-backend.md index 96b6539..483bc76 100644 --- a/docs/map/09-supabase-backend.md +++ b/docs/map/09-supabase-backend.md @@ -8,7 +8,7 @@ ## 1. Supabase project (`server/supabase/`) - `config.toml` — project_id `d3ro-voice`; local ports API 55321 / DB 55322 / Studio 55323 / Inbucket 55324; DB major v17; storage 50 MiB; auth `site_url` localhost:5173, redirects include `https://d3ro.chanpaca.net` and `d3ro-voice://auth-callback`; external providers Google/GitHub/Apple; per-function `verify_jwt` settings; analytics off. -- `seed.sql`, `migrations/` (65; production at `20260927000035` — it lagged at `0028` until 2026-09-27, GAP-OPS-01), `functions/` (23, all redeployed 2026-09-27), `functions/_shared/`, `tests/`, `deno.json`. +- `seed.sql`, `migrations/` (66; production at `20260927000036` — it lagged at `0028` until 2026-09-27, GAP-OPS-01), `functions/` (23, all redeployed 2026-09-27), `functions/_shared/`, `tests/`, `deno.json`. > Note: root `supabase/` contains only empty scaffolding (`.branches/`, `snippets/`). The real project lives under `server/supabase/`. @@ -19,7 +19,7 @@ | Core schema + RLS + auth triggers | initial tables, profiles, triggers | | Storage buckets | audio, meeting documents, exports | | Teams | team invites, membership, roles, `team_activities` feed (`20260913000033`) | -| Cross-device sync | `20260927000035`: knowledge_documents tombstones/cursor/realtime + `pg_cron` job `prune-sync-tombstones` (daily 03:17 UTC). `20260927000034`: `sync_tombstones` (AFTER DELETE triggers on 8 synced tables, owner-read RLS, realtime, `prune_sync_tombstones_v1`), `history.revision` bump on content change, `meeting_memos.updated_at`, `(user_id, updated_at, id)` cursor indexes, client-keyed `sync_upsert/delete_user_template_v1`, realtime for `devices`/`memo_tags`/`custom_instructions`/`user_templates`, and `export_account_portability` projected back onto v1 archive keys | +| Cross-device sync | `20260927000036`: preset prompt RPCs `sync_set_builtin_instruction_prompt_v1` / `sync_list_builtin_instructions_v1` + default SSOT `builtin_instruction_default_prompt_v1`. `20260927000035`: knowledge_documents tombstones/cursor/realtime + `pg_cron` job `prune-sync-tombstones` (daily 03:17 UTC). `20260927000034`: `sync_tombstones` (AFTER DELETE triggers on 8 synced tables, owner-read RLS, realtime, `prune_sync_tombstones_v1`), `history.revision` bump on content change, `meeting_memos.updated_at`, `(user_id, updated_at, id)` cursor indexes, client-keyed `sync_upsert/delete_user_template_v1`, realtime for `devices`/`memo_tags`/`custom_instructions`/`user_templates`, and `export_account_portability` projected back onto v1 archive keys | | Knowledge / RAG | `knowledge_documents`, `knowledge_chunks`, pgvector | | Notifications / push | push tokens, durable outbox | | Billing | Payple, subscriptions, payment provider events/operations (`'stripe'` provider values kept only for historical rows) | diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 63c9d8a..db4ee73 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -41,7 +41,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | AI-02 | Cloud LLM (Claude/OpenAI) | [x] | [x] | [x] | [x] | Desktop `PremiumLLMService`; web/mobile via `llm-proxy`; .NET `LlmProxyService` | | AI-03 | Auto Polish (cleanup/filler removal) | [x] | [~] | [~] | [x] | Desktop built-in; web/mobile via commands. Desktop Auto Polish is the plain `refine` action (`llm-prompts.ts:14`), not a custom instruction, so it was **not** affected by the 2026-09-21 instruction-prompt fix (AI-05); regression cases now pin `refine`/`summarize`/`grammar`/`expand` (`VoiceModeService.test.ts:435`, `llm-prompts.test.ts:153`) | | AI-04 | Translate / summarize / rephrase | [~] | [x] | [x] | [x] | Built-in instructions. **Desktop has two paths and only one of them worked.** The plain-action path (Settings → `defaultLLMAction`, `SettingsModal.tsx:653`) reads `BASE_SYSTEM_PROMPTS` directly and was always correct. The built-in *instruction* presets (`CustomInstructionService.ts:26/35/44/53/62`) ran through the custom-instruction path and inserted the instruction's own wording instead of the result — see AI-05. Fixed in `9c2b4d4` (2026-09-21), **not verified in a running app** (`11` GAP-LLM-02). Translate still always targets English: `AppConfig` has no target-language key and neither `language` (UI locale) nor `sttLanguage` (source language) can stand in (`llm-prompts.ts:37-52`, `11` GAP-LLM-01) | -| AI-05 | Custom instructions (user commands) | [~] | [x] | [x] | [x] | Web `commands`; mobile `CommandsScreen` — both go through Edge Functions and are unaffected. **Desktop: the custom-instruction path never worked in any shipped release.** The instruction was passed in the `text` argument of `processText(text, action, targetLanguage, customPrompt)` with the system-prompt argument left empty; `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell back to `refine` silently and the model polished the instruction it was handed — the transcript never reached it. Separately, only `{{text}}` was substituted and **none of the five built-ins use it** (`{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder), so the substitution was a no-op from the day it was written. Introduced `fea923d` (2026-04-05); present `v0.1.0-alpha`..`v1.4.0`; **not a regression**. Three entry points were affected: commands-page activation (`CommandsPage.tsx:102-117`), command-popup selection (`bootstrap.ts:377-381`), voice keyword match (`VoiceModeService.ts:681-686`). Fixed in `9c2b4d4` (2026-09-21) — `llm-prompts.ts` is now the SSOT for placeholder substitution and argument placement (`renderInstructionPrompt:78`, `buildInstructionInvocation:101`, `resolveSystemPrompt:118`). The earlier "Red Team RT-03 verified" claim did not catch this and its cited evidence file (`red_team_log.md`) is not in the repo. `[~]` because unit tests pass (`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5) but there is **no running-app verification** and the related `tests/red/{instruction,chain,voice,config}.usecase.test.ts` could not execute (`11` GAP-INFRA-06). See `11` GAP-LLM-01/02 **Sync (2026-09-27):** desktop user commands (not the desktop presets) mirror Supabase `custom_instructions` both ways, deletions included; server presets (`builtin_key`) are not copied to desktop. | +| AI-05 | Custom instructions (user commands) | [~] | [x] | [x] | [x] | Web `commands`; mobile `CommandsScreen` — both go through Edge Functions and are unaffected. **Desktop: the custom-instruction path never worked in any shipped release.** The instruction was passed in the `text` argument of `processText(text, action, targetLanguage, customPrompt)` with the system-prompt argument left empty; `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell back to `refine` silently and the model polished the instruction it was handed — the transcript never reached it. Separately, only `{{text}}` was substituted and **none of the five built-ins use it** (`{{targetLanguage}}`, `{{userPrompt}}`, or no placeholder), so the substitution was a no-op from the day it was written. Introduced `fea923d` (2026-04-05); present `v0.1.0-alpha`..`v1.4.0`; **not a regression**. Three entry points were affected: commands-page activation (`CommandsPage.tsx:102-117`), command-popup selection (`bootstrap.ts:377-381`), voice keyword match (`VoiceModeService.ts:681-686`). Fixed in `9c2b4d4` (2026-09-21) — `llm-prompts.ts` is now the SSOT for placeholder substitution and argument placement (`renderInstructionPrompt:78`, `buildInstructionInvocation:101`, `resolveSystemPrompt:118`). The earlier "Red Team RT-03 verified" claim did not catch this and its cited evidence file (`red_team_log.md`) is not in the repo. `[~]` because unit tests pass (`llm-prompts.test.ts` 21, `llm-handlers.test.ts` 7, `VoiceModeService.test.ts` 22, `ChainService.test.ts` 5) but there is **no running-app verification** and the related `tests/red/{instruction,chain,voice,config}.usecase.test.ts` could not execute (`11` GAP-INFRA-06). See `11` GAP-LLM-01/02 **Sync (2026-09-27):** desktop user commands (not the desktop presets) mirror Supabase `custom_instructions` both ways, deletions included; server presets (`builtin_key`) are not copied to desktop. **Presets (2026-09-27):** prompt edits of the four desktop presets that have a phone counterpart (translate/summarize/formal/explain-code) update the server preset row through `sync_set_builtin_instruction_prompt_v1` (NULL restores the default; `{{targetLanguage}}` is sent as English because both sides translate to English); the phone executes that prompt. `자유 프롬프트` has no phone counterpart and stays local | | AI-06 | Voice keyword commands | [~] | [-] | [ ] | [-] | Desktop `VoiceCommandService` + command popup. Keyword matching itself works (`VoiceModeService.ts:681-686`), but execution went through the broken custom-instruction path (AI-05), and a **second, separate defect** made the shortcut skip LLM processing entirely whenever `defaultLLMAction === 'none'` — which is exactly the value the commands UI and the command popup write when no command is active (`CommandsPage.tsx:116`, `bootstrap.ts:385`), so an explicitly named instruction was nullified by the default setting. Both fixed in `9c2b4d4` (`VoiceModeService.ts:779`/`:785`); **not verified in a running app** (`11` GAP-LLM-02) | | AI-07 | LLM Chains (multi-step pipelines) | [~] | [ ] | [ ] | [-] | Desktop `ChainService`. The argument placement was already correct here, but chain steps **never substituted placeholders**, so `{{targetLanguage}}` / `{{userPrompt}}` reached the model verbatim as the system prompt. Now shares the same resolution function as the other two paths (`ChainService.ts:196`). Fixed in `9c2b4d4` (2026-09-21); 5 unit tests GREEN, but `tests/red/chain.usecase.test.ts` could not execute (`11` GAP-INFRA-06) and there is **no running-app verification** (`11` GAP-LLM-02) | | AI-08 | Screen/context capture for prompts | [x] | [-] | [ ] | [-] | Desktop `ScreenContextService` | @@ -75,7 +75,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | ID | Feature | D | W | M | B | Anchors / notes | |---|---|---|---|---|---|---| -| MTG-01 | Meeting recording + live transcript | [x] | [~] | [x] | [x] | Desktop `MeetingModeService`; web realtime view; mobile | +| MTG-01 | Meeting recording + live transcript | [x] | [~] | [x] | [x] | Desktop `MeetingModeService`; web realtime view; mobile **Sync (2026-09-27):** the phone renders `transcripts` segments in preference to `edited_transcript`, so desktop edits, auto-polish and diarization never reached it; the desktop now rewrites the meeting's segments from its `[MM:SS] [speaker] text` lines on every transcript change (upsert by `meeting_id,segment_index`, trailing segments trimmed; parser SSOT `@d3ro/core/meeting-transcript`, `tests/main/sync/SyncTranscriptsPresets.test.ts` (4) + local-stack integration case "전사 구간·프리셋") | | MTG-02 | Meeting creation (title/attendees/language/template) | [x] | [x] | [x] | [x] | SSOT H-006 GREEN | | MTG-03 | Timestamped memos during meeting | [x] | [x] | [x] | [x] | | | MTG-04 | AI summary generation | [x] | [x] | [~] | [x] | Desktop `MeetingSummaryService`; web `generate-document-button`; mobile via Edge | diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index ad10a95..a92e03b 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -97,7 +97,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-SYNC-03 | Portability | Mobile/cloud data export failed for any account with meetings or generated documents: `export_account_portability` used `to_jsonb(row)` while the v1 archive contract (server restore + mobile `exactKeys`) is a frozen key set, and later migrations added columns. | `server/supabase/migrations/20260927000034_cross_device_sync_parity.sql` §7 | `[x]` 2026-09-27: export projects each dataset onto its v1 keys; integration test asserts the memo/meeting key sets. | | GAP-SYNC-04 | Mobile devices | Mobile `normalizeRegisteredDevice` rejected every platform except `android`/`ios`, so the first desktop/web device on an account would have made the whole Devices screen fail to load. | `apps/mobile-rn/src/features/devices/device-service.ts`, `screens/DevicesScreen.tsx` | `[x]` 2026-09-27: accepts the server platform list (`android/ios/web/windows/macos`), card shows the platform; `__tests__/devices.test.ts` 3/3, full mobile Jest 372/372. | | GAP-SYNC-05 | Sync | **Not yet in production.** Migration `20260927000034` is applied only to the local stack; the rewritten desktop sync ships with the next desktop release. Until the migration is deployed, desktop tombstone pulls and template RPCs fail against production (they are isolated per step, other entities keep syncing). | migration file, `apps/desktop/src/main/services/sync/` | `[~]` 2026-09-27: production `supabase db push` applied `0029`–`0035` (backup dump taken first; see GAP-OPS-01), all 23 Edge Functions redeployed, v1.7.0 published (runtime/portable first, then the updater feed) and installed on the owner PC. **Remaining:** the desktop on that PC is in local mode — one Google sign-in in Settings > Cloud, then edit/delete on the phone and confirm the desktop updates within seconds and appears under Devices. | -| GAP-SYNC-06 | Sync | Still desktop-local (not mirrored): knowledge base (desktop local RAG embeddings vs cloud pgvector — different embedding spaces), audio (desktop WAV files vs mobile Storage uploads), `user_settings` (locale, auto-polish, active command), desktop preset-command prompt edits, meeting `transcripts` segments (desktop uses `raw_transcript`, which mobile also fills). | `docs/map/10-feature-catalog.md` MEM-09/MEM-05/SHELL rows | `[x]` 2026-09-27: knowledge docs sync as source text and are re-embedded per surface; recordings upload to the mobile storage contract (≤50 MiB per file, opt-out toggle) and remote recordings play through signed URLs; `user_settings` maps the shared fields. Deliberately not synced (see §7.x): meeting `transcripts` segments (mobile renders `raw_transcript`/`edited_transcript`, which the desktop fills) and desktop preset-command prompt edits (server presets are immutable by RLS). Evidence: `tests/main/sync/SyncExtensions.test.ts` (9) + integration case "지식 문서·설정·녹음" against the local Supabase stack. | +| GAP-SYNC-06 | Sync | Still desktop-local (not mirrored): knowledge base (desktop local RAG embeddings vs cloud pgvector — different embedding spaces), audio (desktop WAV files vs mobile Storage uploads), `user_settings` (locale, auto-polish, active command), desktop preset-command prompt edits, meeting `transcripts` segments (desktop uses `raw_transcript`, which mobile also fills). | `docs/map/10-feature-catalog.md` MEM-09/MEM-05/SHELL rows | `[x]` 2026-09-27: knowledge docs sync as source text and are re-embedded per surface; recordings upload to the mobile storage contract (≤50 MiB per file, opt-out toggle) and remote recordings play through signed URLs; `user_settings` maps the shared fields. 2026-09-27 (later): the two items first left out are done too — meeting `transcripts` segments are rebuilt from the desktop transcript (the phone renders segments before `edited_transcript`, so desktop edits were invisible there) and preset prompt edits reach the phone through `sync_set_builtin_instruction_prompt_v1` (migration `0036`, production applied; `tests/main/sync/SyncTranscriptsPresets.test.ts` (4) + local-stack integration case "전사 구간·프리셋"). Evidence: `tests/main/sync/SyncExtensions.test.ts` (9) + integration case "지식 문서·설정·녹음" against the local Supabase stack. | | GAP-SYNC-07 | Sync | `sync_tombstones` grows without a schedule. `prune_sync_tombstones_v1(interval)` (service_role) exists; nothing calls it. | migration §1 | `[x]` 2026-09-27: migration `0035` enables `pg_cron` and schedules `prune-sync-tombstones` daily at 03:17 UTC (production `cron.job` active=true). A client whose cursor is older than 180 days must fall back to a full resync. | | GAP-OPS-01 | Ops | **Production Supabase was five migrations behind** (`0029` content reporting, `0030` profile column ACL — users could raise their own `role`/`tier` through PostgREST, `0031` audit-log actor unlink, `0032` meeting-document reporting, `0033` team activity feed) and `content-report` was never deployed, while the map listed those features GREEN from local runs. Most functions still ran the 2026-08-21 build. | `server/supabase/migrations/`, Management API `schema_migrations`, `functions` list | `[x]` 2026-09-27: schema+data dump taken, `db push` applied `0029`–`0035`, all functions redeployed (unauthenticated calls return 401, i.e. live and gated); `authenticated` can no longer UPDATE `profiles.tier`; real-account export returns exactly the v1 keys. Next: add a CI check that compares `supabase migration list --linked` with the repo so production cannot silently lag again. | @@ -218,8 +218,8 @@ Actionable checklist for the work started this session. Fields to fill are blank - **원격 삭제가 로컬 미전송 수정보다 우선한다.** 다른 기기에서 지운 행은 데스크톱에서 편집 중이었어도 지워진다. - **업그레이드 후 첫 동기화는 예전 삭제를 되살릴 수 있다.** 이 버전 이전에는 삭제 기록이 없었으므로, 데스크톱에만 남아 있던 행(모바일에서 지웠지만 데스크톱이 몰랐던 행)은 첫 대조에서 서버로 다시 올라간다. 데이터 유실보다 되살림을 택했다. - **데스크톱 사용 횟수(`dictionary.usage_count`)는 자주 바뀌어 매번 올리지 않는다.** 단어를 추가·수정할 때 함께 올라간다. -- **회의 `transcripts` 세그먼트는 동기화하지 않는다.** 모바일은 회의 본문을 `raw_transcript`/`edited_transcript`로 그리고, 데스크톱은 이 두 컬럼을 채운다. -- **데스크톱 프리셋 명령의 프롬프트 수정은 기기 로컬이다.** 서버 프리셋(`builtin_key`)은 RLS상 클라이언트가 고칠 수 없고 두 프리셋 목록도 다르다. 사용자가 만든 명령만 동기화한다. +- **회의 전사 구간은 데스크톱 전사에서 다시 만든다.** 폰은 구간이 있으면 구간을 그리므로, 데스크톱 전사가 바뀔 때마다 구간을 통째로 맞춘다(시각 표기가 없는 줄은 앞 줄의 시각을 잇는다). 폰은 전사를 고치지 않으므로 서버→데스크톱 방향은 `raw_transcript`로 충분하다. +- **프리셋 명령은 짝이 있는 4개만 맞춘다.** 폰은 프리셋 행을 직접 고칠 수 없고(RLS) 데스크톱에서 고친 프롬프트만 전용 RPC로 올린다. 번역의 `{{targetLanguage}}`는 English로 바꿔 올린다(양쪽 모두 영어 번역). `자유 프롬프트`는 폰에 대응이 없어 로컬이다. - **설정은 뜻이 같은 것만 맞춘다.** 언어는 모바일이 지원하는 ko/en, 테마는 시스템/라이트/다크만(데스크톱 전용 테마는 로컬), 자동 다듬기는 켬/끔, 활성 명령은 양쪽에 있는 사용자 명령일 때만. 서버에 설정 행이 이미 있으면 첫 동기화에서 서버 값이 이긴다. - **지식 문서는 원문만 옮긴다.** 기기마다 임베딩 모델·차원이 달라(데스크톱 768, 서버 1536) 벡터는 각자 만든다. 다른 기기에서 온 문서에는 원본 파일이 없다. diff --git a/package-lock.json b/package-lock.json index 312b9c9..34de088 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-monorepo", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-monorepo", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "workspaces": [ "apps/desktop", @@ -25,7 +25,7 @@ }, "apps/admin": { "name": "@d3ro/admin", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@d3ro/desktop", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -161,7 +161,7 @@ }, "apps/web": { "name": "@d3ro/web", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -17216,7 +17216,7 @@ }, "packages/api-client": { "name": "@d3ro/api-client", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -17233,7 +17233,7 @@ }, "packages/core": { "name": "@d3ro/core", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -17244,7 +17244,7 @@ }, "packages/i18n": { "name": "@d3ro/i18n", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -17255,7 +17255,7 @@ }, "packages/ui": { "name": "@d3ro/ui", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "dependencies": { "@d3ro/core": "*" @@ -17275,7 +17275,7 @@ }, "packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.8.0", + "version": "1.9.0", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/package.json b/package.json index aa99095..23f7faa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d3ro-voice-monorepo", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "author": "D3RO", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 54b7b80..472f2ce 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/api-client", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", diff --git a/packages/core/__tests__/meeting-transcript.test.ts b/packages/core/__tests__/meeting-transcript.test.ts new file mode 100644 index 0000000..aefe723 --- /dev/null +++ b/packages/core/__tests__/meeting-transcript.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { formatTranscriptLines, parseTranscriptLines } from '../src/meeting-transcript' + +describe('meeting transcript lines', () => { + it('parses timestamps and speakers written by the desktop recorder and diarization', () => { + const text = '[00:05] 안녕하세요\n\n[01:12] [화자 2] 다음 안건입니다\n[12:03] [Speaker 1] 끝' + expect(parseTranscriptLines(text)).toEqual([ + { timestampMs: 5_000, timed: true, speaker: null, text: '안녕하세요' }, + { timestampMs: 72_000, timed: true, speaker: '화자 2', text: '다음 안건입니다' }, + { timestampMs: 723_000, timed: true, speaker: 'Speaker 1', text: '끝' }, + ]) + }) + + it('keeps untimed lines (phone transcripts, manual edits) and carries the last time forward', () => { + expect(parseTranscriptLines('first line\n[00:30] timed\nfollow-up')).toEqual([ + { timestampMs: 0, timed: false, speaker: null, text: 'first line' }, + { timestampMs: 30_000, timed: true, speaker: null, text: 'timed' }, + { timestampMs: 30_000, timed: false, speaker: null, text: 'follow-up' }, + ]) + }) + + it('drops empty and timestamp-only lines and round-trips through the formatter', () => { + const text = '[00:01] [화자 1] a\n[00:02]\nb' + const lines = parseTranscriptLines(text) + expect(lines).toHaveLength(2) + expect(formatTranscriptLines(lines)).toBe('[00:01] [화자 1] a\nb') + }) + + it('returns nothing for empty input', () => { + expect(parseTranscriptLines(null)).toEqual([]) + expect(parseTranscriptLines(' \n ')).toEqual([]) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 705eaba..b157e04 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/core", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "license": "MIT", @@ -42,6 +42,10 @@ "types": "./src/caption-streaming.ts", "default": "./src/caption-streaming.ts" }, + "./meeting-transcript": { + "types": "./src/meeting-transcript.ts", + "default": "./src/meeting-transcript.ts" + }, "./entitlement": { "types": "./src/entitlement.ts", "default": "./src/entitlement.ts" diff --git a/packages/core/src/meeting-transcript.ts b/packages/core/src/meeting-transcript.ts new file mode 100644 index 0000000..10edfdf --- /dev/null +++ b/packages/core/src/meeting-transcript.ts @@ -0,0 +1,54 @@ +// packages/core/src/meeting-transcript.ts +// 회의 전사 텍스트 ↔ 구간(segment) 변환의 정본. +// 데스크톱은 전사를 `[MM:SS] [화자] 내용` 줄로 저장하고, Supabase `transcripts` 는 구간 행으로 저장한다 +// (모바일은 구간이 있으면 구간을 우선 표시한다). 두 표현을 이 한 곳에서만 바꾼다. + +export interface TranscriptLine { + /** 회의 시작 기준 ms. 시각 표기가 없는 줄은 앞 줄의 시각을 이어받는다(처음이면 0). */ + timestampMs: number + /** 시각 표기가 있었는지 — 없으면 화면이 순번으로 대신 표시할 수 있다 */ + timed: boolean + speaker: string | null + text: string +} + +const LINE_PATTERN = /^\[(\d{1,3}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.*)$/ + +/** 빈 줄을 버리고 각 줄을 구간으로 나눈다. 내용이 빈 줄(`[00:01]` 만 있는 줄)도 버린다. */ +export function parseTranscriptLines(text: string | null | undefined): TranscriptLine[] { + if (!text) return [] + const lines: TranscriptLine[] = [] + let lastTimestamp = 0 + for (const raw of text.split('\n')) { + const line = raw.trim() + if (!line) continue + const match = LINE_PATTERN.exec(line) + if (match) { + const content = match[4].trim() + lastTimestamp = (Number(match[1]) * 60 + Number(match[2])) * 1000 + if (!content) continue + lines.push({ timestampMs: lastTimestamp, timed: true, speaker: match[3]?.trim() || null, text: content }) + } else { + lines.push({ timestampMs: lastTimestamp, timed: false, speaker: null, text: line }) + } + } + return lines +} + +export function formatTranscriptTimestamp(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` +} + +/** parseTranscriptLines 의 역변환(시각 표기가 없던 줄은 그대로). */ +export function formatTranscriptLines(lines: readonly TranscriptLine[]): string { + return lines + .map((l) => { + if (!l.timed) return l.text + const speaker = l.speaker ? `[${l.speaker}] ` : '' + return `[${formatTranscriptTimestamp(l.timestampMs)}] ${speaker}${l.text}` + }) + .join('\n') +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json index b6aa72a..063144b 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/i18n", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "license": "MIT", diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json index 1271988..bc3624b 100644 --- a/packages/ui-native/package.json +++ b/packages/ui-native/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui-native", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 9059258..5bc146f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "license": "MIT", diff --git a/release/product-version.json b/release/product-version.json index 34a7938..180f0d3 100644 --- a/release/product-version.json +++ b/release/product-version.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "1.8.0", - "androidVersionCode": 1080000, - "iosBuildNumber": 1080000, + "version": "1.9.0", + "androidVersionCode": 1090000, + "iosBuildNumber": 1090000, "releaseDate": "2026-09-27", "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" } diff --git a/server/supabase/migrations/20260927000036_builtin_instruction_prompt_sync.sql b/server/supabase/migrations/20260927000036_builtin_instruction_prompt_sync.sql new file mode 100644 index 0000000..20c6740 --- /dev/null +++ b/server/supabase/migrations/20260927000036_builtin_instruction_prompt_sync.sql @@ -0,0 +1,101 @@ +-- Preset command prompts edited on the desktop reach the phone. +-- +-- Built-in custom_instructions rows are not writable by clients (RLS allows +-- only builtin_key IS NULL), and the phone executes every command with the +-- row's prompt. These RPCs let the owner change only the prompt of their own +-- preset row, or restore its default; name, key and order stay server-owned. +-- Defaults live in one function that bootstrap_custom_instructions' values +-- mirror, so "is this the default?" has a single answer. + +BEGIN; + +CREATE OR REPLACE FUNCTION public.builtin_instruction_default_prompt_v1(p_builtin_key text) +RETURNS text +LANGUAGE sql +IMMUTABLE +SET search_path = '' +AS $$ + SELECT CASE p_builtin_key + WHEN 'translate_en' THEN 'Translate the following text into natural English. Return only the translation.' + WHEN 'summarize' THEN 'Summarize the following text in no more than three concise lines.' + WHEN 'formal' THEN 'Rewrite the following text in a formal business style while preserving its meaning. Return only the rewritten text.' + WHEN 'explain_code' THEN 'Explain the following code in Korean, including its responsibilities and important caveats.' + END +$$; + +REVOKE ALL ON FUNCTION public.builtin_instruction_default_prompt_v1(text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.builtin_instruction_default_prompt_v1(text) TO authenticated; + +-- p_prompt NULL (or blank) restores the default prompt. +CREATE OR REPLACE FUNCTION public.sync_set_builtin_instruction_prompt_v1( + p_builtin_key text, + p_prompt text DEFAULT NULL +) +RETURNS public.custom_instructions +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public, auth +AS $$ +DECLARE + actor_id uuid := auth.uid(); + default_prompt text := public.builtin_instruction_default_prompt_v1(p_builtin_key); + next_prompt text; + saved public.custom_instructions; +BEGIN + IF actor_id IS NULL THEN + RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501'; + END IF; + IF default_prompt IS NULL THEN + RAISE EXCEPTION 'invalid_builtin_key' USING ERRCODE = '22023'; + END IF; + + next_prompt := coalesce(nullif(btrim(p_prompt), ''), default_prompt); + IF char_length(next_prompt) > 4000 THEN + RAISE EXCEPTION 'invalid_instruction_prompt' USING ERRCODE = '22023'; + END IF; + + -- The preset rows are created lazily; make sure this user has them. + PERFORM public.bootstrap_custom_instructions(); + + UPDATE public.custom_instructions + SET prompt = next_prompt + WHERE user_id = actor_id + AND builtin_key = p_builtin_key + AND prompt IS DISTINCT FROM next_prompt + RETURNING * INTO saved; + + IF saved.id IS NULL THEN + SELECT * INTO saved + FROM public.custom_instructions + WHERE user_id = actor_id AND builtin_key = p_builtin_key; + END IF; + RETURN saved; +END; +$$; + +REVOKE ALL ON FUNCTION public.sync_set_builtin_instruction_prompt_v1(text, text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.sync_set_builtin_instruction_prompt_v1(text, text) TO authenticated; + +-- Preset rows with a flag telling whether the prompt is still the default. +CREATE OR REPLACE FUNCTION public.sync_list_builtin_instructions_v1() +RETURNS TABLE (builtin_key text, prompt text, is_default boolean, updated_at timestamptz) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = '' +AS $$ + SELECT + instruction.builtin_key, + instruction.prompt, + instruction.prompt = public.builtin_instruction_default_prompt_v1(instruction.builtin_key), + instruction.updated_at + FROM public.custom_instructions AS instruction + WHERE instruction.user_id = auth.uid() + AND instruction.builtin_key IS NOT NULL + ORDER BY instruction.sort_order +$$; + +REVOKE ALL ON FUNCTION public.sync_list_builtin_instructions_v1() FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.sync_list_builtin_instructions_v1() TO authenticated; + +COMMIT; diff --git a/site/package-lock.json b/site/package-lock.json index 746ab25..a0003ea 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index e098f4e..4515044 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.8.0", + "version": "1.9.0", "type": "module", "scripts": { "dev": "vite --port 5199 --host", diff --git a/site/src/release.ts b/site/src/release.ts index 4360143..f3638e6 100644 --- a/site/src/release.ts +++ b/site/src/release.ts @@ -5,7 +5,7 @@ // NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는 // 실제 배포 환경에서 404가 된다. -export const DESKTOP_VERSION = '1.8.0' +export const DESKTOP_VERSION = '1.9.0' /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ export const DESKTOP_RELEASE_DATE = '2026-09-27'