d3ro-voice/apps/desktop/src/main/services/CustomInstructionService.ts
윤찬 e55687d298 fix(desktop+supabase): 로컬 ID UUID 통일 + Realtime publication (빅뱅 Phase 5 Part 2)
SaaS [9] — 실증 A/B/C/D 전부 통과, 로컬→클라우드 push 최초 성공(pushed=1).

## Bug 4: 로컬 nanoid PK vs Supabase UUID PK 불일치
- 증상: Push history failed: invalid input syntax for type uuid: "fvy6bIzr..."
- 원인: 로컬 drizzle schema는 text PK + nanoid() 생성, Supabase는 uuid PK.
  빅뱅 사이클 내내 push가 한 번도 성공한 적 없었음 (지난 pushed=0은 데이터 0건이라서).
- 픽스: 로컬을 UUID로 통일 (근본 해결, 땜질 금지).
  14개 서비스 20곳 nanoid() → crypto.randomUUID() 일괄 교체.
  nanoid 의존성 + electron.vite.config exclude 제거.
  drizzle schema는 text PK 그대로 유지 (SQLite는 UUID 문자열 저장 가능).

## Bug 5: supabase_realtime publication 누락
- 증상: 로그인 직후 Realtime 채널 상태: TIMED_OUT
- 원인: initial_schema.sql이 transcripts 테이블만 publication에 추가.
  데스크톱이 구독하는 meetings/history/dictionary는 누락 → postgres_changes 흐르지 않음.
- 픽스: 20260411000002_realtime_publication.sql 신규.
  pg_publication_tables 카탈로그 체크 + 조건부 ADD TABLE (meetings/meeting_memos/
  meeting_documents/history/dictionary 5개). supabase db push 적용.

## Bug 6: persistSession:false에서 realtime.setAuth 자동 전파 안 됨 (부분 픽스)
- 픽스: CloudSyncService.startRealtime()에 client.realtime.setAuth(access_token)
  명시 호출 (채널 구성 이전).
- ⚠️ Bug 5+6 적용 후에도 Realtime 여전히 TIMED_OUT. 후속 조사 필요.
  블로커 아님 — 주기 pull + Phase 3.3 auto push로 최종 일관성 유지.

## 실증 결과
- A 세션 자동 복원: Restored session for yunchan8804@gmail.com → DB 재오픈
- B push 경로: HistoryService created 56a767ac-... → Sync complete pushed=1 errors=0
- C 로그아웃 복귀: Realtime 종료 → users/_local/d3ro.db 복귀 → local mode
- D 재로그인 복원: 실증 A의 restore 경로와 동일, 같은 uuid DB 파일 보존
- E 웹 크로스 디바이스: Phase 3.3 이후로 지연 (Realtime 이슈 별건)

검증: desktop tsc --noEmit , dev 재기동 , push 최초 성공 
2026-04-11 18:46:22 +09:00

241 lines
7 KiB
TypeScript

