Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
364
src/main/services/MemoService.ts
Normal file
364
src/main/services/MemoService.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
// src/main/services/MemoService.ts
|
||||
// Phase 10.3: 음성 메모 태그 시스템. 히스토리 항목에 태그를 부착하고 태그별 검색/내보내기를 지원한다.
|
||||
|
||||
import { eq, and, desc, count, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { getDatabase } from '../db'
|
||||
import { memoTags, history } from '../db/schema'
|
||||
import type { MemoTagRow } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type {
|
||||
MemoTag,
|
||||
TagCount,
|
||||
SearchByTagParams,
|
||||
ExportMemoParams,
|
||||
HistoryEntry,
|
||||
HistoryPage
|
||||
} from '@shared/types'
|
||||
|
||||
const logger = getLogger('MemoService')
|
||||
|
||||
class MemoService {
|
||||
/**
|
||||
* 특정 히스토리 항목에 부착된 태그 목록을 조회한다.
|
||||
*/
|
||||
getTagsForEntry(historyId: string): MemoTag[] {
|
||||
const db = getDatabase()
|
||||
const rows = db
|
||||
.select()
|
||||
.from(memoTags)
|
||||
.where(eq(memoTags.historyId, historyId))
|
||||
.orderBy(desc(memoTags.createdAt))
|
||||
.all()
|
||||
|
||||
return rows.map((r) => this._toMemoTag(r))
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 항목에 태그를 추가한다.
|
||||
* 동일 historyId+tag 조합이 이미 존재하면 MemoTagDuplicate 에러를 던진다.
|
||||
*/
|
||||
addTag(historyId: string, tag: string): MemoTag {
|
||||
const db = getDatabase()
|
||||
const normalizedTag = tag.trim().toLowerCase()
|
||||
|
||||
// 중복 검사
|
||||
const existing = db
|
||||
.select()
|
||||
.from(memoTags)
|
||||
.where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag)))
|
||||
.get()
|
||||
|
||||
if (existing) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MemoTagDuplicate,
|
||||
`Tag "${normalizedTag}" already exists for history ${historyId}`
|
||||
)
|
||||
}
|
||||
|
||||
const id = nanoid()
|
||||
const now = Date.now()
|
||||
|
||||
db.insert(memoTags)
|
||||
.values({
|
||||
id,
|
||||
historyId,
|
||||
tag: normalizedTag,
|
||||
createdAt: now
|
||||
})
|
||||
.run()
|
||||
|
||||
logger.info(`Tag added: "${normalizedTag}" → history ${historyId}`)
|
||||
|
||||
return {
|
||||
id,
|
||||
historyId,
|
||||
tag: normalizedTag,
|
||||
createdAt: now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 히스토리 항목에서 태그를 제거한다.
|
||||
* 존재하지 않는 태그이면 MemoTagNotFound 에러를 던진다.
|
||||
*/
|
||||
removeTag(historyId: string, tag: string): void {
|
||||
const db = getDatabase()
|
||||
const normalizedTag = tag.trim().toLowerCase()
|
||||
|
||||
const result = db
|
||||
.delete(memoTags)
|
||||
.where(and(eq(memoTags.historyId, historyId), eq(memoTags.tag, normalizedTag)))
|
||||
.run()
|
||||
|
||||
if (result.changes === 0) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MemoTagNotFound,
|
||||
`Tag "${normalizedTag}" not found for history ${historyId}`
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`Tag removed: "${normalizedTag}" from history ${historyId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 태그 목록을 사용 횟수 내림차순으로 반환한다.
|
||||
*/
|
||||
getAllTags(): TagCount[] {
|
||||
const db = getDatabase()
|
||||
const rows = db
|
||||
.select({
|
||||
tag: memoTags.tag,
|
||||
count: count()
|
||||
})
|
||||
.from(memoTags)
|
||||
.groupBy(memoTags.tag)
|
||||
.orderBy(desc(count()))
|
||||
.all()
|
||||
|
||||
return rows.map((r) => ({
|
||||
tag: r.tag,
|
||||
count: r.count
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 태그가 부착된 히스토리 항목을 페이지네이션으로 조회한다.
|
||||
*/
|
||||
searchByTag(params: SearchByTagParams): HistoryPage {
|
||||
const db = getDatabase()
|
||||
const { tag, page, pageSize } = params
|
||||
const normalizedTag = tag.trim().toLowerCase()
|
||||
|
||||
const totalResult = db
|
||||
.select({ count: count() })
|
||||
.from(memoTags)
|
||||
.innerJoin(history, eq(memoTags.historyId, history.id))
|
||||
.where(eq(memoTags.tag, normalizedTag))
|
||||
.get()
|
||||
|
||||
const total = totalResult?.count ?? 0
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
id: history.id,
|
||||
originalText: history.originalText,
|
||||
polishedText: history.polishedText,
|
||||
focusedApp: history.focusedApp,
|
||||
focusedAppName: history.focusedAppName,
|
||||
focusedAppWindowTitle: history.focusedAppWindowTitle,
|
||||
mode: history.mode,
|
||||
status: history.status,
|
||||
errorCode: history.errorCode,
|
||||
audioLocalPath: history.audioLocalPath,
|
||||
duration: history.duration,
|
||||
detectedLanguage: history.detectedLanguage,
|
||||
micDevice: history.micDevice,
|
||||
wordCount: history.wordCount,
|
||||
sttModel: history.sttModel,
|
||||
llmModel: history.llmModel,
|
||||
sttLatencyMs: history.sttLatencyMs,
|
||||
llmLatencyMs: history.llmLatencyMs,
|
||||
createdAt: history.createdAt,
|
||||
updatedAt: history.updatedAt,
|
||||
appVersion: history.appVersion
|
||||
})
|
||||
.from(memoTags)
|
||||
.innerJoin(history, eq(memoTags.historyId, history.id))
|
||||
.where(eq(memoTags.tag, normalizedTag))
|
||||
.orderBy(desc(history.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(page * pageSize)
|
||||
.all()
|
||||
|
||||
return {
|
||||
entries: rows.map((r) => this._toHistoryEntry(r)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그+날짜 기준으로 그룹핑된 마크다운 파일을 생성하고 파일 경로를 반환한다.
|
||||
*/
|
||||
exportMarkdown(params: ExportMemoParams): string {
|
||||
const db = getDatabase()
|
||||
|
||||
// 태그별 히스토리 조회
|
||||
let tagFilter = params.tag
|
||||
? eq(memoTags.tag, params.tag.trim().toLowerCase())
|
||||
: undefined
|
||||
|
||||
const dateConditions: ReturnType<typeof sql>[] = []
|
||||
if (params.from) {
|
||||
const fromMs = new Date(params.from).getTime()
|
||||
dateConditions.push(sql`${history.createdAt} >= ${fromMs}`)
|
||||
}
|
||||
if (params.to) {
|
||||
const toMs = new Date(params.to).getTime()
|
||||
dateConditions.push(sql`${history.createdAt} <= ${toMs}`)
|
||||
}
|
||||
|
||||
const conditions = [tagFilter, ...dateConditions].filter(
|
||||
(c): c is NonNullable<typeof c> => c !== undefined
|
||||
)
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
tag: memoTags.tag,
|
||||
originalText: history.originalText,
|
||||
polishedText: history.polishedText,
|
||||
createdAt: history.createdAt,
|
||||
duration: history.duration
|
||||
})
|
||||
.from(memoTags)
|
||||
.innerJoin(history, eq(memoTags.historyId, history.id))
|
||||
.where(whereClause)
|
||||
.orderBy(memoTags.tag, desc(history.createdAt))
|
||||
.all()
|
||||
|
||||
// 태그별 → 날짜별 그룹핑
|
||||
const grouped = new Map<string, Map<string, typeof rows>>()
|
||||
for (const row of rows) {
|
||||
const dateKey = new Date(row.createdAt).toISOString().split('T')[0]
|
||||
if (!grouped.has(row.tag)) {
|
||||
grouped.set(row.tag, new Map())
|
||||
}
|
||||
const dateMap = grouped.get(row.tag)!
|
||||
if (!dateMap.has(dateKey)) {
|
||||
dateMap.set(dateKey, [])
|
||||
}
|
||||
dateMap.get(dateKey)!.push(row)
|
||||
}
|
||||
|
||||
// 마크다운 생성
|
||||
const lines: string[] = []
|
||||
const exportDate = new Date().toISOString().split('T')[0]
|
||||
lines.push(`# Voice Memo Export — ${exportDate}`)
|
||||
lines.push('')
|
||||
|
||||
if (grouped.size === 0) {
|
||||
lines.push('No memo entries found.')
|
||||
}
|
||||
|
||||
for (const [tag, dateMap] of grouped) {
|
||||
lines.push(`## #${tag}`)
|
||||
lines.push('')
|
||||
|
||||
for (const [date, entries] of dateMap) {
|
||||
lines.push(`### ${date}`)
|
||||
lines.push('')
|
||||
|
||||
for (const entry of entries) {
|
||||
const time = new Date(entry.createdAt).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const text = entry.polishedText ?? entry.originalText
|
||||
const durationSec = Math.round(entry.duration)
|
||||
lines.push(`- **${time}** (${durationSec}s): ${text}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 저장
|
||||
const exportDir = path.join(app.getPath('userData'), 'exports')
|
||||
if (!fs.existsSync(exportDir)) {
|
||||
fs.mkdirSync(exportDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
const tagSuffix = params.tag ? `_${params.tag}` : ''
|
||||
const filePath = path.join(exportDir, `memo${tagSuffix}_${timestamp}.md`)
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8')
|
||||
logger.info(`Memo exported to: ${filePath}`)
|
||||
return filePath
|
||||
} catch (err) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MemoExportFailed,
|
||||
`Failed to export memo: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
logger.info('MemoService disposed')
|
||||
}
|
||||
|
||||
private _toMemoTag(row: MemoTagRow): MemoTag {
|
||||
return {
|
||||
id: row.id,
|
||||
historyId: row.historyId,
|
||||
tag: row.tag,
|
||||
createdAt: row.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
private _toHistoryEntry(row: {
|
||||
id: string
|
||||
originalText: string
|
||||
polishedText: string | null
|
||||
focusedApp: string | null
|
||||
focusedAppName: string | null
|
||||
focusedAppWindowTitle: string | null
|
||||
mode: string
|
||||
status: string
|
||||
errorCode: string | null
|
||||
audioLocalPath: string | null
|
||||
duration: number
|
||||
detectedLanguage: string | null
|
||||
micDevice: string | null
|
||||
wordCount: number
|
||||
sttModel: string | null
|
||||
llmModel: string | null
|
||||
sttLatencyMs: number | null
|
||||
llmLatencyMs: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
appVersion: string
|
||||
}): HistoryEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
originalText: row.originalText,
|
||||
polishedText: row.polishedText,
|
||||
focusedApp: row.focusedApp,
|
||||
focusedAppName: row.focusedAppName,
|
||||
focusedAppWindowTitle: row.focusedAppWindowTitle,
|
||||
mode: row.mode as HistoryEntry['mode'],
|
||||
status: row.status as HistoryEntry['status'],
|
||||
errorCode: row.errorCode,
|
||||
audioLocalPath: row.audioLocalPath,
|
||||
duration: row.duration,
|
||||
detectedLanguage: row.detectedLanguage,
|
||||
micDevice: row.micDevice,
|
||||
wordCount: row.wordCount,
|
||||
sttModel: row.sttModel,
|
||||
llmModel: row.llmModel,
|
||||
sttLatencyMs: row.sttLatencyMs,
|
||||
llmLatencyMs: row.llmLatencyMs,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
appVersion: row.appVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let instance: MemoService | null = null
|
||||
|
||||
export function getMemoService(): MemoService {
|
||||
if (!instance) {
|
||||
instance = new MemoService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue