Phase 5 구현: SQLite DB + History/Dictionary 서비스 + UI

- DB: better-sqlite3 + drizzle-orm (history/dictionary/stats, WAL 모드)
- HistoryService: CRUD + 검색 + 통계 + 30일 보존 정책, 세션 완료 시 자동 저장
- DictionaryService: CRUD + 검색 + 사용 횟수 추적 + STT 프롬프트 힌트
- HistoryPage: 목록 + 검색 + 삭제 + 복사 + 페이지네이션
- DictionaryPage: 목록 + 검색 + 추가 다이얼로그 + 삭제
- DashboardPage: 실데이터 통계 (총/오늘 세션, 시간, 단어수, 연속일수)
- IPC: history/dictionary/stats 핸들러 + preload API
- Bootstrap: DB 초기화 (critical) + 이력 자동 저장 연동
This commit is contained in:
Yun Chan 2026-04-05 02:32:53 +09:00
parent 4a5cf6c819
commit 291e2a29d0
17 changed files with 3111 additions and 35 deletions

View file

@ -6,6 +6,8 @@ import { initConfigService } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getVoiceModeService } from './services/VoiceModeService'
import { getLocalLLMService } from './services/LocalLLMService'
import { getHistoryService } from './services/HistoryService'
import { initDatabase } from './db'
import {
createMainWindow,
preloadPopupWindows,
@ -30,6 +32,7 @@ export async function bootstrap(): Promise<void> {
const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig },
{ name: 'database', critical: true, fn: initDB },
{ name: 'create-windows', critical: true, fn: createWindows },
{ name: 'tray', critical: false, fn: initTray },
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
@ -65,6 +68,10 @@ async function initConfig(): Promise<void> {
initConfigService()
}
async function initDB(): Promise<void> {
initDatabase()
}
async function createWindows(): Promise<void> {
createMainWindow()
}
@ -106,11 +113,26 @@ async function initVoiceMode(): Promise<void> {
}
})
voiceMode.on('session-completed', ({ finalText }) => {
voiceMode.on('session-completed', ({ session, finalText }) => {
hideRecordingTip()
if (finalText.length > 0) {
showResultPopup(finalText)
}
// 이력 저장
try {
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
getHistoryService().create({
originalText: session.transcription || finalText,
polishedText: session.processedText,
mode: session.mode === 'hands-free' ? 'dictation' : session.mode,
status: 'completed',
duration: (Date.now() - session.startedAt) / 1000,
wordCount
})
} catch (error) {
logger.warn(`Failed to save history: ${error instanceof Error ? error.message : String(error)}`)
}
})
voiceMode.on('session-cancelled', () => {

107
src/main/db/index.ts Normal file
View file

@ -0,0 +1,107 @@
// src/main/db/index.ts
// 설계서 03의 DB 초기화 코드
import Database from 'better-sqlite3'
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { app } from 'electron'
import path from 'path'
import * as schema from './schema'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('database')
let db: BetterSQLite3Database<typeof schema> | null = null
let sqlite: Database.Database | null = null
export function initDatabase(): BetterSQLite3Database<typeof schema> {
const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db')
logger.info(`Initializing database at: ${dbPath}`)
sqlite = new Database(dbPath)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.pragma('busy_timeout = 5000')
// 테이블 생성 (마이그레이션 대신 직접 생성)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
original_text TEXT NOT NULL,
polished_text TEXT,
focused_app TEXT,
focused_app_name TEXT,
focused_app_window_title TEXT,
mode TEXT NOT NULL DEFAULT 'dictation',
status TEXT NOT NULL DEFAULT 'completed',
error_code TEXT,
audio_local_path TEXT,
duration REAL NOT NULL,
detected_language TEXT,
mic_device TEXT,
word_count INTEGER NOT NULL DEFAULT 0,
stt_model TEXT,
llm_model TEXT,
stt_latency_ms INTEGER,
llm_latency_ms INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
app_version TEXT NOT NULL DEFAULT '1.0.0'
);
CREATE INDEX IF NOT EXISTS idx_history_created_at ON history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_status ON history(status);
CREATE INDEX IF NOT EXISTS idx_history_detected_language ON history(detected_language);
CREATE INDEX IF NOT EXISTS idx_history_focused_app_name ON history(focused_app_name);
CREATE INDEX IF NOT EXISTS idx_history_mode ON history(mode);
CREATE TABLE IF NOT EXISTS dictionary (
id TEXT PRIMARY KEY,
word TEXT NOT NULL,
pronunciation TEXT,
category TEXT NOT NULL DEFAULT 'user',
usage_count INTEGER NOT NULL DEFAULT 0,
last_used_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_dictionary_word_category ON dictionary(word, category);
CREATE INDEX IF NOT EXISTS idx_dictionary_created_at ON dictionary(created_at);
CREATE INDEX IF NOT EXISTS idx_dictionary_usage_count ON dictionary(usage_count DESC);
CREATE TABLE IF NOT EXISTS stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_duration REAL NOT NULL DEFAULT 0,
total_words INTEGER NOT NULL DEFAULT 0,
session_count INTEGER NOT NULL DEFAULT 0,
streak_days INTEGER NOT NULL DEFAULT 0,
last_session_at INTEGER,
last_updated INTEGER NOT NULL
);
INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated)
VALUES (1, 0, 0, 0, 0, ${Date.now()});
`)
db = drizzle(sqlite, { schema })
logger.info('Database initialized')
return db
}
export function getDatabase(): BetterSQLite3Database<typeof schema> {
if (!db) {
throw new Error('Database not initialized. Call initDatabase() first.')
}
return db
}
export function closeDatabase(): void {
if (sqlite) {
sqlite.close()
sqlite = null
db = null
logger.info('Database closed')
}
}

83
src/main/db/schema.ts Normal file
View file

@ -0,0 +1,83 @@
// src/main/db/schema.ts
// 설계서 03의 drizzle-orm 스키마 정의
import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'
// ── history ──────────────────────────────────────────────
export const history = sqliteTable(
'history',
{
id: text('id').primaryKey(),
originalText: text('original_text').notNull(),
polishedText: text('polished_text'),
focusedApp: text('focused_app'),
focusedAppName: text('focused_app_name'),
focusedAppWindowTitle: text('focused_app_window_title'),
mode: text('mode', { enum: ['dictation', 'translate', 'command'] })
.notNull()
.default('dictation'),
status: text('status', { enum: ['completed', 'cancelled', 'error'] })
.notNull()
.default('completed'),
errorCode: text('error_code'),
audioLocalPath: text('audio_local_path'),
duration: real('duration').notNull(),
detectedLanguage: text('detected_language'),
micDevice: text('mic_device'),
wordCount: integer('word_count').notNull().default(0),
sttModel: text('stt_model'),
llmModel: text('llm_model'),
sttLatencyMs: integer('stt_latency_ms'),
llmLatencyMs: integer('llm_latency_ms'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
appVersion: text('app_version').notNull().default('1.0.0')
},
(table) => [
index('idx_history_created_at').on(table.createdAt),
index('idx_history_status').on(table.status),
index('idx_history_detected_language').on(table.detectedLanguage),
index('idx_history_focused_app_name').on(table.focusedAppName),
index('idx_history_mode').on(table.mode)
]
)
// ── dictionary ───────────────────────────────────────────
export const dictionary = sqliteTable(
'dictionary',
{
id: text('id').primaryKey(),
word: text('word').notNull(),
pronunciation: text('pronunciation'),
category: text('category', { enum: ['user', 'auto', 'technical'] })
.notNull()
.default('user'),
usageCount: integer('usage_count').notNull().default(0),
lastUsedAt: integer('last_used_at'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull()
},
(table) => [
uniqueIndex('idx_dictionary_word_category').on(table.word, table.category),
index('idx_dictionary_created_at').on(table.createdAt),
index('idx_dictionary_usage_count').on(table.usageCount)
]
)
// ── stats ────────────────────────────────────────────────
export const stats = sqliteTable('stats', {
id: integer('id').primaryKey(),
totalDuration: real('total_duration').notNull().default(0),
totalWords: integer('total_words').notNull().default(0),
sessionCount: integer('session_count').notNull().default(0),
streakDays: integer('streak_days').notNull().default(0),
lastSessionAt: integer('last_session_at'),
lastUpdated: integer('last_updated').notNull()
})
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect
export type NewHistory = typeof history.$inferInsert
export type Dictionary = typeof dictionary.$inferSelect
export type NewDictionary = typeof dictionary.$inferInsert
export type Stats = typeof stats.$inferSelect

View file

@ -0,0 +1,56 @@
// src/main/ipc/dictionary-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getDictionaryService } from '../services/DictionaryService'
import type {
DictionaryQueryParams,
DictionaryAddParams,
DictionaryUpdateParams,
DictionaryDeleteParams,
DictionarySearchParams
} from '@shared/types'
export function registerDictionaryHandlers(): void {
ipcMain.handle(IPC_CHANNELS.DICTIONARY.GET_ALL, async (_event, params: DictionaryQueryParams) => {
try {
return ipcSuccess(getDictionaryService().list(params))
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get dictionary')
}
})
ipcMain.handle(IPC_CHANNELS.DICTIONARY.ADD, async (_event, params: DictionaryAddParams) => {
try {
return ipcSuccess(getDictionaryService().add(params))
} catch {
return ipcError(ErrorCode.DBWriteFailed, 'Failed to add dictionary entry')
}
})
ipcMain.handle(IPC_CHANNELS.DICTIONARY.UPDATE, async (_event, params: DictionaryUpdateParams) => {
try {
return ipcSuccess(getDictionaryService().update(params))
} catch {
return ipcError(ErrorCode.DBWriteFailed, 'Failed to update dictionary entry')
}
})
ipcMain.handle(IPC_CHANNELS.DICTIONARY.DELETE, async (_event, params: DictionaryDeleteParams) => {
try {
getDictionaryService().delete(params.id)
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete dictionary entry')
}
})
ipcMain.handle(IPC_CHANNELS.DICTIONARY.SEARCH, async (_event, params: DictionarySearchParams) => {
try {
return ipcSuccess(getDictionaryService().search(params))
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to search dictionary')
}
})
}

View file

@ -0,0 +1,64 @@
// src/main/ipc/history-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getHistoryService } from '../services/HistoryService'
import type {
HistoryQueryParams,
HistoryGetByIdParams,
HistoryDeleteParams,
HistorySearchParams
} from '@shared/types'
export function registerHistoryHandlers(): void {
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_ALL, async (_event, params: HistoryQueryParams) => {
try {
return ipcSuccess(getHistoryService().list(params))
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get history')
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.GET_BY_ID, async (_event, params: HistoryGetByIdParams) => {
try {
return ipcSuccess(getHistoryService().getById(params.id))
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get history entry')
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.DELETE, async (_event, params: HistoryDeleteParams) => {
try {
getHistoryService().delete(params.id)
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete history entry')
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.DELETE_ALL, async () => {
try {
getHistoryService().deleteAll()
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.DBWriteFailed, 'Failed to delete all history')
}
})
ipcMain.handle(IPC_CHANNELS.HISTORY.SEARCH, async (_event, params: HistorySearchParams) => {
try {
return ipcSuccess(getHistoryService().search(params))
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to search history')
}
})
ipcMain.handle(IPC_CHANNELS.STATS.GET_SUMMARY, async () => {
try {
return ipcSuccess(getHistoryService().getStats())
} catch {
return ipcError(ErrorCode.DBQueryFailed, 'Failed to get stats')
}
})
}

View file

@ -8,6 +8,8 @@ import { registerVoiceHandlers } from './voice-handlers'
import { registerSTTHandlers } from './stt-handlers'
import { registerHotkeyHandlers } from './hotkey-handlers'
import { registerLLMHandlers } from './llm-handlers'
import { registerHistoryHandlers } from './history-handlers'
import { registerDictionaryHandlers } from './dictionary-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -21,5 +23,7 @@ export function registerAllIpcHandlers(): void {
registerSTTHandlers()
registerHotkeyHandlers()
registerLLMHandlers()
registerHistoryHandlers()
registerDictionaryHandlers()
logger.info('All IPC handlers registered')
}

View file

@ -0,0 +1,170 @@
// src/main/services/DictionaryService.ts
// 사용자 커스텀 단어 사전. 설계서 01/03 IDictionaryService 구현.
import { eq, like, desc, count, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getDatabase } from '../db'
import { dictionary } from '../db/schema'
import type { Dictionary, NewDictionary } from '../db/schema'
import { getLogger } from './LoggerService'
import type {
DictionaryEntry,
DictionaryQueryParams,
DictionaryPage,
DictionaryAddParams,
DictionaryUpdateParams,
DictionarySearchParams
} from '@shared/types'
const logger = getLogger('DictionaryService')
class DictionaryService {
add(params: DictionaryAddParams): DictionaryEntry {
const db = getDatabase()
const now = Date.now()
const id = nanoid()
const entry: NewDictionary = {
id,
word: params.word,
pronunciation: params.pronunciation ?? null,
category: params.category ?? 'user',
usageCount: 0,
lastUsedAt: null,
createdAt: now,
updatedAt: now
}
db.insert(dictionary).values(entry).run()
logger.info(`Dictionary entry added: "${params.word}"`)
return this._toEntry(entry as Dictionary)
}
update(params: DictionaryUpdateParams): DictionaryEntry | null {
const db = getDatabase()
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
if (!existing) return null
const updates: Partial<NewDictionary> = { updatedAt: Date.now() }
if (params.word !== undefined) updates.word = params.word
if (params.pronunciation !== undefined) updates.pronunciation = params.pronunciation
if (params.category !== undefined) updates.category = params.category
db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run()
const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
return updated ? this._toEntry(updated) : null
}
delete(id: string): boolean {
const db = getDatabase()
const result = db.delete(dictionary).where(eq(dictionary.id, id)).run()
return result.changes > 0
}
list(params: DictionaryQueryParams): DictionaryPage {
const db = getDatabase()
const { page, pageSize } = params
const totalResult = db.select({ count: count() }).from(dictionary).get()
const total = totalResult?.count ?? 0
const entries = db
.select()
.from(dictionary)
.orderBy(desc(dictionary.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: DictionarySearchParams): DictionaryPage {
const db = getDatabase()
const { query, page, pageSize } = params
const pattern = `%${query}%`
const totalResult = db
.select({ count: count() })
.from(dictionary)
.where(like(dictionary.word, pattern))
.get()
const total = totalResult?.count ?? 0
const entries = db
.select()
.from(dictionary)
.where(like(dictionary.word, pattern))
.orderBy(desc(dictionary.usageCount))
.limit(pageSize)
.offset(page * pageSize)
.all()
return {
entries: entries.map((e) => this._toEntry(e)),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
}
}
incrementUsage(id: string): void {
const db = getDatabase()
db.update(dictionary)
.set({
usageCount: sql`usage_count + 1`,
lastUsedAt: Date.now()
})
.where(eq(dictionary.id, id))
.run()
}
/**
* STT initialPrompt .
*/
getPromptHints(limit = 50): string {
const db = getDatabase()
const words = db
.select({ word: dictionary.word })
.from(dictionary)
.orderBy(desc(dictionary.usageCount))
.limit(limit)
.all()
return words.map((w) => w.word).join(', ')
}
dispose(): void {
logger.info('DictionaryService disposed')
}
private _toEntry(row: Dictionary): DictionaryEntry {
return {
id: row.id,
word: row.word,
pronunciation: row.pronunciation,
category: row.category as DictionaryEntry['category'],
usageCount: row.usageCount,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt
}
}
}
let instance: DictionaryService | null = null
export function getDictionaryService(): DictionaryService {
if (!instance) {
instance = new DictionaryService()
}
return instance
}

View file

@ -0,0 +1,219 @@
// src/main/services/HistoryService.ts
// SQLite 기반 전사/명령 이력 저장. 설계서 01/03 IHistoryService 구현.
import { eq, desc, like, and, sql, count } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getDatabase } from '../db'
import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
import { getLogger } from './LoggerService'
import type {
HistoryEntry,
HistoryQueryParams,
HistoryPage,
HistorySearchParams,
StatsSummary
} from '@shared/types'
const logger = getLogger('HistoryService')
class HistoryService {
create(input: Omit<NewHistory, 'id' | 'createdAt' | 'updatedAt'>): HistoryEntry {
const db = getDatabase()
const now = Date.now()
const id = nanoid()
const entry: NewHistory = {
id,
...input,
createdAt: now,
updatedAt: now
}
db.insert(history).values(entry).run()
// stats 싱글톤 업데이트
this._updateStats(input.duration, input.wordCount)
logger.info(`History entry created: ${id}`)
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()
}
private _toEntry(row: History): 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: HistoryService | null = null
export function getHistoryService(): HistoryService {
if (!instance) {
instance = new HistoryService()
}
return instance
}

View file

@ -26,3 +26,5 @@ export { getVoiceModeService } from './VoiceModeService'
export { getAudioCaptureService } from './AudioCaptureService'
export { getTextInsertService } from './TextInsertService'
export { getLocalLLMService } from './LocalLLMService'
export { getHistoryService } from './HistoryService'
export { getDictionaryService } from './DictionaryService'