이번 세션 범위: - Part 5-A: /simplify 패스 — isImeComposingEvent helper 추출, 8곳 IME 가드 통합 (커밋3b77be0) - Part 5-B: Voice Conversation 파이프라인 복구 — Bug 10.5/11/12 3개 버그 연쇄 fix (커밋ee6ac3d) - Part 5-C: Voice Conversation UX 개선 인터뷰 설계 — 몰입 패널 + 사운드 피드백 + 상태 분기 (ASCII 와이어프레임 + 구현 작업 리스트 handoff에 저장, 다음 세션 이월) - Part 5-D: Bug 13 신규 발견 — 빈 STT 결과 시 피드백 부재 (U6 몰입 패널과 함께 처리 예정) 갱신 파일: - memory/project_status.md — SaaS [13] 엔트리 (Bug 10.5/11/12/13, UX 설계 스펙) - memory/handoff-latest.md — Part 5 전체 요약 + Part 5-C 와이어프레임 + 다음 세션 프롬프트 - memory/handoff-archive/2026-04-11-2200-phase5-part5a-simplify.md — 직전 Part 5-A 스냅샷 아카이브 다음 세션 우선 작업: 1. Voice Conversation 몰입 패널 구현 (handoff의 Part 5-C 작업 리스트 8번까지) 2. Bug 13 피드백 훅 추가 (몰입 패널과 함께) 3. Phase 3.2 PREMIUM_LLM (선행: Anthropic/OpenAI API key 주입) 빅뱅 성공 기준: 8개 중 7개 완료 (Phase 3.2 PREMIUM_LLM만 잔존).
1098 lines
82 KiB
Markdown
1098 lines
82 KiB
Markdown
# D3RO-VOICE 프로젝트 현황
|
||
|
||
> 마지막 갱신: 2026-04-11 (Mac 부트스트랩)
|
||
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
|
||
|
||
## Mac 환경 부트스트랩 (2026-04-11)
|
||
|
||
Windows → Mac 핸드오프 완료 (`memory/handoff-latest.md` 참조).
|
||
|
||
| 항목 | 상태 |
|
||
|---|---|
|
||
| Homebrew | ✅ 기존 설치 |
|
||
| Python 3.13 | ✅ 기존 설치 |
|
||
| Node 22.22.2 (brew) | ✅ 신규 설치 |
|
||
| sox 14.4.2 (brew) | ✅ 신규 설치 |
|
||
| Ollama | ❌ 미설치 (로컬 LLM 필요 시 `brew install ollama && ollama pull qwen3:4b`) |
|
||
| `npm install --force` | ✅ 1142 패키지 (rollup win32 옵셔널 dep 무시) |
|
||
| `@electron/rebuild@3 --version=33.4.11` | ✅ native 모듈 재빌드 완료 |
|
||
| desktop `tsc --noEmit` | ✅ |
|
||
| web `tsc --noEmit` | ✅ |
|
||
| web `next build` | ✅ (15 라우트, 5.8s compile) |
|
||
| api-client `vitest` | ✅ (19 passed, 283ms) |
|
||
| git working tree | ✅ clean (`package-lock.json` 포함 변경 없음) |
|
||
|
||
**남은 사용자 액션**
|
||
- OAuth Provider (Google/GitHub) 연결 — Supabase 대시보드 → Auth → Providers
|
||
- 외부 API 키 — ANTHROPIC / OPENAI / GOOGLE_CLOUD_STT / RESEND / STRIPE (아래 Edge Function Secrets 섹션)
|
||
- (선택) `brew install ollama` + `ollama pull qwen3:4b`
|
||
- (필요 시) `npm run dev`로 Electron 앱 수동 검증 — 첫 실행 시 Accessibility/마이크/Apple Events 권한 다이얼로그 승인
|
||
|
||
## Supabase 프로젝트 연결 (2026-04-11)
|
||
|
||
**프로젝트**: `yunchan8804-blip's Project` (ref: `llnocwyqvhgwpdjcqqyw`)
|
||
**URL**: `https://llnocwyqvhgwpdjcqqyw.supabase.co`
|
||
**CLI**: `supabase 2.84.2` (brew install supabase/tap/supabase)
|
||
|
||
| 단계 | 결과 |
|
||
|---|---|
|
||
| `supabase link --project-ref llnocwyqvhgwpdjcqqyw` | ✅ |
|
||
| `config.toml` db.major_version 15→17 | ✅ (원격 매치) |
|
||
| `supabase db push --include-all` | ✅ 8개 마이그레이션 전부 적용 |
|
||
| `supabase functions deploy` | ✅ 10개 Edge Functions 배포 |
|
||
| `supabase secrets list` | SUPABASE_* 자동 주입 4개만 존재 |
|
||
|
||
**배포된 Edge Functions** (10개)
|
||
`embed-chunks`, `llm-proxy`, `search-knowledge`, `send-push`, `stripe-checkout`, `stripe-portal`, `stripe-webhook`, `stt-proxy`, `team-accept`, `team-invite`
|
||
|
||
**로컬 env 파일** (git 정책으로 Claude가 직접 쓰지 못함 — 사용자가 수동 생성)
|
||
- `apps/web/.env.local` — NEXT_PUBLIC_SUPABASE_URL + ANON_KEY + SITE_URL + SERVICE_ROLE_KEY
|
||
- `server/supabase/.env.local` — REF/URL/KEY/DB_PASSWORD/DATABASE_URL 참조용
|
||
|
||
## Edge Function Secrets — 필요한 외부 키
|
||
|
||
지금 배포된 함수가 실제로 동작하려면 아래 키를 `supabase secrets set ...`로 주입해야 함. 키가 없으면 해당 함수는 400/500 반환.
|
||
|
||
| 키 | 쓰는 곳 | 필수? |
|
||
|---|---|---|
|
||
| `ANTHROPIC_API_KEY` | llm-proxy (채팅/액션/문서생성) | **필수** — 없으면 웹 /chat, /actions, meetings 문서 생성 불가 |
|
||
| `OPENAI_API_KEY` | embed-chunks, search-knowledge | **필수** — knowledge 시맨틱 검색에 필수 |
|
||
| `RESEND_API_KEY` | team-invite (이메일 발송) | 팀 초대 기능 쓸 때 |
|
||
| `RESEND_FROM` | team-invite 보낸이 | 기본값: `D3RO Voice <noreply@d3ro.dev>` |
|
||
| `SITE_URL` | team-invite 이메일 내 링크 | 기본값: `https://d3ro.dev` |
|
||
| `STRIPE_SECRET_KEY` | stripe-checkout/portal | 결제 기능 쓸 때 |
|
||
| `STRIPE_PRICE_PRO` / `STRIPE_PRICE_TEAM` | stripe-checkout | 각 요금제 Price ID |
|
||
| `STRIPE_WEBHOOK_SECRET` | stripe-webhook | 웹훅 서명 검증 |
|
||
| `GOOGLE_CLOUD_STT_KEY` | stt-proxy | 현재 스캐폴딩 상태 (index.ts 주석처리) |
|
||
|
||
주입 예:
|
||
```bash
|
||
cd server
|
||
SUPABASE_ACCESS_TOKEN=<ACCESS_TOKEN> supabase secrets set \
|
||
ANTHROPIC_API_KEY=sk-ant-... \
|
||
OPENAI_API_KEY=sk-proj-... \
|
||
RESEND_API_KEY=re_...
|
||
```
|
||
|
||
## 현재 단계
|
||
|
||
**V1 완료** → **V2 1차 전 페이즈 + 2차/3차/4차/5차 고도화 완료** → **Mac 부트스트랩 + Supabase 배포 완료**
|
||
|
||
V2 1차:
|
||
- Phase V2-1 ✅ (Monorepo 전환 a/b/c/d + minor 정리)
|
||
- Phase V2-2 ✅ 로컬 + 원격 배포 완료 (Supabase llnocwyqvhgwpdjcqqyw)
|
||
- Phase V2-3 ✅ (Web App MVP — Next.js 15, 12 라우트)
|
||
- Phase V2-4 ✅ (데스크톱 Supabase push 동기화)
|
||
- Phase V2-5 🟡 로컬 완료 (Mac 빌드), 사용자 검증 대기
|
||
- Phase V2-6 ✅ (Mobile MVP — Expo)
|
||
- Phase V2-7 ✅ (팀 기능 — Web /teams)
|
||
- Phase V2-8 ✅ (결제 스캐폴딩 — Stripe Edge Functions + /billing)
|
||
|
||
V2 2차 고도화 (2026-04-09):
|
||
- [A1] ✅ SettingsModal에 CloudSyncSection 통합
|
||
- [A2] ✅ web Sidebar 공유 layout (app/(app)/)
|
||
- [A3] ✅ 11개 locale 번역
|
||
- [B] ✅ V2-4b pull 동기화 — history/dictionary
|
||
- [C] ✅ V2-8b Stripe webhook HMAC-SHA256 + Customer Portal
|
||
- [D] ✅ V2-6b packages/ui-native 패키지
|
||
|
||
V2 3차 고도화 (2026-04-10):
|
||
- [E] ✅ V2-4b pull 확장 — meetings/meeting_memos/meeting_documents
|
||
- [F] ⚠️ Database 제네릭 — interface→type 전환 (4차에서 완전 복원)
|
||
- [G] ✅ apps/mobile UI를 @d3ro/ui-native로 실제 교체
|
||
- [H] ✅ V2-7b 이메일 invite flow (team_invites + Edge Functions + /accept-invite)
|
||
- [I] ✅ Realtime transcripts 구독 (LiveTranscriptList)
|
||
- [J] ✅ api-client 유닛 테스트 + web E2E 스모크 스캐폴딩
|
||
|
||
V2 4차 고도화 (2026-04-10):
|
||
- [K] ✅ @supabase/ssr 0.5→0.10, supabase-js 2.45→2.103 bump → Database 제네릭 완전 주입
|
||
- [L] ✅ V1 Voice Conversation → web /chat
|
||
- [M] ✅ V1 RAG → web /knowledge
|
||
- [N] ✅ team-invite Resend 이메일 발송
|
||
- [O] ✅ mobile Expo Push
|
||
- [P] ✅ meetings/[id] 문서 생성 버튼
|
||
|
||
V2 5차 고도화 (2026-04-10):
|
||
- [Q] ✅ pgvector + knowledge 시맨틱 검색 — embedding 컬럼(1536차원), ivfflat 인덱스, match_knowledge_chunks RPC, embed-chunks/search-knowledge Edge Functions, /knowledge 검색창
|
||
- [R] ✅ /chat SSE 스트리밍 — llm-proxy가 Anthropic stream 프록시, chat-panel에서 content_block_delta 파싱 후 progressive 렌더링
|
||
- [S] ✅ 회의 문서 편집 DocumentEditor — MUI Dialog + textarea 기반 MarkdownEditor, 저장/삭제
|
||
- [T] ✅ V1 VoiceAction → web /actions — 자연어 명령 LLM 파싱 (create_meeting/search_knowledge/create_memo/send_team_invite), 파싱 결과 확인 후 실행
|
||
- [U] ✅ Desktop CloudSyncService Realtime 구독 + web record 오디오 Storage 업로드 + 회의 메모 작성 UI (MemoForm)
|
||
- [V] ✅ 11개 locale에 nav.chat/knowledge/actions 키 추가, ko.json 정리, Sidebar Actions 메뉴
|
||
|
||
V2 6차 고도화 (2026-04-11):
|
||
- [W] 🐛 fix: AppRouterCacheProvider Emotion key `'d3ro-mui'` → `'mui'` (`d3ro` 안에 숫자 `3` → Emotion 검증 실패로 dev SSR 500)
|
||
- [A] ✅ Dark/Light/Theme 토글 — `theme-mode-context.tsx` (localStorage + prefers-color-scheme), Sidebar에 6종 + auto MUI Select, 12개 locale `theme.*` 키 추가
|
||
- [B] ✅ DocumentEditor Mermaid + Markdown 렌더 — `markdown-preview.tsx` (react-markdown + remark-gfm + lazy mermaid), Edit/Preview Tabs
|
||
- [C] ✅ 회의 오디오 재생 — `meeting-audio-player.tsx` (Storage signed URL 1h TTL → `<audio controls>`), `/meetings/[id]` AUDIO 카드
|
||
- [D] ✅ Dashboard 14일 추이 차트 — recharts LineChart (회의/문서 수 일별 집계), 이번 주 stat 자동 계산
|
||
- [E] 🐛 fix(desktop): macOS 단축키 표기 — `format-hotkey.ts` 신규 (플랫폼별 segment 변환). preload `electronAPI.platform` 노출. macOS는 `⌃ ⌥ ⇧ ⌘` (Apple HIG 순서) + 구분자 없음 (`⌘⇧1`), Win/Linux는 `Ctrl + Shift + 1`. SettingsModal/OnboardingModal/DashboardPage/HotkeyRecordModal 모두 `binding.displayLabel` 직접 참조 → `formatHotkeyLabel/Segments(binding)`로 교체. displayLabel split 버그(저장 시 `'+'` join, 표시 시 `' + '` split 불일치)도 함께 해소.
|
||
- [F] 🐛 fix(desktop): Mac sidecar 부팅 — 시스템 python3에 numpy 등 deps 없어서 매번 ModuleNotFoundError. `apps/desktop/sidecar/.venv/` 신규(gitignored), faster-whisper/fastapi/uvicorn/python-multipart/numpy 설치. `paths.ts.getSidecarCommand`가 venv python 우선 사용 (macOS `.venv/bin/python3`, Win `.venv/Scripts/python.exe`).
|
||
- [G] 🐛 fix(desktop): HotkeyRecordModal 칩 'WIN' 표기 잔존 — `getKeyName`이 KEY_DISPLAY_MAP(Windows 전용 91/92→'Win') 사용. `format-hotkey.ts`의 `keyCodeToName/getPlatform` export → modal에서 호출. `'Shift + Win + 1 — 저장' ` 안내 텍스트도 mac에서 구분자 없이 결합.
|
||
- [H] 🐛 fix(desktop): `hideRecordingTip is not defined` ReferenceError — `bootstrap.ts`의 WindowManager import 목록에 `hideRecordingTip`/`updateRecordingTipState` 누락. session-cancelled/error 핸들러에서 호출 → ReferenceError로 RecordingTip 팝업이 닫히지 않는 누적 버그. import 추가.
|
||
- [I] 🐛 fix(desktop): 핫키 누를 때 macOS 시스템 beep — uiohook-napi는 글로벌 모니터링만 하고 OS swallow 안 함. 키가 받는 곳 없어 NSBeep 발생. `HotkeyService`에 Electron `globalShortcut` 통합. `bindingToAccelerator(binding)`이 Windows VK + modifier → Electron Accelerator string 변환 (mac은 `Cmd`, win은 `Super`). `globalShortcut.register(accel, noop)`로 OS swallow + uiohook은 그대로 hold/release 처리. `HotkeyConfig.acceleratorString` 필드 추가. unregister/stop에서 cleanup. 단일 modifier 키만 등록 시(Right Alt 단독 등) accelerator 변환 불가하면 globalShortcut 건너뛰고 uiohook만 사용.
|
||
- [J] 🐛 fix(desktop): sidecar 동적 포트 + orphan 충돌 — dev mode HMR 재시작 시 이전 sidecar python이 orphan으로 18765 점유. 새 main이 spawn하면 EADDRINUSE로 즉시 exit. `LocalSTTService._findFreePort(start, max=20)` 추가 (`net.createServer().listen(port, '127.0.0.1')`로 free 포트 탐색), spawn 진입 시 `this._port` 동적 갱신. health/load/transcribe URL이 자동으로 새 포트 사용.
|
||
- [K] 🐛 fix(desktop): macOS 텍스트 붙여넣기 — `TextInsertService._insertViaClipboard`가 `nut.Key.LeftControl + V` 하드코딩이라 macOS에서는 paste 안 됨 (Cmd+V여야). `pasteModKey = process.platform === 'darwin' ? LeftSuper : LeftControl` 분기. 잘못된 클립보드 검증("text 그대로면 정상") 로직 제거 (macOS는 Ctrl+V가 noop이라 항상 거짓 양성). macOS는 paste 후 sleep 250ms.
|
||
- [L] 🐛 fix(desktop): qwen3 reasoning 39초 + 빈 응답 — qwen3:4b는 reasoning model이라 응답에 `<think>...</think>` 블록을 길게 출력, 본문이 비어 _completeSession("")로 paste 호출 안 됨. SYSTEM_PROMPTS 전체에 `/no_think` 토큰 추가 (qwen3 비활성화, 다른 모델은 무시). `stripReasoningBlocks()` 추가 — `<think>`/`<thinking>` 태그 안전망. `processText`가 stripped 결과 길이 0이면 원본 transcript fallback + warn 로그.
|
||
|
||
## SaaS 데스크톱 클라이언트 1차 (2026-04-11)
|
||
|
||
> 사용자 표현: "설계가 잘못된 것 같다"
|
||
> 실제 상황: 백엔드/웹/모바일은 100% multi-tenant SaaS, 데스크톱만 V1 single-user 잔재로
|
||
> 사용자가 Settings에서 Supabase URL/Key를 직접 입력해야 하는 UX였음.
|
||
> Notion/Linear/Slack 데스크톱 패턴으로 정정.
|
||
|
||
### SaaS [1] 빌드 타임 Supabase env 주입
|
||
- `electron.vite.config.ts`: `defineConfig(({ mode }) => ...)`로 변경, `loadEnv(mode, cwd, 'D3RO_')`
|
||
로 `.env.local` 자동 로드 → main 번들에 `define`으로 inline 박음
|
||
- `apps/desktop/.env.local` 생성 (gitignored): `D3RO_SUPABASE_URL`, `D3RO_SUPABASE_ANON_KEY`
|
||
- `apps/desktop/env.example` 템플릿 (committed)
|
||
- `ConfigService`:
|
||
- `BUILD_TIME_SUPABASE_URL` / `BUILD_TIME_SUPABASE_ANON_KEY` 상수 (process.env에서 읽음)
|
||
- `isSupabaseBuildTimeConfigured()` 헬퍼 export
|
||
- `configGet('supabaseUrl'/'supabaseAnonKey')`은 빌드 타임 값 절대 우선
|
||
- `configSet`는 빌드 타임 모드일 때 supabaseUrl/AnonKey 변경 거부
|
||
|
||
### SaaS [2] OAuth 첫 실행 강제 게이트 → **철회 (Phase 1.5에서 로컬 모드 entry point로 변경)**
|
||
- `CloudSyncState`에 `saasMode: boolean` 필드 추가 (유지)
|
||
- `CloudSyncSection`: saasMode에서 OAuth 버튼만, legacy에서는 URL/Key 입력 UI (유지)
|
||
- ~~`LoginScreen.tsx` 강제 게이트~~ → 제거 (App.tsx에서 사용 안 함, 파일 자체는 유지)
|
||
- ~~`App.tsx` AuthGate 상태머신~~ → 제거 (항상 메인 UI 렌더)
|
||
|
||
**철회 이유** (2026-04-11 빅뱅 Phase 1.5):
|
||
사용자 피드백 "무료 로컬은 사용자 엔트리 포인트가 될 거구, 수익화는 클라우드 SaaS 서비스임".
|
||
로컬 STT/LLM이 이 앱의 킬러 피처 → 회원가입 없이 즉시 체험 가능해야 함 (onboarding funnel 입구).
|
||
OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는 철학 + 인프라 양쪽 모두 위배.
|
||
|
||
### SaaS [3] 사용자별 SQLite 격리 — ✅ **완료 (2026-04-11, 빅뱅 Phase 1)**
|
||
|
||
- **`apps/desktop/src/main/db/index.ts` 전면 재설계**
|
||
- 구 `initDatabase()` / `closeDatabase()` 폐기 (이름 차원에서 제거)
|
||
- 새 API: `openForUser(userId)`, `closeCurrent()`, `getDatabase()`, `getCurrentUserId()`, `isDatabaseOpen()`, `getCurrentDbPath()`
|
||
- 경로: `userData/users/${userId}/d3ro.db` (userId path-separator/.. 가드)
|
||
- `applySchema(s)`로 테이블 + 인라인 ALTER 마이그레이션 추출 — 새 DB 생성 시마다 재실행
|
||
- 모듈 상태: `currentUserId | null`, `sqlite`, `db` — closeCurrent 후 모두 null
|
||
- 같은 userId 재호출 시 no-op, 다른 userId면 먼저 close 후 재오픈
|
||
- `openForUser` 실패 시 부분 상태 정리 + `D3ROError(DBOpenFailed)` throw
|
||
- **legacy DB 정책**
|
||
- V1 `userData/d3ro-voice.db`는 한 번도 정식 서비스 안 됨 → `archiveLegacyDbIfExists()`로 첫 `openForUser` 직전 silent rename (`d3ro-voice.legacy-${ISO timestamp}.db`)
|
||
- 파일 삭제 없음, dialog 없음, 데이터 이전 없음 ("클린하게" 요청)
|
||
- **`CloudSyncService` 인증 lifecycle 단일 진입점** (SSOT)
|
||
- `_onAuthenticated(session, { reason: 'restore' | 'signin' })`:
|
||
1. `this._session = session`
|
||
2. `openForUser(userId)` — 실패 시 세션 무효화 + `auth-changed(null)` + `sync-error` emit 후 return
|
||
3. `auth-changed(user)` emit (DB가 열린 뒤에)
|
||
4. `startRealtime()` 자동 시작
|
||
- `init()` 세션 복원, `handleAuthCallback(code)` 신규 로그인 모두 이 함수로 수렴
|
||
- `_onSignOut()`:
|
||
1. VoiceMode active → `cancelSession()` (녹음 진행 중이면 강제 취소)
|
||
2. MeetingMode active → `stopRecording()` (await)
|
||
3. Caption → `stop()` (await)
|
||
4. `stopRealtime()`
|
||
5. Supabase `auth.signOut()`
|
||
6. `_session = null` + `_clearStoredRefreshToken()`
|
||
7. `auth-changed(null)` emit — 렌더러 LoginScreen 복귀
|
||
8. `closeCurrent()` — 이 시점 이후 `getDatabase()`는 throw
|
||
- `signOut()` public 메서드는 `_onSignOut()` 위임
|
||
- `import` 동적 변경: VoiceMode/MeetingMode/Caption 모두 dynamic import로 순환 의존 회피
|
||
- **`bootstrap.ts` 순서 변경**
|
||
- `database` step 완전 제거 (critical 2번째였던 자리 삭제)
|
||
- `cloud-sync` step은 마지막 유지 — 세션 복원 성공 시 DB가 여기서 열림
|
||
- `initDatabase` import 삭제
|
||
- **검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop `npm run build` ✅ (main bundle 997kB, vite 경고는 기존 동적 import 관련 pre-existing)
|
||
- runtime (dev 띄워서 LoginScreen → OAuth → user DB 생성 확인) — 사용자 수동 검증 대기
|
||
- **남은 작업 (Phase 1 follow-up)**
|
||
- 사용자 A → signOut → 사용자 B 로그인 시 A 데이터 안 보이는지 실측
|
||
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
|
||
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
|
||
|
||
### SaaS [13] Voice Conversation 파이프라인 복구 + UX 개선 설계 (Phase 5 Part 5, 2026-04-11)
|
||
|
||
> **Voice Conversation이 Mac에서 한 번도 end-to-end 동작한 적 없었음을 발견.** 세 개의 독립된 버그가 합쳐져 있었고 진단 로그를 추가해 하나씩 분해해 해결. UX 개선은 사용자와 인터뷰로 설계서만 확정하고 구현은 다음 세션으로 이월.
|
||
|
||
**Bug 10.5 — TTS `spawn powershell ENOENT` (Mac)**
|
||
- 원인: `TTSPlaybackService.ts:105`가 `spawn('powershell', ...)` 하드코딩. 파일 주석부터 "Windows SAPI (PowerShell) 기반" — V2-5 플랫폼 감사 때 이 파일 누락.
|
||
- Fix: `_speakOne`를 dispatcher로 바꾸고 `_speakOneMac`(macOS `say -r <wpm>`) / `_speakOneWindows`(기존 PowerShell) 분리. 공통 close/error 핸들러는 `_bindProcessHandlers`로 추출. `_getMacRate`(WPM 기준 180×speed) / `_getWindowsRate`(-10~10 SAPI) 분리.
|
||
- 파급: 음성 대화뿐 아니라 TTS를 쓰는 모든 경로가 Mac에서 벙어리였음. 이제 `/usr/bin/say`로 한/영/자동 판별 voice.
|
||
|
||
**Bug 11 — `finishListening` 오디오 버퍼 스냅샷 순서 (진짜 root cause, OS 무관)**
|
||
- 증상: 녹음 후 "생각 중" 진입은커녕 조용히 listening으로 복귀, STT 한 번도 안 탐. 진단 로그로 추적 시 `finishListening called: bufferCount=311, totalBytes=597120` 직후 1ms 뒤 `audio too short: 0 bytes`로 찍힘.
|
||
- 원인: `_stopListening()` 내부에 `this._audioBuffers = []` 리셋이 있는데 `finishListening`이 concat **이후**에 stop을 부르는 게 아니라 **이전**에 불러서, Buffer.concat 시점엔 이미 빈 배열. 19초짜리 오디오가 매번 날아감. Windows에서도 동일 버그였을 가능성 높음.
|
||
- Fix (`VoiceConversationService.ts:193`): `_stopListening()` **전**에 `const audioBuffer = Buffer.concat(this._audioBuffers)` 스냅샷 저장. `this._audioBuffers = []` 재설정도 제거(이미 stop에서 하므로 중복).
|
||
|
||
**Bug 12 — Whisper 모델 사전 로드 누락**
|
||
- 증상: Bug 11 fix 후 `finishListening`이 STT로 진입은 하지만 "모델 로딩 중, 오디오 버퍼에 적재"에서 영원히 pending. state가 'thinking'에 고정.
|
||
- 원인: `LocalSTTService.initialize()`가 모델을 로드하는데, MeetingMode/CaptionService는 startRecording 시 이걸 명시적으로 호출하지만 VoiceConversationService는 호출 안 함. 첫 transcribe가 내부 pending 큐에 쌓이지만 아무도 로드를 트리거하지 않아 deadlock.
|
||
- Fix (`VoiceConversationService.ts:84`): `startSession`에서 `void getLocalSTTService().initialize().catch(...)` fire-and-forget. initialize는 이미 Ready 같은 모델이면 즉시 return하므로 idempotent.
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` EXIT=0
|
||
- Mac `/usr/bin/say -r 180` 직접 호출 시 스피커로 소리 확인 (사용자 증언 "오 들린다!")
|
||
- 사용자 재시연에서 Mic 클릭 → 말함 → ⏹ Stop → 전사 → LLM 스트리밍 → TTS 소리 → 자동 listening 복귀 연속 대화 성공
|
||
|
||
**Bug 13 (신규 발견, 미수정) — 빈 STT 결과 시 사용자 피드백 부재**
|
||
- 증상: 재시연 중 사용자가 ⏹ Stop 버튼 눌러도 "반응 없음"으로 느낌. 로그 추적 결과 finishListening → transcribe → `faster_whisper VAD filter removed 00:22.740 of audio` → 전사 `''` 빈 텍스트 → 조용히 listening 복귀 (에러 없음). 사용자 관점에선 버튼 먹통.
|
||
- 원인 조합:
|
||
1. Mac 시스템 마이크 입력 볼륨 너무 낮거나 `coreaudio input clipped` 왜곡으로 VAD가 전체 오디오를 무음 판정
|
||
2. `VoiceConversationService.finishListening`에서 빈 STT 결과를 조용히 삼키고 listening으로 복귀 — **사용자 피드백 없음**
|
||
- Fix 방향 (다음 세션): `_emitError('stt', 'No speech detected...')` 토스트 + 몰입 패널 내 "음성이 감지되지 않았습니다. 마이크 볼륨을 확인하세요." 힌트. 근본적으로는 UX 개선(몰입 패널)과 함께 처리.
|
||
- 워크어라운드: Mac 시스템 설정 → 사운드 → 입력 볼륨 조정 (중간 이상).
|
||
|
||
**UX 개선 설계 (구현 미완, 다음 세션 이월)**
|
||
사용자 피드백: 녹음 상태 불명확 / 텍스트 입력 방치 / 종료·응답 완료 피드백 부재. 인터뷰로 확정한 결정:
|
||
1. **녹음 모드 = 풀 몰입 계측기 패널** — 메시지 영역을 REC LED + 9바 phosphor waveform + 0:04 타이머 + "SPEAK NOW · PRESS ⏹ TO SEND" 힌트로 완전 대체 (listening 상태에서만)
|
||
2. **thinking/speaking 상태 = 메시지 리스트로 복귀** — 대화 맥락 유지. typing indicator 버블 추가. TextField는 disabled "응답 대기 중…"
|
||
3. **연속 대화** — 응답 후 자동 listening 복귀 (기존 코드 동작 유지)
|
||
4. **사운드 4개 전부** — recording-start/recording-stop/chime(recording-stop 재사용)/error
|
||
5. **Audio level IPC 신규 채널** — `VOICE_CONVERSATION.AUDIO_LEVEL`로 AudioCaptureService 'audio-level' 이벤트 listening 상태에서만 forward
|
||
6. **SoundEffectService SoundName에 'chime' 추가** — 파일은 `recording-stop.wav` 재사용 (cancel=error 패턴과 동일). 나중에 전용 WAV로 교체 예정
|
||
|
||
설계 ASCII 와이어프레임 + 작업 리스트는 이번 세션 대화 말미에 존재. 다음 세션 우선 구현 대상.
|
||
|
||
**남은 미해결 이슈**
|
||
- U1 — Realtime TIMED_OUT (블로커 아님)
|
||
- U3 — Refresh token 간헐적 소진 (블로커 아님)
|
||
- U4 — `package-lock.json` optional dep 정리
|
||
- **Phase 3.2** — PREMIUM_LLM quota 게이트 (Anthropic/OpenAI key 주입 대기, 빅뱅 성공 기준 8번 중 마지막 1개)
|
||
- **Voice Conversation UX** — 위 설계 대로 다음 세션 구현
|
||
- **AudioCaptureService.getDevices()** — Mac에서 PowerShell 호출 실패 경고 로그 (1개 device fallback 동작, 블로커 아님)
|
||
|
||
---
|
||
|
||
### SaaS [12] /simplify 패스 — IME 가드 helper 추출 + 8곳 일괄 적용 (Phase 5 Part 5, 2026-04-11)
|
||
|
||
> **Bug 10의 한글 IME Enter 중복 가드를 `isImeComposingEvent` helper로 추출**. MeetingModePage 기존 인라인 가드 교체 + 7개 사이트 신규 적용 → 총 8곳 통합. Phase 3.3 훅 8곳은 품질 리뷰 결과 이미 최적 상태로 판정(수정 없음).
|
||
|
||
**신규 helper** — `apps/desktop/src/renderer/utils/keyboard.ts`
|
||
```ts
|
||
export function isImeComposingEvent(e: KeyboardEvent): boolean {
|
||
return e.nativeEvent.isComposing || e.keyCode === 229
|
||
}
|
||
```
|
||
JSDoc에 Bug 10 원리(Chromium이 IME 조합 중 Enter를 2번 발화) + 권장 사용 패턴 포함.
|
||
|
||
**적용 8곳 (`isImeComposingEvent(e) return` 1줄 삽입)**
|
||
| 파일 | 라인 | 용도 | 변경 전 |
|
||
|---|---|---|---|
|
||
| `pages/MeetingModePage.tsx` | 164 | 회의 메모 입력 | 인라인 가드 4줄 (Bug 10 fix) |
|
||
| `pages/KnowledgeBasePage.tsx` | 84 | RAG 쿼리 | 가드 없음 |
|
||
| `pages/VoiceConversationPage.tsx` | 113 | 텍스트 채팅 | 가드 없음 |
|
||
| `components/meeting/MeetingChatPanel.tsx` | 111 | 미팅 챗 | 가드 없음 |
|
||
| `components/meeting/EditableSegment.tsx` | 70 | 전사 편집 (Enter+Esc) | 가드 없음 |
|
||
| `components/meeting/MeetingDetailTabs.tsx` | 242 | 미팅 타이틀 (인라인 arrow) | 인라인 `onKeyDown={(e)=>e.key==='Enter'&&...}` 1줄 → 블록 확장 |
|
||
| `components/shared/HistoryEntryCard.tsx` | 64 | 태그 추가 (Enter+Esc) | 가드 없음 |
|
||
| `pages/CommandsPage.tsx` | 336 | 키워드 추가 (Enter+Esc) | 가드 없음 |
|
||
|
||
**Phase 3.3 훅 품질 리뷰 결론 — 수정 없음**
|
||
- `HistoryService.create/updateTitle`, `DictionaryService.add/update`, `MeetingModeService.startRecording/addMemo/_runPostProcessing/generateDocument`, `MeetingSummaryService.generateAndSave` 8곳 전부 `void getCloudSyncService().pushOne(table, id)` + `// Phase 3.3: fire-and-forget` 주석 일관.
|
||
- `CloudSyncService.pushOne` 내부가 try-catch로 warn만 찍고 삼킴 → 호출자는 무조건 안전, 로컬 write 차단 금지 철학 준수.
|
||
- helper 추출 후보 없음(이미 1줄). 일관성 OK, DRY OK, 에러 흡수 OK → **현 상태가 최적, 리팩터 불필요**.
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` ✅ EXIT=0
|
||
- Vite HMR 자동 반영(기존 dev 프로세스 살아있음) — 별도 재기동 없이 적용
|
||
- 대표 2곳 한글 Enter 실측 (VoiceConversationPage 채팅 / MeetingDetailTabs 타이틀) — 사용자 수동 검증 대기
|
||
|
||
**U5 해소, 남은 미해결 이슈**
|
||
- U1 — Realtime TIMED_OUT (블로커 아님)
|
||
- U3 — Refresh token 간헐적 소진 (블로커 아님)
|
||
- U4 — `package-lock.json` optional dep 정리
|
||
- Phase 3.2 — PREMIUM_LLM quota 게이트 (Anthropic/OpenAI key 주입 대기)
|
||
|
||
---
|
||
|
||
### SaaS [11] Meeting pre-push 직접 실증 + IME Enter 중복 addMemo 픽스 (Phase 5 Part 4, 2026-04-11)
|
||
|
||
> **U2 해소.** Meeting 모드 녹음을 직접 돌려 Fix 1(startRecording pre-push) + addMemo hook을 실시간 로그로 증명. 부수적으로 발견된 한글 IME Enter 중복 addMemo 버그(Bug 10)를 `isComposing` 가드로 픽스.
|
||
|
||
**직접 실증 타임라인 (session 78421e75, 21:51:24~21:51:50)**
|
||
```
|
||
21:51:24.937 pushOne meetings/78421e75 ok ← Fix 1 pre-push (813ms after startRecording)
|
||
21:51:30.499 pushOne meeting_memos/4ed7e59a ok ← addMemo #1 "메모가"
|
||
21:51:32.202 pushOne meeting_memos/de011c15 ok ← addMemo #2 "두번씩"
|
||
21:51:35.893 pushOne meeting_memos/3184fe5e ok ← addMemo #3 "입력되는걸 막아야합니다."
|
||
21:51:38.263 pushOne meeting_memos/00f041ef ok ← addMemo #4 "잘되는지 봅시다."
|
||
21:51:40.740 pushOne meeting_memos/0808c4d2 ok ← addMemo #5 "잘되는 것 같네요."
|
||
21:51:50.681 pushOne meetings/78421e75 ok ← _runPostProcessing hook
|
||
21:51:50.709 pushOne history/70388090 ok ← CaptionService → HistoryService.create
|
||
```
|
||
- **meetings/78421e75가 녹음 시작 + 종료 두 번 push** → Fix 1 완전 증명 (부모 row가 메모 push 시점에 이미 Supabase에 존재해서 자식 RLS/FK 통과)
|
||
- **메모 5건 전부 100ms 내 1:1 push** → addMemo hook 정상, RLS 에러 0
|
||
- **history push 출처 확인**: `CaptionService.ts:459`에서 회의 전사가 완료되면 전체 텍스트를 HistoryService에도 저장하는 기존 경로. Phase 3.3 훅이 `HistoryService.create()` 끝에 걸려 있어 자동으로 따라옴. 의도대로 동작.
|
||
|
||
**발견+픽스한 버그 — Bug 10: 한글 IME Enter 중복 addMemo**
|
||
- 증상: 첫 번째 시연 녹음(bd7f2367)에서 "메모 기능이 잘 작동하는지 봅니다"가 **2ms 간격으로 2번** 저장됨 (UUID c7f0db45 + a6e442cc, 로그 21:47:57.410/.412). 다른 메모들은 1회.
|
||
- 원인: 한글 IME 조합 중 Enter 키는 Chromium이 `keydown`을 두 번 발화 — (1) IME 조합 확정용(`isComposing=true`, keyCode=229) + (2) 실제 Enter 액션. 기존 `handleMemoKeyDown`(MeetingModePage.tsx:164-174)은 `e.key === 'Enter' && !e.shiftKey`만 검사하고 `isComposing`을 무시해 두 이벤트 모두에서 `handleAddMemo()` 발화 → IPC 2회 → `MeetingModeService.addMemo()` 2회 → DB insert 2건 → `pushOne meeting_memos` 2회. 중복이 다른 메모에서 안 난 이유는 해당 메모들은 IME 조합이 이미 확정된 상태(공백/한자 변환 등)에서 Enter를 쳤기 때문.
|
||
- Fix: `MeetingModePage.tsx:168`에 이중 가드 추가. `if (e.nativeEvent.isComposing || e.keyCode === 229) return` — `isComposing`은 모던 표준, `keyCode === 229`는 Chromium 엣지 케이스(조합 확정 시점에 isComposing이 false로 내려가면서 keyCode만 229로 주는 경우) 호환.
|
||
- 검증: 두 번째 녹음(78421e75)에서 한글 메모 5건 연속 입력 → **전원 1:1 매핑**, 중복 0. Vite HMR로 즉시 반영, 별도 재기동 없이 fix 유효.
|
||
|
||
**잠재 IME 버그 — 다른 Enter 핸들러 7곳 (다음 `/simplify` 때 일괄 처리 예정)**
|
||
| 파일 | 라인 | 용도 | 한글 위험도 |
|
||
|---|---|---|---|
|
||
| `KnowledgeBasePage.tsx` | 85 | RAG 쿼리 | 높음 |
|
||
| `VoiceConversationPage.tsx` | 114 | 텍스트 채팅 | 높음 |
|
||
| `components/meeting/MeetingChatPanel.tsx` | 113 | 미팅 챗 | 높음 |
|
||
| `components/meeting/EditableSegment.tsx` | 72 | 전사 세그먼트 편집 | 높음 |
|
||
| `components/meeting/MeetingDetailTabs.tsx` | 242 | 미팅 타이틀 | 중간 |
|
||
| `components/shared/HistoryEntryCard.tsx` | 65 | 태그 추가 | 중간 |
|
||
| `CommandsPage.tsx` | 337 | 키워드 추가 | 중간 |
|
||
|
||
(LicenseModal.tsx:196은 ASCII 전용이라 제외)
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` ✅ (EXIT=0)
|
||
- Meeting 모드 녹음 1회차(bd7f2367): Fix 1 + addMemo hook 증명, IME 중복 1건 발견
|
||
- Meeting 모드 녹음 2회차(78421e75): IME fix 적용 후 메모 5건 전부 1:1 ✅
|
||
- Realtime 채널 상태: 여전히 U1 TIMED_OUT (블로커 아님, Phase 3.3 auto push가 대체 경로)
|
||
|
||
**빅뱅 성공 기준 상태 — 이번 세션 갱신 없음** (8개 중 7개 완료, Phase 3.2 PREMIUM_LLM만 잔존)
|
||
|
||
**다음 세션 권장 액션**
|
||
- `/simplify` 실행 — Phase 3.3 + Bug 10 fix 코드 품질 리뷰 + 위 7개 IME Enter 핸들러 일괄 가드 추가
|
||
- Phase 3.2 PREMIUM_LLM Edge Function 경로 (Anthropic/OpenAI key 주입 필요, 사용자 의사결정 대기)
|
||
- U1 Realtime TIMED_OUT 원인 조사 (선택)
|
||
- `package-lock.json` optional dep 정리 (Phase 5 Part 2부터 이월)
|
||
|
||
---
|
||
|
||
### SaaS [10] Phase 3.3 Auto push on write + 웹 transcript fallback + Dashboard Link 픽스 (Phase 5 Part 3, 2026-04-11)
|
||
|
||
> **수동 Push 버튼이 사라졌다.** 로컬 SQLite write가 일어나는 모든 경로(8곳)에서 `CloudSyncService.pushOne`이 fire-and-forget으로 즉시 실행되어 Supabase에 반영. 웹 Dashboard 새로고침 한 번이면 방금 녹음한 미팅/메모/전사까지 전부 보인다. 이번 세션 한 번에 end-to-end 실증까지 통과.
|
||
|
||
**실증 시나리오**
|
||
1. 데스크톱 dev 재기동 → `Restored session for user: yunchan8804@gmail.com` (cloud-sync.token 자동 복원) → 사용자 DB 재오픈
|
||
2. 신규 Meeting 모드 녹음 1건 (약 33초, 중간에 메모 2건 추가)
|
||
3. 로그에 즉시 나타난 push 이벤트:
|
||
- `pushOne history/4c860fe2-... ok`
|
||
- `pushOne meetings/6bb51eca-... ok`
|
||
4. 재기동 시 pushAll 캐치업으로 직전 실패한 메모 2건이 자동 재시도되어 성공 (`Sync complete: pushed=4 errors=0`)
|
||
5. 웹 `http://localhost:3000/meetings/6bb51eca-...` 새로고침 → **전사 + 메모 둘 다 렌더 확인** (사용자 "잘 전사와 메모가 올라왔어")
|
||
|
||
**구현 — Phase 3.3 Auto push on write**
|
||
|
||
1. `CloudSyncService.pushOne(table, id)` 신규 — 제네릭 단건 push. 로그인 상태 아니면 silent noop(로컬 write 절대 차단 금지), 실패 시 `logger.warn`만(throw 금지). `pushAll`과 공통 매퍼 공유 (`_mapHistoryPayload` / `_mapDictionaryPayload` / `_mapMeetingPayload` / `_mapMemoPayload` / `_mapDocumentPayload` 5개 private 메서드 추출, DRY).
|
||
2. Hook 삽입 위치 8곳 (전부 `void getCloudSyncService().pushOne(...)`, fire-and-forget):
|
||
- `HistoryService.create()` 끝 → history
|
||
- `HistoryService.generateTitle()` DB update 직후 → history (LLM 자동 타이틀)
|
||
- `MeetingSummaryService.generateSummary()` summaryText 저장 후 → history ("updatePolishedText" 상응)
|
||
- `DictionaryService.add()` 끝 → dictionary
|
||
- `DictionaryService.update()` 끝 → dictionary
|
||
- `MeetingModeService.startRecording()` 끝 → meetings (**부모 pre-push**, Fix 1 참조)
|
||
- `MeetingModeService.addMemo()` 끝 → meeting_memos
|
||
- `MeetingModeService._runPostProcessing()` 완료 상태 저장 직후 → meetings (raw_transcript 포함)
|
||
- `MeetingModeService.generateDocument()` DB insert 직후 → meeting_documents
|
||
|
||
**발견+픽스한 구조 버그 3건 (이번 세션)**
|
||
|
||
#### Bug 7: meeting_memos RLS violation — 부모 meeting row 미존재
|
||
- 증상: 녹음 중 메모 추가 → `pushOne meeting_memos/4a733d76-... failed: new row violates row-level security policy for table "meeting_memos"` (21:19:14에 2건 실패)
|
||
- 원인: RLS 정책이 `meeting_id`에 해당하는 부모 `meetings` row의 존재/소유권을 요구하는 것으로 추정 (정확한 정책 확인은 안 함). 기존 구현에서는 `_runPostProcessing()`에서 "completed" 상태 저장 시점에만 meeting이 push되므로, 녹음 중 addMemo → pushOne 시점에는 **부모 row가 Supabase에 아직 없음** → 자식 insert RLS 거부.
|
||
- Fix 1: `MeetingModeService.startRecording()` 끝에 `void getCloudSyncService().pushOne('meetings', sessionId)` 추가 — 녹음 시작 즉시 부모 row(status='recording', raw_transcript=null)를 Supabase에 upsert. 이후 addMemo/generateDocument의 자식 push가 FK/RLS를 통과.
|
||
- 검증: 재기동 직후 pushAll 캐치업이 이전 세션의 실패 메모 2건을 재시도 → `Sync complete: pushed=4 errors=0` (meeting 1 + memos 2 + history 1 추정) → 웹에서 메모 렌더 확인.
|
||
- 부수 효과(의도적): fire-and-forget 실패는 pushAll 캐치업으로 자동 복구. 실패가 영구 손실로 이어지지 않음.
|
||
|
||
#### Bug 8: 웹 meeting detail 페이지가 transcripts 테이블만 읽음 (meetings.raw_transcript 무시)
|
||
- 증상: 녹음 종료 후 웹 `/meetings/[id]` 상세 페이지의 TRANSCRIPT 섹션이 빈 상태로 표시.
|
||
- 원인: `apps/web/src/app/(app)/meetings/[id]/page.tsx`가 `supabase.from('transcripts').select(...).eq('meeting_id', id)`만 읽고 `LiveTranscriptList`에 넘김. 데스크톱은 CaptionService 세그먼트를 인메모리에 버퍼링했다가 `meeting_sessions.rawTranscript` 단일 텍스트 필드에만 저장 — `transcripts` 테이블에는 **아무것도 쓰지 않음**. Realtime 스트림 설계(웹 실시간 캡션 뷰어용)와 데스크톱 오프라인 전사 플로우의 접점이 없었음.
|
||
- Fix 2 (웹 fallback): `transcripts`가 비어있고 미팅 상태가 `recording`이 아닐 때 `meeting.edited_transcript ?? meeting.raw_transcript`를 `whiteSpace: 'pre-wrap'`의 Box로 렌더. 둘 다 없으면 "전사가 없습니다." placeholder. recording 상태거나 `transcripts` 행이 있으면 기존 `LiveTranscriptList` 유지(향후 실시간 캡션 뷰어 경로 보존).
|
||
- 왜 데스크톱을 고치지 않았나: MVP 스코프 고려. 세그먼트 per-row push는 쓰기 빈도가 폭증하고 RLS 정책을 별도로 다듬어야 함. Phase 3.3의 목표는 "완료된 미팅이 웹에서 읽혀야 한다"이고, 그건 meetings.raw_transcript 경로 하나로 충분. 실시간 캡션 동기화가 정식 기능으로 들어올 때 desktop → transcripts 쓰기를 추가.
|
||
|
||
#### Bug 9: Dashboard RECENT MEETINGS 카드 클릭 무반응 (U2)
|
||
- 증상: 지난 세션 말미 사용자 피드백 "웹에서 미팅 아이템이 들어가지질 않는다" 재확인 → Dashboard 카드 클릭 시 `/meetings/[id]` 이동 안 됨.
|
||
- 원인: `apps/web/src/app/(app)/dashboard/page.tsx:156`의 `MetalCard`가 `<Link>`로 감싸져 있지 않아 클릭 인터랙션 자체가 없었음.
|
||
- Fix 3: `next/link`의 `Link`로 `MetalCard`를 감싸고 `href={\`/meetings/${meeting.id}\`}`, `cursor: 'pointer'` + `&:hover { transform: translateY(-1px) }` hover 효과 추가, `textDecoration: 'none'`으로 링크 기본 스타일 억제.
|
||
- 검증: 새 기동 후 실증 녹음 전에 이미 웹에서 `GET /meetings/cd4df4f1-... 200` 확인됨.
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- web `tsc --noEmit` ✅
|
||
- dev 런타임 재기동 ✅ (`Restored session` → 직전 `Sync complete: pushed=4 errors=0` 캐치업 → Main window)
|
||
- 녹음 1건 fire-and-forget push ✅ (`pushOne history/... ok` + `pushOne meetings/... ok`, 21:19:38)
|
||
- **웹 Dashboard 새 미팅 렌더 ✅** (카드 + 상세 페이지 + 전사 + 메모 전부)
|
||
- **사용자 확인: "잘 전사와 메모가 올라왔어"**
|
||
- Realtime 채널 TIMED_OUT ❌ (알려진 U1, 블로커 아님 — Phase 3.3 auto push가 대체 일관성 경로)
|
||
|
||
**다음 세션 권장 액션**
|
||
- Fix 1의 재현 실증 (녹음 → 중간 메모 추가 시점마다 `pushOne meeting_memos/... ok` 실시간 발화 — 이번 세션은 pushAll 캐치업으로 통과해서 pre-push 효과를 직접 본 건 아님)
|
||
- U1 Realtime TIMED_OUT 원인 조사 (선택)
|
||
- Phase 3.2 PREMIUM_LLM Edge Function 경로 (Anthropic/OpenAI key 주입 필요)
|
||
- 24 → 25+ commits → git push 컨펌 (사용자 결정 시점)
|
||
- `package-lock.json` 정리 (`rollup-win32-x64-msvc` optional dep 이슈)
|
||
|
||
---
|
||
|
||
### SaaS [9] 로컬 ID UUID 통일 + Realtime publication 픽스 + 실증 A~E 전원 통과 (Phase 5 Part 2, 2026-04-11)
|
||
|
||
> **3 클라이언트 데이터 일원화 end-to-end 실증 완료.** Phase 5 Part 1에서 못 잡은 3개 구조 버그를 한 세션에 픽스하고 실증 A/B/C/D/E 전원 통과.
|
||
> 로컬→클라우드 push가 **처음으로 성공** (pushed=1, pushed=5), 데스크톱 녹음 데이터가 **웹 Dashboard에 그대로 반영**되는 것을 스크린샷 캡처까지 확인.
|
||
|
||
**실증 결과 (18:36~19:11)**
|
||
|
||
| # | 실증 | 결과 | 증거 |
|
||
|---|---|---|---|
|
||
| A | 세션 자동 복원 + RLS 픽스 검증 | ✅ | `Restored session for user: yunchan8804@gmail.com` + `Pull complete applied=0 errors=0` |
|
||
| B | 사용자별 DB + push 경로 (history 단건) | ✅ | `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` + `Sync complete: pushed=1 errors=0` |
|
||
| C | 로그아웃 → 로컬 복귀 | ✅ | `Realtime 종료` → `Database closed (7da3dd02-...)` → `Reverted to local DB: users/_local/d3ro.db` → `continuing in local mode` |
|
||
| D | 재로그인 데이터 복원 | ✅ | 19:09:00 재로그인 → 같은 uuid DB가 `created=false`로 재오픈 (파일 보존 확인) |
|
||
| E | **웹 크로스 디바이스 (Meeting 경로)** | ✅ | Meeting Mode 녹음 → `Sync complete: pushed=5 errors=0` → 웹 `localhost:3000/dashboard` 새로고침 → RECENT MEETINGS에 "(제목 없음) 2026. 4. 11. 오후 7:09:21" 표시, "총 회의 1 / 이번 주 1 / ACTIVITY TREND 04/11 스파이크" 전부 일치 |
|
||
|
||
**실증 E 세부 플로우** (end-to-end 전체 데이터 경로 검증)
|
||
```
|
||
데스크톱 Meeting Mode (19:09:53 History created: 093d9d5d-...)
|
||
→ MeetingModeService가 meeting_sessions / meeting_memos / meeting_documents 로컬 write
|
||
→ users/7da3dd02-.../d3ro.db (SQLite, UUID PK)
|
||
→ 수동 Push 버튼 (19:11:21)
|
||
→ CloudSyncService.pushAll (history, dictionary, meetings, meeting_memos, meeting_documents 5개 upsert)
|
||
→ "Sync complete: pushed=5 errors=0"
|
||
→ Supabase `meetings` 테이블 upsert (RLS user_id=auth.uid() 통과)
|
||
→ 웹 Next.js dashboard getSupabaseServerClient().from('meetings').select(...)
|
||
→ 브라우저 렌더: "(제목 없음) 2026. 4. 11. 오후 7:09:21"
|
||
```
|
||
|
||
**발견+픽스한 구조 버그 3건**
|
||
|
||
#### Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치 (실증 B 첫 시도에서 발견)
|
||
- 증상: `Push history failed: invalid input syntax for type uuid: "fvy6bIzrEtZ0vo96Dd-WZ"` → `Sync complete: pushed=0 errors=1`
|
||
- 원인: `apps/desktop/src/main/db/schema.ts`의 모든 PK는 `text('id').primaryKey()` + 서비스 코드는 `nanoid()` 생성 (예: `fvy6bIzr...`, 21자). 반면 Supabase `initial_schema.sql`은 `id uuid PRIMARY KEY DEFAULT gen_random_uuid()`. Phase V2-4 설계 단계에서 "V2-4 MVP는 uuid로 새로 발급, 매핑 테이블 필요"라고 적혀 있었으나 실제 구현에선 매핑이 없어 push 경로 전체가 깨져 있었음. 결과적으로 **빅뱅 사이클 내내 push가 한 번도 성공한 적 없음** (지난 세션 `pushed=0`은 로컬 데이터 0건이라 에러가 안 난 거지 버그가 없어서가 아님).
|
||
- 사용자 결정: **로컬을 UUID로 통일** (방향 B). 이유 — 3 클라이언트(desktop/web/mobile) id 스펙 일관 + PostgreSQL UUID PK 관용 유지 + `crypto.randomUUID()` 표준 API + `nanoid` 외부 의존성 자체 제거. "땜질 아닌 근본 해결" 사용자 요청.
|
||
- 픽스:
|
||
- 14개 서비스 파일에서 `import { nanoid } from 'nanoid'` 제거 + `nanoid()` → `crypto.randomUUID()` 일괄 교체 (총 20곳): HistoryService / MeetingModeService / MemoService / DictionaryService / CaptionService / VoiceConversationService / VoiceCommandService / VoiceActionService / FileTranscriptionService / CustomInstructionService / MeetingDocTemplateService / DictationTemplateService / RAGService / ChainService
|
||
- `apps/desktop/package.json` dependencies에서 `nanoid: ^5.1.7` 제거
|
||
- `apps/desktop/electron.vite.config.ts` externalizeDepsPlugin exclude에서 `'nanoid'` 제거
|
||
- 기존 로컬 DB 2개(`users/_local`, `users/7da3dd02-...`) drop — dev 초기 nanoid row만 있었고 의미 있는 데이터 0건이라 cascade FK 마이그레이션 대신 재생성 선택
|
||
- drizzle schema(`text('id').primaryKey()`)는 건드릴 필요 없음 — SQLite text PK는 UUID 36자 문자열을 그대로 저장
|
||
- 검증: 재기동 후 신규 녹음 → `HistoryService created 56a767ac-0fbc-45c8-98f2-2e2d4afa70f9` (UUID v4) → Push → `pushed=1 errors=0` **최초 성공**
|
||
|
||
#### Bug 5: Supabase supabase_realtime publication 누락
|
||
- 증상: 로그인 직후 `Realtime 채널 상태: TIMED_OUT` (실증 A에서 재현, 재기동 후에도 재현)
|
||
- 원인: `20260409000001_initial_schema.sql:134`이 `ALTER PUBLICATION supabase_realtime ADD TABLE public.transcripts` 한 줄만 실행. 데스크톱 `CloudSyncService.startRealtime()`이 실제 구독하는 `meetings` / `history` / `dictionary`는 publication에 **포함되지 않아** postgres_changes 스트림이 물리적으로 흐를 수 없었음.
|
||
- 픽스: `20260411000002_realtime_publication.sql` 신규 — `DO $$ ... LOOP ... ALTER PUBLICATION supabase_realtime ADD TABLE public.%I ... END $$;` 패턴으로 `meetings` / `meeting_memos` / `meeting_documents` / `history` / `dictionary` 5개 테이블을 pg_publication_tables 카탈로그 체크 후 조건부 추가. `supabase db push` 적용 완료.
|
||
|
||
#### Bug 6: persistSession:false 에서 realtime.setAuth 자동 전파 안 됨 (가설 — 부분 검증)
|
||
- 증상: Bug 5 migration 적용 후에도 TIMED_OUT 재발.
|
||
- 원인 (가설): `createClient(url, anonKey, { auth: { persistSession: false, ... } })` 구성에서 `setSession()` 호출이 onAuthStateChange를 통한 `realtime.setAuth()` 자동 전파 경로를 깨뜨리는 것으로 추정. 즉 realtime WebSocket이 anon key JWT로 join을 시도하고 RLS 필터(`user_id=eq.${userId}`)를 통과 못해 TIMED_OUT.
|
||
- 픽스: `CloudSyncService.startRealtime()`에 `this._client.realtime.setAuth(this._session.access_token)`을 채널 구성 **이전**에 명시 호출 (try/catch로 안전하게). 에러 시 warn만.
|
||
- 현재 상태: ⚠️ **여전히 TIMED_OUT 재현**. Bug 5 migration은 확실히 적용됐고 setAuth 명시 호출도 들어갔으나 해결 안 됨. 후속 조사 필요 (task 8로 분리). 가능 가설 — (a) Supabase 프로젝트 Realtime 서비스 자체 disabled, (b) setAuth 타이밍 문제(WebSocket 이미 connect된 상태에서 setAuth 무효), (c) RLS 정책이 `user_id = auth.uid()` 외 다른 조건을 요구, (d) realtime 전용 RLS policy(`realtime.messages` 구독 권한)가 별도로 필요.
|
||
- **블로커 여부**: 아님. 주기적 pull + Phase 3.3 Auto push 경로로 최종 일관성 유지. Realtime은 "웹→데스크톱 5초 이내 반영" 편의 기능.
|
||
|
||
**픽스로 해결된 부수 이슈**
|
||
- `cloud-sync.token` 기반 자동 세션 복원 경로 ✅ (지난 세션 `Invalid Refresh Token: Already Used`는 당시 토큰이 소진된 일회성 이슈, 새 로그인 후 재시작하니 `Restored session for user: yunchan8804@gmail.com` 성공)
|
||
- `users/${uuid}/d3ro.db` 파일이 로그아웃 시 close만 되고 보존되어 재로그인 시 `created=false`로 복원되는 경로 ✅
|
||
- LicenseService 로컬 모드 리셋 경로 ✅ (로그아웃 후 익명 로컬 모드 정상 동작)
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- dev 런타임 재기동 ✅ (`Main window shown` → `Restored session` → `DB opened for 7da3dd02` → `Initial sync pushed=0 pulled=0`)
|
||
- 핫키 녹음 → UUID row 생성 → Push → `pushed=1 errors=0` ✅ (**최초 성공**)
|
||
- 로그아웃 → Realtime 종료 → `_local` DB 복귀 ✅
|
||
|
||
**이 섹션에서 의도적으로 뺀 것**
|
||
- package-lock.json 정리: `npm install`이 기존부터 `rollup-win32-x64-msvc` optional dep로 실패. 당장 nanoid import는 이미 제거되어 런타임 영향 없음. lockfile은 후속 세션에서 `--omit=optional`로 정리.
|
||
- Realtime 근본 원인 — task 8 별건으로 분리
|
||
- 실증 E(웹 크로스 디바이스) — Phase 3.3 이후 수행
|
||
|
||
### SaaS [8] OAuth 로그인 완주 + RLS 재귀 픽스 (Phase 5 Part 1, 2026-04-11)
|
||
|
||
> **로그인 성공!** 빅뱅 Phase 5 실증 시작. Google OAuth 토큰 교환 성공, 사용자별 DB 생성 확인.
|
||
> 그러나 첫 pull에서 Supabase RLS 무한 재귀 + Realtime TIMED_OUT 발생 → 같은 세션에서 즉시 픽스.
|
||
|
||
**성공 이벤트 로그 (11:46:52 ~ 55)**
|
||
```
|
||
[Opening OAuth URL in external browser: google]
|
||
[deep-link] Implicit flow tokens detected, calling handleAuthTokens()
|
||
[CloudSyncService] Signed in (implicit): yunchan8804@gmail.com
|
||
[auth:signin] DB opened for 7da3dd02-9f2f-4ee9-a9b3-1c2c24875a93:
|
||
.../users/7da3dd02-.../d3ro.db (created=true)
|
||
[auth:signin] Initial sync starting — push then pull
|
||
[deep-link] handleAuthTokens completed successfully
|
||
[auth:signin] Initial push done: pushed=0, errors=0
|
||
```
|
||
|
||
**발견된 버그 + 픽스**
|
||
|
||
#### Bug 1: Supabase implicit flow 응답을 데스크톱이 무시
|
||
- 증상: 로그인 4회 시도, 매번 `auth-callback deep link had no 'code' parameter`
|
||
- 원인: supabase-js가 `persistSession: false`일 때 PKCE code_verifier 저장소가 없어 **implicit flow로 fallback**. Supabase가 fragment(`#access_token=...&refresh_token=...`)로 토큰 직접 전달. 우리 `handleDeepLink`는 `?code=...`(PKCE)만 파싱.
|
||
- 픽스:
|
||
- `apps/desktop/src/main/index.ts`: `handleDeepLink`가 query(`?code=`)와 fragment(`#access_token=`) 둘 다 파싱. fragment에 토큰 있으면 `CloudSyncService.handleAuthTokens()` 호출. 에러 파라미터(`error=`, `error_description=`) 상세 로깅.
|
||
- `CloudSyncService.handleAuthTokens({accessToken, refreshToken})` 신규 — `client.auth.setSession()`으로 세션 복원 후 기존 `_onAuthenticated(session, {reason: 'signin'})` SSOT 진입점으로 수렴.
|
||
- 토큰 로깅은 host+path만, 토큰 값은 로그에 안 찍히도록 수정 (`query=yes/no, fragment=yes/no`)
|
||
|
||
#### Bug 2: Supabase RLS 무한 재귀
|
||
- 증상: `Pull meetings failed: infinite recursion detected in policy for relation "team_members"` × 3 테이블 + `Realtime 채널 상태: TIMED_OUT`
|
||
- 원인 (`20260409000002_rls_policies.sql`): `team_members` SELECT 정책이 **자기 테이블을 재조회**:
|
||
```sql
|
||
CREATE POLICY "team_members_read_same_team" ON team_members
|
||
FOR SELECT USING (
|
||
team_id IN (SELECT team_id FROM team_members WHERE user_id = auth.uid())
|
||
);
|
||
```
|
||
이 정책이 Postgres RLS 엔진에서 무한 재귀 탐지로 에러. `meetings` / `meeting_memos` / `meeting_documents` / `team_invites` 정책이 모두 `team_members` 서브쿼리를 경유해서 **전부 같이 터짐**. Realtime 구독도 같은 이유로 TIMED_OUT.
|
||
- 픽스 (`server/supabase/migrations/20260411000001_fix_team_members_rls_recursion.sql`):
|
||
- `SECURITY DEFINER` 함수 2개 신규 — Supabase 공식 권장 패턴으로 재귀 끊기
|
||
- `public.user_team_ids(uid uuid) RETURNS SETOF uuid`
|
||
- `public.user_admin_team_ids(uid uuid) RETURNS SETOF uuid`
|
||
- `search_path = public, pg_temp` 고정 + `REVOKE ALL` 후 `authenticated, service_role`에만 `GRANT EXECUTE`
|
||
- 기존 정책 재작성: `team_id IN (SELECT ... FROM team_members)` → `team_id IN (SELECT public.user_team_ids(auth.uid()))`
|
||
- 영향 정책: `team_members` × 4개, `meetings` × 2개, `meeting_memos` × 1개, `meeting_documents` × 2개, `team_invites` × 1개
|
||
- `supabase db push` 적용 완료
|
||
|
||
#### Bug 3: LoginScreen stale 번들
|
||
- 증상: Phase 1.5에서 LoginScreen import 제거했는데도 렌더러 화면에 LoginScreen 계속 뜸. HMR/vite cache 혼동 상태.
|
||
- 원인: 불명 (vite HMR이 어딘가에서 stale state 유지한 듯, grep으로는 import 사용처 0개 확인)
|
||
- 픽스: **`LoginScreen.tsx` 파일 자체 삭제**. vite가 컴파일할 대상이 아예 사라져서 stale이 불가능해짐.
|
||
|
||
**검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop dev 런타임 ✅ (로그인 성공 확인)
|
||
- Supabase `db push` ✅ (migration 적용)
|
||
- pull / Realtime 재테스트 — 사용자가 Settings → Cloud Sync → Pull 눌러서 확인 필요
|
||
|
||
### SaaS [7] 3 클라이언트 일원화 검증 — **OAuth provider 연결 대기** (Phase 5)
|
||
|
||
> Phase 1~4+3이 로컬/클라우드 feature gate + sync 인프라를 모두 배선했으나,
|
||
> 실제 로그인을 통해 "데스크톱 ↔ 웹 ↔ 모바일 같은 데이터 관찰"을 검증하려면 Supabase OAuth provider 연결이 선행되어야 함.
|
||
|
||
**사용자가 해야 할 선행 작업 (Supabase 대시보드)**
|
||
1. https://supabase.com/dashboard/project/llnocwyqvhgwpdjcqqyw/auth/providers 접속
|
||
2. **Google provider 활성화**:
|
||
- Google Cloud Console에서 OAuth 2.0 Client ID 발급
|
||
- Application type: Web application
|
||
- Authorized redirect URIs: `https://llnocwyqvhgwpdjcqqyw.supabase.co/auth/v1/callback`
|
||
- Supabase 대시보드의 Google provider에 Client ID/Secret 입력
|
||
3. **(선택) GitHub provider**도 동일 패턴으로 활성화
|
||
4. 데스크톱 deep link scheme은 이미 `d3ro-voice://auth-callback`으로 설정됨
|
||
|
||
**OAuth 연결 후 검증 체크리스트 (다음 세션에서)**
|
||
- [ ] 데스크톱 Settings → Cloud Sync → Google 클릭 → 브라우저 동의 → 앱으로 deep link 콜백
|
||
- [ ] `[auth:signin] DB opened for ${uuid}` 로그 + `users/${uuid}/d3ro.db` 파일 생성
|
||
- [ ] `[auth:signin] Initial push done` + `Initial pull done` 로그
|
||
- [ ] `subscriptions` 테이블에 free 티어 row 자동 생성 (auth trigger)
|
||
- [ ] LicenseService tier가 'free'로 sync됨 (tier-changed 이벤트)
|
||
- [ ] 웹 앱(`apps/web`) 에서 같은 계정으로 로그인 → 데스크톱에서 생성한 history/meetings 즉시 조회
|
||
- [ ] 웹에서 meeting 편집 → 데스크톱에 Realtime으로 반영 (5초 이내)
|
||
- [ ] 모바일 Expo 앱에서 같은 계정 로그인 → 같은 데이터
|
||
- [ ] 데스크톱 signOut → `LicenseService.resetToFree()` + `users/_local/d3ro.db`로 복귀 → 익명 로컬 모드 계속 동작
|
||
- [ ] 로그아웃 상태에서 PREMIUM_LLM 호출 시도 시 `UpgradePromptModal` (login_required) 표시
|
||
|
||
### SaaS [6] 로컬 우선 sync + login_required UI (Phase 3, 2026-04-11)
|
||
|
||
> 빅뱅 Phase 3의 원래 설계("Supabase = source of truth")는 로컬 entry point 철학과 충돌하므로 재정의.
|
||
> **로컬이 source of truth, 클라우드는 로그인 시 mirror + 양방향 sync.**
|
||
|
||
- **CloudSyncService `_initialSync(reason)`**
|
||
- `_onAuthenticated` 끝에 fire-and-forget으로 호출
|
||
- 로그인 직후 `pushAll()` 이후 `pullAll()` 순차 실행
|
||
- signin → 익명 세션 로컬 데이터를 사용자 계정으로 업로드
|
||
- restore → 다른 기기 변경사항 반영
|
||
- 실패해도 warn만, 수동 Sync 버튼으로 재시도 가능
|
||
- **`FeatureAccess.reason` / `UpgradePromptEvent.reason` 확장**
|
||
- `'login_required'` 케이스 추가 (types.ts)
|
||
- `promptUpgrade(feature, 'login_required')` — `requiredTier: 'free'`로 emit
|
||
- **`UpgradePromptModal` login_required 케이스**
|
||
- Title: "X은(는) 로그인이 필요합니다"
|
||
- Desc: "클라우드 기능을 사용하려면 로그인하세요. 로컬 기능은 로그인 없이도 계속 사용할 수 있습니다."
|
||
- Primary button: "로그인하기" → `d3ro:open-settings` custom event (tab=cloud)
|
||
- **LicenseService.consumeQuota()** 에도 `login_required` 케이스 추가 — promptUpgrade + D3ROError(TierRequired)
|
||
- **i18n ko/en**: `license.loginRequired.title/desc/signIn`
|
||
- **검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop `npm run build` ✅ (renderer 2,130kB)
|
||
- 실증은 OAuth provider 연결 후 Phase 5에서
|
||
|
||
**Phase 3에서 의도적으로 뺀 것 (후속 사이클로)**
|
||
- **auto push on write** — VoiceMode / MeetingMode session 완료 즉시 `pushOne()`. 현재는 수동 Sync 버튼 + Realtime debounced pull만. 실제 구현은 각 서비스에 hook 추가가 필요하므로 규모 큼.
|
||
- **오프라인 WriteQueue** — 네트워크 끊긴 상태에서 write → 디스크 큐 → 복귀 시 flush. 현재는 실패 시 logger.warn만.
|
||
- **PREMIUM_LLM 실제 호출 경로** — LocalLLMService에 cloud mode 추가해서 `llm-proxy` Edge Function 호출 + canUse(PREMIUM_LLM) gate. feature enum/policy는 Phase 4에서 준비 완료.
|
||
|
||
### SaaS [5] 티어 enforcement — 로컬 해방 + 클라우드 gate (Phase 4, 2026-04-11)
|
||
|
||
> 사용자 표현: "로컬 사용자는 풀어줘야해! 클라우드 동기화 같은 것과 고급 api가 막히는거지"
|
||
>
|
||
> 기존 LicenseService는 DICTATION 20/일, LLM_PROCESS 10/일 quota + LIVE_CAPTION/VOICE_MEMO/SCREEN_CONTEXT/... 대부분이 pro lock 이었음. 로컬 entry point 철학과 정면 충돌 → 전면 교체.
|
||
|
||
**정책 매트릭스 (Phase 4 확정)**
|
||
|
||
| Feature 종류 | 익명 로컬 `_local` | 로그인 free | pro | pro_plus |
|
||
|---|---|---|---|---|
|
||
| 로컬 기능 16종 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 | ✅ 무제한 |
|
||
| PREMIUM_LLM (신규) | ❌ login_required | 🔸 5/일 | 🔸 500/일 | ✅ 무제한 |
|
||
| CLOUD_SYNC (신규) | ❌ login_required | ✅ | ✅ | ✅ |
|
||
|
||
- `packages/core/src/types.ts`:
|
||
- `Feature` enum에 `PREMIUM_LLM`, `CLOUD_SYNC` 추가
|
||
- `FeatureAccess.reason`에 `'login_required'` 추가 (익명 로컬 모드에서 cloud feature 요청 시)
|
||
- `LicenseService.ts`:
|
||
- `QUOTA_LIMITS.free` — DICTATION/LLM_PROCESS 20/10 삭제, `PREMIUM_LLM: 5`만 남김
|
||
- `QUOTA_LIMITS.pro` — `PREMIUM_LLM: 500` (fair use cap)
|
||
- `FEATURE_MIN_TIER` — 기존 16개 feature 전부 `'free'`로 완화. 신규 `PREMIUM_LLM`/`CLOUD_SYNC`도 `'free'` (로그인만 하면 접근 가능, 티어는 quota로만 구분)
|
||
- `CLOUD_FEATURES: Set<Feature>` 신규 — 익명 로컬 모드 감지용
|
||
- `canUse()` — `CLOUD_FEATURES.has(feature) && isLocalMode()` → `login_required` 반환. 그 외는 기존 tier/quota 로직
|
||
- `syncFromCloud(tier)` 신규 — Supabase `subscriptions` 티어를 electron-store에 캐시 + `tier-changed` emit
|
||
- `resetToFree()` 신규 — 로그아웃 시 호출. licenseKey/activatedAt/lastVerifiedAt/offlineGraceUntil 모두 null로 리셋
|
||
- `db/index.ts`의 `isLocalMode()` import 추가
|
||
- `CloudSyncService.ts`:
|
||
- `_fetchSubscriptionTier(userId)` 신규 — `subscriptions` 테이블에서 `{tier, status: 'active'}` 조회. 없으면 free, 비정상 티어도 free 폴백
|
||
- `_onAuthenticated()`:
|
||
- DB 오픈 → **subscription tier fetch → LicenseService.syncFromCloud(tier)** → auth-changed emit → Realtime
|
||
- fetch 실패 시 warn만 찍고 현재 티어 유지 (오프라인 grace)
|
||
- `_onSignOut()`:
|
||
- Voice/Meeting/Caption stop → Realtime stop → auth.signOut → token clear → **LicenseService.resetToFree()** → closeCurrent → openLocal → auth-changed(null) emit
|
||
- **검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop `npm run build` ✅ (main bundle 정상)
|
||
- 익명 로컬 사용자는 이제 기존 기능(LIVE_CAPTION/VOICE_MEMO/MEETING_SUMMARY/FILE_TRANSCRIPTION 등 16개) 전부 사용 가능 — **pro lock 완전 제거**
|
||
- 실측 검증 대기:
|
||
- 기존 녹음/후처리가 quota 메시지 없이 계속 작동
|
||
- PREMIUM_LLM 호출 경로는 Phase 3에서 추가 예정 → 지금은 feature 정의만
|
||
|
||
**Phase 4에서 의도적으로 뺀 것**
|
||
- PREMIUM_LLM 실제 호출 경로 — Phase 3(cloud-first)에서 Edge Function `llm-proxy` 호출 로직과 함께
|
||
- daily_usage를 Supabase로 동기화 — 현재는 로컬 SQLite `daily_usage` 테이블로만 추적 (사용자별 격리 DB라 문제 없음)
|
||
- UpgradePromptModal 텍스트 업데이트 — `login_required` 케이스용 새 메시지는 Phase 3/5에서
|
||
|
||
### SaaS [4] 회원가입 / 온보딩 (Phase 2, 2026-04-11)
|
||
|
||
> 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가.
|
||
|
||
- **AppConfig 타입 정식화**
|
||
- `packages/core/src/types.ts` `AppConfig`에 `onboardingCompleted: boolean` 추가
|
||
- `ConfigService.CONFIG_DEFAULTS`에 `onboardingCompleted: false` 추가
|
||
- 타입 캐스팅(`as keyof AppConfig`, `as never`) 3곳 제거:
|
||
- `AppLayout.tsx` 첫 실행 감지: `cfg.onboardingCompleted` 직접 접근
|
||
- `OnboardingModal.tsx` handleFinish: `key: 'onboardingCompleted'` 리터럴
|
||
- `SettingsModal.tsx` "온보딩 다시 보기" 버튼: 동일
|
||
- **온보딩 Step 확장 (5 → 6 step)**
|
||
- 기존 flow: 환영 → 마이크 → 핫키 → Ollama → 완료
|
||
- 새 flow: 환영 → 마이크 → 핫키 → Ollama → **Cloud Sync(선택)** → 완료
|
||
- Cloud Sync step은 `saasMode === true`일 때만 노출:
|
||
- `getState()`로 saasMode 조회
|
||
- Ollama "다음" → `nextAfterOllama()`: saasMode면 step 4, 아니면 step 5로 분기
|
||
- Cloud Sync step 내용: tagline + 3개 benefit (동기화/Premium LLM/팀공유) + "설정에서 언제든지 로그인" 안내. **로그인 버튼 없음 — onboarding scope 밖** (Settings의 CloudSyncSection이 유일한 OAuth 진입점).
|
||
- **i18n ko/en 키 추가** (`packages/i18n/src/locales/`):
|
||
- `onboarding.cloud.title` / `tagline` / `benefit1~3` / `signInLater`
|
||
- 나머지 10개 locale은 fallback (Phase 2 scope 밖, 추후 일괄)
|
||
- **검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop `npm run build` ✅ (renderer 2,129kB, +3kB)
|
||
- 수동 검증 대기: Settings → About → "온보딩 다시 보기" 눌러서 신규 시퀀스 확인
|
||
- **Phase 2에서 의도적으로 뺀 것** (추후 사이클)
|
||
- Supabase `profiles.onboarded_at` 컬럼 — OAuth provider(Google) 설정 전까진 의미 없음
|
||
- OAuth 로그인 후 "환영" 페이지 — provider 연결 후 Phase 4와 함께
|
||
- STT 모델 선택 step — faster-whisper sidecar가 첫 사용 시 자동 다운로드하므로 불필요
|
||
- 온보딩 키의 나머지 10개 locale 번역
|
||
|
||
### SaaS [3.5] 로컬 모드 = entry point (Phase 1.5, 2026-04-11)
|
||
|
||
> 비즈니스 모델: 무료 로컬 모드 = onboarding funnel 입구 (킬러 피처),
|
||
> 유료 클라우드 SaaS = 수익화 (OAuth 로그인 + pro 구독 + premium 모델 + sync).
|
||
> SaaS [2] OAuth 강제 게이트 철회.
|
||
|
||
- **`db/index.ts`**
|
||
- `LOCAL_USER_ID = '_local'` 상수 export
|
||
- `openLocal()` 헬퍼 — `openForUser('_local')` 래퍼. 경로: `users/_local/d3ro.db`
|
||
- `isLocalMode()` 헬퍼 — `currentUserId === LOCAL_USER_ID`
|
||
- **`bootstrap.ts`**
|
||
- `database` step 복원 (critical). `openLocal()` 호출로 앱 시작 시 로컬 DB 자동 오픈
|
||
- 순서: logger → config → **database (local)** → license → windows → tray → ipc → ... → cloud-sync
|
||
- **`CloudSyncService._onAuthenticated(session)`**
|
||
- `openForUser(userId)` — 내부에서 기존 로컬 DB close 후 사용자 DB 재오픈 (자동)
|
||
- auth-changed(user) emit 후 Realtime 시작
|
||
- **`CloudSyncService._onSignOut()`**
|
||
- VoiceMode cancel → MeetingMode stopRecording → Caption stop → Realtime stop → auth.signOut → token clear
|
||
- `closeCurrent()` → `openLocal()` — **로컬 모드 DB로 복귀** (앱은 계속 로컬 모드로 동작)
|
||
- auth-changed(null) emit
|
||
- **`App.tsx`**
|
||
- AuthGate 상태머신 완전 제거. `LoginScreen` import 제거.
|
||
- 항상 `<AppLayout /> <UpgradePromptModal />` 렌더링 (로그인/로그아웃 무관)
|
||
- **`LoginScreen.tsx`**
|
||
- 파일 유지 (dead code — 추후 Welcome 화면 재활용 가능)
|
||
- **검증**
|
||
- desktop `tsc --noEmit` ✅
|
||
- desktop `npm run build` ✅ (main 992kB, renderer 2,126kB)
|
||
- dev 런타임 ✅:
|
||
- `[bootstrap] local database opened: .../users/_local/d3ro.db (created=true)`
|
||
- `[bootstrap] database initialized` → 나머지 step 정상
|
||
- `Main window shown` — LoginScreen 없이 바로 메인 UI
|
||
- 파일시스템: `users/_local/d3ro.db` + WAL/SHM 정상 생성
|
||
|
||
## 빅뱅 다음 세션 목표 (2026-04-11 wrap-up)
|
||
|
||
데스크톱을 진짜 SaaS 클라이언트로 완성. 자세한 계획 + 단계별 prompt는
|
||
`memory/handoff-latest.md` 참조.
|
||
|
||
핵심:
|
||
1. 사용자별 SQLite 격리 (`userData/users/${userId}/d3ro.db`)
|
||
2. 회원가입/온보딩 흐름 — OAuth 첫 가입 시 환영 + 기본 설정
|
||
3. cloud-first 정책 — Supabase가 source of truth, 로컬 cache는 read-through
|
||
4. 멀티 디바이스 sync 강화 (Realtime + last-write-wins → CRDT 검토)
|
||
5. 결제/티어 enforcement (free 일일 quota, 데스크톱에서도 차단)
|
||
6. 회의/지식 web ↔ 데스크톱 일원화
|
||
|
||
## SaaS 아키텍처 진단 (2026-04-11)
|
||
|
||
> 사용자 우려: "single-user로 잘못 만들어진 것 같다"
|
||
> 결론: **잘못된 게 아님 — 100% multi-tenant SaaS로 이미 구축됨**
|
||
|
||
| 레이어 | 상태 |
|
||
|---|---|
|
||
| Supabase 스키마 | ✅ 모든 테이블에 `user_id` FK + RLS 활성화 (12 테이블) |
|
||
| RLS 정책 | ✅ `user_id = auth.uid()` 격리 + 팀 공유 패턴 (205줄) |
|
||
| Auth 트리거 | ✅ `handle_new_user`로 가입 시 profile + free subscription 자동 생성 |
|
||
| Edge Functions | ✅ 10개 전부 `requireUser(req)` JWT 검증 |
|
||
| 웹 앱 | ✅ Supabase OAuth + 미들웨어 라우트 가드 |
|
||
| 모바일 앱 | ✅ supabase-js + Expo Push 사용자별 |
|
||
| 결제 | ✅ Stripe + subscriptions 테이블 multi-tenant |
|
||
| 데스크톱 | ✅ SaaS의 fat client (Notion/Linear/Slack 패턴) — OAuth 로그인 후 user별 sync |
|
||
|
||
**오해 가능 지점**: 데스크톱 앱은 V1 single-user 출신이라 첫 실행 시 CloudSync가 disabled. Settings에서 Supabase URL/Key + OAuth 로그인을 해야 비로소 SaaS 클라이언트로 동작. 그 이전에는 로컬 SQLite만 쓰는 single-user처럼 보임. 이건 의도된 fat client 아키텍처지만 UX 개선 여지 있음:
|
||
|
||
**향후 데스크톱 SaaS UX 개선 후보 (잘못된 게 아닌 향상)**
|
||
1. 첫 실행 시 OAuth 로그인을 강제 (Notion 패턴) — 게스트 모드 분리
|
||
2. Supabase URL/Key를 빌드 타임 환경변수로 박기 — 사용자가 직접 입력하지 않게
|
||
3. 로컬 SQLite를 `userData/${user.id}/d3ro.db`로 사용자별 격리 — PC 공유 시 데이터 혼동 방지
|
||
|
||
**6차 검증**: web typecheck/dev 200, production build 15 라우트 (`dashboard` 1.6kB→111kB, `meetings/[id]` 4.8kB→55kB by chart/mermaid)
|
||
|
||
**다음 사이클 후보**: VoiceAction 카탈로그 확대, RAG → /chat 연동, Knowledge 파일 업로드(PDF/docx), Voice Input UI(/actions 마이크), 알림 센터, 팀 대시보드, recharts dynamic import로 dashboard split, Playwright browsers install + E2E 실 실행
|
||
|
||
## V1 완료 페이즈
|
||
|
||
| Phase | 내용 | 상태 |
|
||
|---|---|---|
|
||
| 1~9 | 기본 음성 어시스턴트 (STT/LLM/TTS/핫키/팝업) | ✅ |
|
||
| 10 | Killer features (메모, 보이스 커맨드, 스크린 컨텍스트, 체인, 캡션) | ✅ |
|
||
| 11 | Monetization (라이선스, 쿼터) | ✅ |
|
||
| 12 | Pro features (파일 전사, 회의 요약, 딕테이션 템플릿) | ✅ |
|
||
| 13 | 음성 대화, RAG, 보이스 액션 | ✅ |
|
||
| 14 | 회의 모드 | ✅ |
|
||
| 14.5 | 회의 고도화 (다중 문서 생성/편집/내보내기) | ✅ |
|
||
| 15 | Auto Polish + AI 채팅 + 마인드맵 + 공유 | ✅ |
|
||
| 15.5 | 화자 구분 (LLM 추정 + pyannote) | ✅ |
|
||
|
||
**추가 개선사항 (post-15.5)**
|
||
- 히스토리/회의 목록 반응형 그리드 + 카드 UI
|
||
- 설정 LLM 모델 드롭다운
|
||
- 오디오 테스트 실시간 레벨 미터
|
||
- Ollama 미실행 시 설치 경로 탐색 후 자동 스폰 (2026-04-08)
|
||
|
||
## V2 진행 상황
|
||
|
||
### Phase V2-1: Monorepo + Core 추출 — **진행 중**
|
||
|
||
| Sub-phase | 범위 | 상태 |
|
||
|---|---|---|
|
||
| V2-1a | npm workspaces + apps/desktop으로 V1 이동 | ✅ 완료 |
|
||
| V2-1b | packages/core 추출 (types, errors, ipc-channels, constants, utils) | ✅ 완료 |
|
||
| V2-1c | packages/ui 추출 (DS 컴포넌트 + theme + theme-vars) | ✅ 완료 |
|
||
| V2-1d | packages/i18n 추출 (locale JSON + 훅) | ✅ 완료 |
|
||
|
||
**V2-1d 완료 내역**
|
||
- `packages/i18n/` (@d3ro/i18n) 신규 생성:
|
||
- `src/locales/` 12개 JSON (ko/en/ja/zh/zh-TW/es/fr/de/pt/ru/vi/th)
|
||
- `src/index.tsx`: TFunction, Locale, LOCALE_META, resolveTranslation, createFormatDate/Number/RelativeDate/Time, I18nContext, useI18n, I18nProvider
|
||
- **Electron 독립성 확보**: `I18nStorage` interface 추가 (load/save 어댑터)
|
||
- 기존 `window.electronAPI.config` 직접 호출 → `storage` prop 주입 패턴
|
||
- 기본값은 noopStorage (메모리 한정)
|
||
- `apps/desktop/src/renderer/App.tsx`에 `electronI18nStorage` 어댑터 구현 후 `<I18nProvider storage={electronI18nStorage}>`로 주입
|
||
- apps/desktop 설정:
|
||
- `@d3ro/i18n: "*"` dep 추가
|
||
- tsconfig paths, vite alias, externalize exclude에 `@d3ro/i18n` 추가
|
||
- **일괄 치환**: `./i18n`, `../i18n`, `../../i18n` → `@d3ro/i18n` (29 파일)
|
||
- `apps/desktop/src/renderer/i18n/` 디렉토리 완전 제거
|
||
|
||
**V2-1d 검증**
|
||
- typecheck ✅
|
||
- build ✅
|
||
- dev 런타임 ✅ (DB/핫키/Ollama/Main window 모두 정상)
|
||
- Electron 의존이 완전히 apps/desktop에만 남아 monorepo 원칙 준수
|
||
(packages/i18n은 React에만 의존, 웹/모바일에서 그대로 재사용 가능)
|
||
|
||
**V2-1c 완료 내역**
|
||
- `packages/ui/` (@d3ro/ui) 신규 생성:
|
||
- `src/theme.ts` (d3roPalette/d3roTypo/d3roShadow SSOT 전체)
|
||
- `src/theme-vars.ts` (팝업/main 프로세스용 CSS 변수 맵)
|
||
- `src/components/ds/{CrtDisplay, InstrumentPanel, Led, MetalCard, MetalDial, PhosphorText, PhysicalButton, ScreenPanel, ButtonGroup, index}.tsx`
|
||
- `src/index.ts` barrel
|
||
- `packages/ui/package.json`: React/MUI/Emotion은 **peerDependencies**, `@d3ro/core`만 직접 dep
|
||
- `apps/desktop/src/shared/` 디렉토리 완전 제거 (theme-vars가 마지막이었음)
|
||
- subpath exports: `./theme`, `./theme-vars`, `./components/ds`
|
||
- DS 컴포넌트 내부 `../../theme` 상대 경로는 그대로 유효 (packages/ui 내부에서 해결)
|
||
- **일괄 치환**:
|
||
- `../theme`, `../../theme`, `./theme` → `@d3ro/ui/theme`
|
||
- `../components/ds`, `../../components/ds`, `./ds`, `../ds` → `@d3ro/ui/components/ds`
|
||
- `../ds/<Component>`, `../../ds/<Component>` → `@d3ro/ui/components/ds` (세부 파일 import는 barrel로 통합)
|
||
- `@shared/theme-vars` → `@d3ro/ui/theme-vars` (WindowManager 1건)
|
||
- `apps/desktop/tsconfig.node/web.json`: `@shared/*` paths 완전 제거, `@d3ro/ui`, `@d3ro/ui/*` paths 추가, `include`에서 `src/shared/**/*` 제거
|
||
- `electron.vite.config.ts`: `@shared` alias 제거, `@d3ro/ui` alias 추가, externalize exclude에 `@d3ro/ui` 추가
|
||
- `vitest.config.ts`: alias 교체
|
||
|
||
**V2-1c 검증**
|
||
- `typecheck` ✅
|
||
- `build` ✅
|
||
- `dev` 런타임 ✅ (DB/핫키/Ollama/Main window 정상)
|
||
- `apps/desktop/src/shared/` 디렉토리 자체가 사라져 `@shared/*`는 이제 프로젝트에 존재하지 않음
|
||
|
||
**V2-1b 완료 내역**
|
||
- `packages/core/` 패키지 신규: `@d3ro/core`, subpath exports (types/errors/ipc-channels/constants/utils/*), docx dep 포함
|
||
- 6개 파일 `git mv`로 `packages/core/src/`로 이동:
|
||
- `apps/desktop/src/shared/{types,errors,ipc-channels,constants}.ts`
|
||
- `apps/desktop/src/main/utils/{meeting-markdown,markdown-to-docx}.ts`
|
||
- `packages/core/src/index.ts` barrel export 추가
|
||
- `apps/desktop/package.json`에 `"@d3ro/core": "*"` dep 추가
|
||
- `apps/desktop/tsconfig.node.json`, `tsconfig.web.json`의 `paths`에 `@d3ro/core/*` 추가, `@shared/*`는 theme-vars용으로 유지
|
||
- `apps/desktop/electron.vite.config.ts` alias 3개 섹션에 `@d3ro/core` 추가 + **M1 수정**: `resolve('src/shared')` → `resolve(__dirname, 'src/shared')`
|
||
- `externalizeDepsPlugin` exclude에 `@d3ro/core` 추가 (main/preload 둘 다)
|
||
- `apps/desktop/vitest.config.ts`에 `@d3ro/core` alias 추가
|
||
- **일괄 치환**: `@shared/{types,errors,ipc-channels,constants}` → `@d3ro/core/*` (사용자 79개 파일, 167건)
|
||
- static import (`from '@shared/...'`)
|
||
- dynamic import (`import('@shared/...')`)
|
||
- type expression import (`keyof import('@shared/...').AppConfig`)
|
||
- `MeetingModeService.ts`의 상대 경로 utils import (`../utils/meeting-markdown`, `../utils/markdown-to-docx`)를 `@d3ro/core/utils/*`로 교체
|
||
- `packages/core` 내부 자기 참조: `meeting-markdown.ts`의 `@shared/types` → `../types` 상대 경로
|
||
|
||
**V2-1b 검증**
|
||
- `npm run typecheck` ✅
|
||
- `npm run build` ✅ (main+preload+renderer)
|
||
- `npm run dev` 런타임 실행 ✅ (DB/핫키/Ollama 모두 정상)
|
||
- `apps/desktop/src/shared/`에는 `theme-vars.ts` 1개만 남음 (V2-1c에서 이동 예정)
|
||
|
||
**V2-1a 완료 내역**
|
||
- 루트 `package.json`을 npm workspaces 루트로 재구성 (workspaces: apps/*, packages/*)
|
||
- `turbo.json`, `tsconfig.base.json` 추가 (Turborepo 자체 설치는 뒤로 미룸)
|
||
- V1 파일 전체를 `apps/desktop/`으로 `git mv`:
|
||
- src/, resources/, tests/, sidecar/, scripts/
|
||
- electron.vite.config.ts, electron-builder.yml, vitest.config.ts
|
||
- tsconfig.node.json, tsconfig.web.json (+ 새 tsconfig.json)
|
||
- `apps/desktop/package.json` 신규 (`name: "@d3ro/desktop"`, 기존 deps 그대로)
|
||
- `@electron/rebuild --version=33.4.11`로 native 모듈 재빌드 (better-sqlite3, uiohook-napi)
|
||
- 루트 tsconfig.json → apps/desktop references 형태
|
||
|
||
**V2-1a 결정사항**
|
||
- **패키지 매니저**: npm workspaces 유지 (pnpm 전환은 별도 sub-phase)
|
||
- 이유: Turborepo는 npm workspaces와 호환, 구조 이동과 PM 전환을 동시에 하면 롤백 불가
|
||
- **site/**: 루트 유지 (랜딩 페이지, V2 무관)
|
||
- **docs/**, **memory/**, **CLAUDE.md**: 루트 유지
|
||
- **productName: "d3ro-voice"** 필수 — package name을 `@d3ro/desktop`으로 바꾸면 Electron의 `app.getName()`이 바뀌어 userData 경로가 `%APPDATA%\@d3ro\desktop\`으로 이동(기존 DB/설정 유실). `productName`을 명시하면 `app.getName()`이 이를 우선 반환하여 `%APPDATA%\d3ro-voice\` 그대로 유지됨.
|
||
|
||
**검증 결과**
|
||
- `npm run typecheck` ✅
|
||
- `npm run build` ✅ (electron-vite main+preload+renderer)
|
||
- `npm run dev` ✅ 실제 실행 → DB 경로 `d3ro-voice/`로 복원, 기존 핫키(Alt keyCode=2) 로드, Ollama 자동 실행 감지 모두 정상
|
||
- `npm run dist` (electron-builder installer)은 V2-1 완료 후 별도 검증
|
||
|
||
### Phase V2-2: Supabase 인프라 — 로컬 작업 완료
|
||
|
||
**작성된 파일** (`server/supabase/`)
|
||
- `config.toml` — Supabase CLI 설정 (로컬 + OAuth providers)
|
||
- `migrations/20260409000001_initial_schema.sql` — 12개 테이블 (profiles, teams, team_members, meetings, meeting_memos, meeting_documents, transcripts, history, dictionary, memo_tags, daily_usage, subscriptions) + 인덱스 + FK CASCADE
|
||
- `migrations/20260409000002_rls_policies.sql` — 개인/팀 이중 RLS 패턴
|
||
- `migrations/20260409000003_auth_triggers.sql` — `handle_new_user` (profiles + subscriptions 자동 생성), `moddatetime` (8개 테이블 updated_at 트리거), `increment_daily_usage` (service_role 전용 쿼터 RPC)
|
||
- `migrations/20260409000004_storage_buckets.sql` — audio/exports/avatars 버킷 + 경로 기반 정책
|
||
- `functions/_shared/` — cors.ts, auth.ts (requireUser), quota.ts (티어별 쿼터)
|
||
- `functions/stt-proxy/index.ts` — Google Cloud STT 래퍼 스캐폴딩 (placeholder 응답)
|
||
- `functions/llm-proxy/index.ts` — Anthropic Messages API 래퍼 스캐폴딩 (티어별 모델 정책)
|
||
- `seed.sql` — 개발 시드 (빈 상태)
|
||
- `.gitignore` — .branches/, .temp/, .env
|
||
|
||
**설계 문서**
|
||
- `docs/v2/phase-V2-2.md` — 상세 설계 (스키마/RLS/Edge Functions/실시간/Storage)
|
||
- `docs/v2/phase-V2-2-setup.md` — 사용자 액션 가이드 (Supabase 계정/OAuth/CLI/배포)
|
||
|
||
**사용자 액션 대기 중** (로컬 외 작업)
|
||
1. Supabase 프로젝트 생성 (https://supabase.com)
|
||
2. `supabase login && supabase link --project-ref <REF>`
|
||
3. `supabase db push` (4개 마이그레이션 적용)
|
||
4. Google/GitHub OAuth 앱 등록 + Supabase Auth Providers 설정
|
||
5. Google Cloud STT 키 + Anthropic API 키 발급
|
||
6. `supabase secrets set` + `supabase functions deploy`
|
||
|
||
**설계 결정사항**
|
||
- **UUID PK**: V1은 nanoid(text), V2는 PostgreSQL `gen_random_uuid()`. V2-4 (동기화)에서 기존 SQLite 데이터는 nanoid 유지, 새 데이터는 UUID.
|
||
- **timestamptz**: V1은 epoch ms(integer), V2는 `timestamptz`. V2-4 동기화 시 타입 변환 필요.
|
||
- **멀티테넌시**: 모든 테이블에 `user_id`. 회의 관련 테이블은 `team_id` nullable 추가.
|
||
- **RLS 이중 패턴**: 개인 전용(history/dictionary)은 단순 `user_id = auth.uid()`. 팀 공유(meetings)는 본인 OR `team_members` 조회 subquery.
|
||
- **쿼터 집계**: `increment_daily_usage` RPC는 `SECURITY DEFINER` + `service_role` only. 일반 유저는 읽기만.
|
||
- **Realtime**: `transcripts` 테이블만 `supabase_realtime` publication에 추가 (회의 중 세그먼트 동기화 용).
|
||
- **Edge Functions placeholder**: 인증/쿼터 파이프라인은 완성, 실제 AI API 호출은 주석 처리된 실제 구현 코드 포함. V2-3 (Web MVP) 진행 전까지 유지.
|
||
|
||
### Phase V2-5: Mac 빌드 — 로컬 작업 완료
|
||
|
||
**작성/수정된 파일**
|
||
- `apps/desktop/src/main/utils/paths.ts` — `EXE_SUFFIX` 상수 도입, sox/sidecar/ffmpeg 경로 플랫폼별 분기
|
||
- `apps/desktop/src/main/services/SoundEffectService.ts` — Mac `afplay`, Linux `aplay` 분기 추가 (execFile로 안전하게)
|
||
- `apps/desktop/src/main/services/ScreenContextService.ts` — `_getActiveWindowInfo`를 win32/darwin로 분기. macOS는 `osascript`로 frontmost process + 윈도우 타이틀 조회 (Accessibility 권한 필요)
|
||
- `apps/desktop/scripts/build-sidecar.py` — `IS_WINDOWS`/`IS_MACOS` 분기, `EXE_SUFFIX`, `--noconsole`은 Windows에서만, 출력 경로 플랫폼 통합
|
||
- `apps/desktop/scripts/install-sox.sh` — Mac/Linux에서 시스템 sox를 `resources/sox/`로 복사. macOS는 `otool`로 dylib 의존성 함께 복사 + `install_name_tool`로 rpath 변경
|
||
- `apps/desktop/electron-builder.yml` — `mac:` 타겟 추가 (dmg+zip, arm64+x64), `hardenedRuntime`, `entitlements`, `extendInfo`(권한 메시지), Linux AppImage placeholder
|
||
- `apps/desktop/build/entitlements.mac.plist` — 마이크/카메라/네트워크/Apple Events/JIT/dylib 권한
|
||
- `apps/desktop/resources/icons/README.md` — 플랫폼별 아이콘 생성 가이드 (sips/iconutil/imagemagick)
|
||
- `.github/workflows/build-mac.yml` — macos-14 runner, brew sox, sidecar 빌드, electron-builder dist (arm64/x64 매트릭스), CSC/notarize 환경변수, artifact 업로드
|
||
- `docs/v2/phase-V2-5-mac-guide.md` — Mac 사전 조건/dev 실행/dist 빌드/서명/공증/CI/트러블슈팅
|
||
|
||
**검증 결과 (Windows에서)**
|
||
- typecheck ✅ — Mac 분기 추가에도 회귀 없음
|
||
- build ✅ — Windows 번들 정상
|
||
- dev 런타임 ✅ — Ollama 자동 실행 + 모든 서비스 정상
|
||
|
||
**Mac에서 검증 대기 (사용자 액션)**
|
||
- `brew install sox node@22 python@3.11`
|
||
- `npm install && npx @electron/rebuild@3 --version=33.4.11`
|
||
- `npm run dev` → Accessibility/마이크/Apple Events 권한 허용
|
||
- 핫키, 녹음, STT, LLM, 화면 컨텍스트 동작 확인
|
||
- 결과 리포트 받아 미세 조정 필요할 수 있음
|
||
|
||
**감사 결과 vs 처리**
|
||
- C1 sox.exe/sidecar.exe/ffmpeg.exe 하드코딩 → ✅ 수정
|
||
- C2 아이콘 부재 → ✅ placeholder 디렉토리 + README
|
||
- C3 mac 타겟 부재 → ✅ 추가
|
||
- C4 SoX 바이너리 부재 → ✅ install-sox.sh + 가이드
|
||
- C5 ScreenContextService 미지원 → ✅ AppleScript 분기
|
||
- C6 SoundEffectService 미지원 → ✅ afplay 분기
|
||
- C7 build-sidecar.py Windows 전제 → ✅ 크로스 플랫폼
|
||
- M6/M7/M8/M9 (이미 분기 완료) → 변경 없음
|
||
|
||
### Phase V2-3: Web App MVP — 완료
|
||
|
||
**신규 패키지**
|
||
- `packages/api-client/` (@d3ro/api-client) — Supabase 래퍼
|
||
- `src/types.ts` — V2-2 스키마에 맞춘 수동 Database 타입 정의 (profiles/teams/meetings/transcripts/history/dictionary/daily_usage/subscriptions)
|
||
- `src/client.ts` — createD3roSupabaseClient 팩토리 (URL/KEY 주입형, env 없어도 placeholder로 작동)
|
||
- `src/auth.ts` — signInWithOAuth/signOut/getSession
|
||
- `src/meetings.ts` — CRUD 함수 (meetings, memos, documents, transcripts)
|
||
- `src/history.ts` — 음성 입력 이력 CRUD
|
||
- `src/usage.ts` — daily_usage 조회 (쿼터 UI)
|
||
- 루트 barrel은 types만 re-export (함수는 subpath로, 순환 의존 방지)
|
||
|
||
**신규 앱 — `apps/web/`**
|
||
- Next.js 15 App Router + MUI 7 + Emotion
|
||
- transpilePackages로 `@d3ro/core/ui/i18n/api-client` 소스 TypeScript 그대로 번들
|
||
- 라우트 (총 9개):
|
||
- `/` — auth 상태에 따라 리다이렉트
|
||
- `/login` — Google/GitHub OAuth (Supabase 미설정 시 경고)
|
||
- `/auth/callback` — OAuth code → session 교환 route handler
|
||
- `/dashboard` — 요약 카드 4개 + 최근 회의 5건
|
||
- `/meetings` — 회의 카드 그리드 리스트
|
||
- `/meetings/[id]` — 상세 (transcripts + memos + documents 탭)
|
||
- `/record` — 녹음 페이지 (getUserMedia + MediaRecorder + stt-proxy)
|
||
- Providers: ThemeProvider(MUI+AppRouterCacheProvider) / I18nProvider(localStorage 어댑터) / AuthProvider(Supabase session)
|
||
- 인증 가드: `/dashboard` layout + `/meetings`, `/record`, `/meetings/[id]` 개별 redirect
|
||
- Sidebar: Dashboard / Meetings / Record / Logout
|
||
- Recorder: 9바 웨이브폼 (Speakly 스타일), 실시간 레벨, elapsed timer, cancel/stop
|
||
|
||
**packages/ui 확장 (@d3ro/ui)**
|
||
- `MetalCard`: `BoxProps` 확장하여 `sx`/`onClick` 등 전달 가능
|
||
- `theme.ts`에 `typoSx(key)` 헬퍼 추가 — d3roTypo를 MUI sx 친화 포맷으로 변환
|
||
- `theme.ts`에 `d3roPalette.tag.blue` 추가 (M5 수정의 부산물)
|
||
- DS 컴포넌트 9개에 `'use client'` directive 추가 — RSC 경계 명시
|
||
- `MetalDial.tsx`의 미사용 import 3개 제거 (tsc strict 회귀)
|
||
|
||
**packages/i18n 확장**
|
||
- `ko.json`에 7개 새 키: `nav.meetings`, `nav.record`, `nav.logout`, `login.subtitle`, `login.google`, `login.github`, `login.terms`
|
||
- 다른 locale은 fallback 체인으로 ko/en 사용 (추후 번역 작업에서 채움)
|
||
|
||
**설계 결정사항**
|
||
- **Database 제네릭**: 현재 `SupabaseClient` 기본 타입 사용, V2-4에서 `supabase gen types typescript --local` 자동화 후 제네릭 주입 예정. 이유: 수동 Database 타입 정의가 @supabase/supabase-js 2.102의 복잡한 제약(PostgrestVersion, Views/Functions/Enums 형식)을 만족시키기 어려움.
|
||
- **api-client barrel 분리**: 루트에서는 types만 export. 함수는 subpath(`@d3ro/api-client/meetings`)로만 접근. 이렇게 하지 않으면 apps/web이 api-client 루트를 import할 때 내부 meetings/history 등이 모두 tsc 스캔 대상이 되어 타입 에러가 전파됨.
|
||
- **DS 컴포넌트 `'use client'`**: MUI + React 훅(CrtDisplay)을 쓰는 컴포넌트들은 Next.js RSC에서 쓰려면 client directive 필요. barrel 대신 각 파일마다 붙임.
|
||
- **Supabase env 없이도 빌드 성공**: placeholder URL/key로 createBrowserClient/createServerClient를 호출. 런타임에 `isSupabaseConfigured()` 체크로 UI 경고 표시.
|
||
|
||
**검증 결과**
|
||
- web typecheck ✅ (apps/web)
|
||
- desktop typecheck ✅ (회귀 없음)
|
||
- web `next build` ✅ — 9 라우트 (/ /login /auth/callback /dashboard /meetings /meetings/[id] /record /_not-found + root)
|
||
- desktop `npm run build` ✅ (main+preload+renderer)
|
||
- 실제 Supabase 연결은 V2-2 사용자 액션 이후 검증 가능
|
||
|
||
### Phase V2-4: 데스크톱 ↔ Supabase 동기화 — 완료
|
||
|
||
**핵심 컴포넌트**
|
||
- `apps/desktop/src/main/services/CloudSyncService.ts` — 싱글톤 + EventEmitter
|
||
- `init/startSignIn/handleAuthCallback/signOut/pushAll` + 4종 이벤트
|
||
- safeStorage 기반 refresh token 영속화
|
||
- upsert(onConflict: id) 방식 push, 매핑 테이블 없음
|
||
- `ipc/cloud-sync-handlers.ts` + `preload index.ts` cloudSync API
|
||
- `renderer/components/CloudSyncSection.tsx` — Settings 섹션
|
||
- `main/index.ts` deep link (`d3ro-voice://auth-callback`) 핸들러 추가
|
||
- `bootstrap.ts` cloud-sync 단계 추가
|
||
- `packages/core/src/ipc-channels.ts` CLOUD_SYNC 11종 채널
|
||
- `packages/core/types.ts` AppConfig + ConfigService defaults에 supabaseUrl/supabaseAnonKey/cloudSyncLastAt + hfToken/diarizationEnabled (Phase 15.5 누락분 보충)
|
||
|
||
**설계 결정**
|
||
- Local-first push only (V2-4b에서 pull 추가)
|
||
- 마지막 동기화 epoch ms 기준 변경분만 전송
|
||
- nanoid PK를 PostgreSQL text로 그대로 upsert
|
||
- macOS open-url + Windows second-instance argv 방식 deep link
|
||
|
||
### Phase V2-6: Mobile MVP — 완료
|
||
|
||
**구조** (`apps/mobile/`, npm workspace에서 **제외**)
|
||
- Expo SDK 51 + React Native + Expo Router
|
||
- `lib/supabase.ts` (AsyncStorage), `lib/auth-context.tsx`
|
||
- `app/_layout.tsx`, `app/index.tsx`, `app/login.tsx`
|
||
- `app/(tabs)/{_layout, meetings, record, profile}.tsx`
|
||
- `expo-av`로 녹음 → stt-proxy 호출
|
||
- `README.md` + setup 가이드
|
||
|
||
**설계 결정**
|
||
- workspace 제외 (Expo native deps 부담 회피)
|
||
- MUI 미사용, 인라인 RN 스타일 (V2-6b에서 `@d3ro/ui-native` 분리)
|
||
- Deep link `scheme: 'd3ro-voice'`
|
||
|
||
### Phase V2-7: 팀 기능 (Web) — 완료
|
||
|
||
- `apps/web/src/app/teams/page.tsx` — 가입한 팀 카드 그리드
|
||
- `apps/web/src/app/teams/[id]/page.tsx` — 팀 상세 (멤버 + 공유 회의)
|
||
- `components/teams/create-team-form.tsx` — 팀 생성 + owner 자동 등록
|
||
- `components/teams/invite-member-form.tsx` — user_id 직접 입력 (V2-7b에서 정식 invite flow)
|
||
- Sidebar에 Teams/Billing 메뉴 + ko.json 새 키
|
||
- V2-2의 teams/team_members RLS 활용
|
||
|
||
### Phase V2-8: 결제 스캐폴딩 — 완료
|
||
|
||
- `apps/web/src/app/billing/page.tsx` — Free/Pro/Team 가격표 + 현재 티어
|
||
- `components/billing/checkout-button.tsx` — Edge Function 호출
|
||
- `server/supabase/functions/stripe-checkout/index.ts` — JWT 인증, customer 조회/생성, Checkout Session
|
||
- `server/supabase/functions/stripe-webhook/index.ts` — checkout.session.completed, subscription.* 이벤트 → subscriptions 업데이트
|
||
- `config.toml`에 두 함수 등록 (webhook verify_jwt=false)
|
||
|
||
**사용자 액션 필요**
|
||
- `STRIPE_SECRET_KEY` / `STRIPE_PRICE_PRO` / `STRIPE_PRICE_TEAM` / `STRIPE_WEBHOOK_SECRET` 설정
|
||
- Stripe 대시보드에서 product + price 생성
|
||
|
||
**미구현 (V2-8b)**
|
||
- Webhook signature 정식 검증 (현재 placeholder)
|
||
- Customer Portal
|
||
- Per-seat billing
|
||
|
||
## 알려진 차단/이슈
|
||
|
||
- **@nut-tree-fork/nut-js 포크 사용** (V1 잔여 이슈)
|
||
- **Ollama 의존성**: 없으면 LLM 기능 사용 불가 (2026-04-08 자동 실행 기능 추가로 UX 개선)
|
||
|
||
## 디렉토리 구조 (V2-1a 목표)
|
||
|
||
```
|
||
d3ro-voice/ # 루트 (monorepo)
|
||
├── apps/
|
||
│ └── desktop/ # 기존 V1 전체
|
||
│ ├── src/
|
||
│ ├── resources/
|
||
│ ├── build/
|
||
│ ├── package.json # name: @d3ro/desktop
|
||
│ ├── electron.vite.config.ts
|
||
│ ├── electron-builder.yml
|
||
│ ├── tsconfig*.json
|
||
│ └── drizzle.config.ts
|
||
├── packages/ # V2-1b/c/d에서 채움
|
||
├── site/ # 랜딩 페이지 (루트 유지)
|
||
├── docs/ # 설계서/페이즈 문서
|
||
├── memory/ # 이 파일
|
||
├── pnpm-workspace.yaml # 또는 package.json "workspaces"
|
||
├── turbo.json
|
||
├── package.json # 루트 workspace
|
||
├── tsconfig.base.json
|
||
└── CLAUDE.md
|
||
```
|