// src/main/services/CustomInstructionService.ts
// 사용자 정의 LLM 명령어 관리. 설계서 01/Phase 6 참조.
// electron-store에 저장, 프리셋 5개 기본 제공.
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
const logger = getLogger('CustomInstructionService')
// ============================================================
// 타입
// ============================================================
export interface CustomInstruction {
id: string
name: string
description: string
prompt: string
icon: string
isBuiltin: boolean
order: number
createdAt: number
updatedAt: number
}
type CreateInput = Omit<CustomInstruction, 'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt'>
// ============================================================
// 프리셋 명령어 (Phase 6 설계)
// ============================================================
const BUILTIN_INSTRUCTIONS: ReadonlyArray<Omit<CustomInstruction, 'createdAt' | 'updatedAt'>> = [
{
id: 'builtin-translate',
name: '번역',
description: '텍스트를 다른 언어로 번역',
prompt: '다음 텍스트를 {{targetLanguage}}로 번역해주세요.\n자연스럽고 정확한 번역만 출력하세요.',
icon: 'Translate',
isBuiltin: true,
order: 0
},
{
id: 'builtin-summarize',
name: '요약',
description: '핵심 내용을 3줄 이내로 요약',
prompt: '다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.\n요약문만 출력하세요.',
icon: 'Summarize',
isBuiltin: true,
order: 1
},
{
id: 'builtin-formal',
name: '전문 리라이트',
description: '격식 있는 비즈니스 문체로 변환',
prompt: '다음 텍스트를 격식 있는 비즈니스 문체로 다시 작성해주세요.\n원래 의미를 유지하면서 전문적인 톤으로 변환하세요.\n다시 작성된 텍스트만 출력하세요.',
icon: 'Business',
isBuiltin: true,
order: 2
},
{
id: 'builtin-explain-code',
name: '코드 설명',
description: '코드를 한국어로 설명',
prompt: '다음 코드를 한국어로 설명해주세요.\n각 부분이 무엇을 하는지 간결하게 설명하세요.',
icon: 'Code',
isBuiltin: true,
order: 3
},
{
id: 'builtin-free-prompt',
name: '자유 프롬프트',
description: '직접 프롬프트를 입력',
prompt: '{{userPrompt}}',
icon: 'Edit',
isBuiltin: true,
order: 4
}
]
// ============================================================
// 저장소 (electron-store 사용)
// ============================================================
// electron-store 대신 간단한 JSON 파일 사용 (ConfigService와 별도)
// 실제로는 electron-store의 별도 인스턴스를 사용하지만,
// Phase 6에서는 메모리 + configGet/configSet 패턴으로 단순화
let instructions: CustomInstruction[] = []
let initialized = false
function loadInstructions(): CustomInstruction[] {
// electron-store에서 로드 시도
try {
const stored = configGet('customInstructions' as never) as CustomInstruction[] | undefined
if (Array.isArray(stored) && stored.length > 0) {
return stored
}
} catch {
// 첫 실행 시 키가 없을 수 있음
}
// 프리셋으로 초기화
const now = Date.now()
return BUILTIN_INSTRUCTIONS.map((b) => ({
...b,
createdAt: now,
updatedAt: now
}))
}
function saveInstructions(): void {
try {
const { configSet } = require('./ConfigService')
configSet('customInstructions' as never, instructions as never)
} catch (error) {
logger.warn(`Failed to save instructions: ${error instanceof Error ? error.message : String(error)}`)
}
}
// ============================================================
// CustomInstructionService
// ============================================================
class CustomInstructionService {
initialize(): void {
if (initialized) return
instructions = loadInstructions()
initialized = true
logger.info(`CustomInstructionService initialized (${instructions.length} instructions)`)
}
getAll(): CustomInstruction[] {
return [...instructions].sort((a, b) => a.order - b.order)
}
getById(id: string): CustomInstruction | null {
return instructions.find((i) => i.id === id) ?? null
}
create(input: CreateInput): CustomInstruction {
const now = Date.now()
const instruction: CustomInstruction = {
id: crypto.randomUUID(),
name: input.name,
description: input.description,
prompt: input.prompt,
icon: input.icon || 'Extension',
isBuiltin: false,
order: instructions.length,
createdAt: now,
updatedAt: now
}
instructions.push(instruction)
saveInstructions()
logger.info(`Custom instruction created: "${instruction.name}"`)
return instruction
}
update(id: string, data: Partial<Omit<CustomInstruction, 'id' | 'isBuiltin' | 'createdAt'>>): CustomInstruction | null {
const index = instructions.findIndex((i) => i.id === id)
if (index === -1) return null
const existing = instructions[index]
// 프리셋은 프롬프트만 수정 가능
if (existing.isBuiltin) {
if (data.prompt !== undefined) {
instructions[index] = { ...existing, prompt: data.prompt, updatedAt: Date.now() }
}
} else {
instructions[index] = { ...existing, ...data, updatedAt: Date.now() }
}
saveInstructions()
return instructions[index]
}
delete(id: string): boolean {
const index = instructions.findIndex((i) => i.id === id)
if (index === -1) return false
// 프리셋은 삭제 불가
if (instructions[index].isBuiltin) {
logger.warn(`Cannot delete builtin instruction: ${id}`)
return false
}
instructions.splice(index, 1)
saveInstructions()
logger.info(`Custom instruction deleted: ${id}`)
return true
}
reorder(ids: string[]): void {
const reordered: CustomInstruction[] = []
for (let i = 0; i < ids.length; i++) {
const inst = instructions.find((item) => item.id === ids[i])
if (inst) {
reordered.push({ ...inst, order: i })
}
}
// ids에 포함되지 않은 항목 추가
for (const inst of instructions) {
if (!ids.includes(inst.id)) {
reordered.push({ ...inst, order: reordered.length })
}
}
instructions = reordered
saveInstructions()
}
resetBuiltins(): void {
const now = Date.now()
const userInstructions = instructions.filter((i) => !i.isBuiltin)
const builtins = BUILTIN_INSTRUCTIONS.map((b) => ({
...b,
createdAt: now,
updatedAt: now
}))
instructions = [...builtins, ...userInstructions]
saveInstructions()
logger.info('Builtin instructions reset')
}
dispose(): void {
logger.info('CustomInstructionService disposed')
}
}
let instance: CustomInstructionService | null = null
export function getCustomInstructionService(): CustomInstructionService {
if (!instance) {
instance = new CustomInstructionService()
}
return instance
}