d3ro-voice/apps/desktop/src/main/services/HistoryService.ts
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

266 lines
7.5 KiB
TypeScript

// src/main/services/HistoryService.ts
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
import { getDatabase } from '../db'
import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import type {
HistoryEntry,
HistoryQueryParams,
HistoryPage,
HistorySearchParams,
StatsSummary
} from '@d3ro/core/types'
const logger = getLogger('HistoryService')
class HistoryService {
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
const db = getDatabase()
const now = Date.now()
const id = crypto.randomUUID()
const entry: NewHistory = {
id,
...input,
createdAt: now,
updatedAt: now
}
db.insert(history).values(entry).run()
// stats 싱글톤 업데이트
this._updateStats(input.duration ?? 0, input.wordCount ?? 0)
logger.info(`History entry created: ${id}`)
// Phase 3.3: 로그인 상태면 자동 push (fire-and-forget, 로컬 write는 차단하지 않음)
void getCloudSyncService().pushOne('history', id)
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
this.generateTitle(id).catch(() => { /* ignore */ })
return this._toEntry(entry as History)
}
getById(id: string): HistoryEntry | null {
const db = getDatabase()
const row = db.select().from(history).where(eq(history.id, id)).get()
return row ? this._toEntry(row) : null
}
list(params: HistoryQueryParams): HistoryPage {
const db = getDatabase()
const { page, pageSize } = params
const totalResult = db.select({ count: count() }).from(history).get()
const total = totalResult?.count ?? 0
const entries = db
.select()
.from(history)
.orderBy(desc(history.createdAt))
.limit(pageSize)
.offset(page * pageSize)
.all()
return {
entries: entries.map((e) => this._toEntry(e)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
}
search(params: HistorySearchParams): HistoryPage {
const db = getDatabase()
const { query, page, pageSize } = params
const pattern = `%${query}%`
const conditions = and(
like(history.originalText, pattern)
)
const totalResult = db
.select({ count: count() })
.from(history)
.where(conditions)
.get()
const total = totalResult?.count ?? 0
const entries = db
.select()
.from(history)
.where(conditions)
.orderBy(desc(history.createdAt))
.limit(pageSize)
.offset(page * pageSize)
.all()
return {
entries: entries.map((e) => this._toEntry(e)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
}
delete(id: string): boolean {
const db = getDatabase()
const result = db.delete(history).where(eq(history.id, id)).run()
return result.changes > 0
}
deleteAll(): void {
const db = getDatabase()
db.delete(history).run()
logger.info('All history entries deleted')
}
getStats(): StatsSummary {
const db = getDatabase()
const row = db.select().from(stats).where(eq(stats.id, 1)).get()
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayMs = todayStart.getTime()
const todayResult = db
.select({
sessions: count(),
duration: sql<number>`COALESCE(SUM(duration), 0)`,
words: sql<number>`COALESCE(SUM(word_count), 0)`
})
.from(history)
.where(
and(
sql`created_at >= ${todayMs}`,
eq(history.status, 'completed')
)
)
.get()
return {
totalRecordingTimeMs: (row?.totalDuration ?? 0) * 1000,
totalWordCount: row?.totalWords ?? 0,
totalSessionCount: row?.sessionCount ?? 0,
todayRecordingTimeMs: (todayResult?.duration ?? 0) * 1000,
todayWordCount: todayResult?.words ?? 0,
todaySessionCount: todayResult?.sessions ?? 0,
streakDays: row?.streakDays ?? 0
}
}
/**
* 보존 정책에 따라 오래된 항목 정리 (30일 초과)
*/
runRetentionCleanup(): number {
const db = getDatabase()
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
const result = db.delete(history).where(sql`created_at < ${cutoff}`).run()
if (result.changes > 0) {
logger.info(`Retention cleanup: ${result.changes} old entries removed`)
}
return result.changes
}
dispose(): void {
logger.info('HistoryService disposed')
}
private _updateStats(duration: number, wordCount: number): void {
const db = getDatabase()
const now = Date.now()
db.update(stats)
.set({
totalDuration: sql`total_duration + ${duration}`,
totalWords: sql`total_words + ${wordCount}`,
sessionCount: sql`session_count + 1`,
lastSessionAt: now,
lastUpdated: now
})
.where(eq(stats.id, 1))
.run()
}
/** 전사 완료 후 LLM으로 자동 타이틀 생성 */
async generateTitle(id: string): Promise<string | null> {
const entry = this.getById(id)
if (!entry) return null
const text = entry.polishedText || entry.originalText
if (!text || text.length < 10) return null
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const llm = getPremiumLLMService()
const result = await llm.generate(
text.slice(0, 2000),
{
systemPrompt: '다음 텍스트의 핵심 내용을 10단어 이내의 짧은 제목으로 만들어주세요. 제목만 출력하세요. 따옴표나 마침표를 붙이지 마세요.',
temperature: 0.3,
maxTokens: 50,
},
)
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
if (title) {
const db = getDatabase()
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
logger.info(`Auto title generated: ${id} → "${title}"`)
// Phase 3.3: 타이틀 업데이트 후 자동 push
void getCloudSyncService().pushOne('history', id)
return title
}
} catch (err) {
logger.warn(`Auto title generation failed: ${err instanceof Error ? err.message : String(err)}`)
}
return null
}
private _toEntry(row: History): HistoryEntry {
return {
id: row.id,
title: row.title ?? null,
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,
summaryText: row.summaryText ?? null,
}
}
}
let instance: HistoryService | null = null
export function getHistoryService(): HistoryService {
if (!instance) {
instance = new HistoryService()
}
return instance
}
export function resetHistoryServiceForTests(): void {
instance = null
}