With a 100 ms trigger left in the installed config, generation started in the gaps between keystrokes; the next key ended the session silently, and that session still counted against the 5 s minimum interval, so the moment the user actually paused nothing came (rate-limited). The log showed a generate-then-vanish cycle every 5-6 s. The trigger delay now has a 500 ms floor and settings revision 6 resets a stored value below it to 600 ms. A session ended because the user kept typing no longer blocks the next request by the minimum interval (per-minute and daily caps still apply), and that dismissal is logged.
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
// src/main/services/ConfigService.ts
|
|
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
|
|
|
|
import { EventEmitter } from 'events'
|
|
import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
|
|
import {
|
|
bindingsEqual,
|
|
createDefaultBindingMap,
|
|
findActionSpec,
|
|
kb,
|
|
normalizeBinding,
|
|
parseBindingMap,
|
|
VK
|
|
} from '@d3ro/core/keybinding'
|
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|
import { getLogger } from './LoggerService'
|
|
|
|
const logger = getLogger('ConfigService')
|
|
|
|
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
|
|
const SUGGESTION_TUNING_REVISION = 6
|
|
|
|
const INITIAL_SUGGESTION_TUNING = {
|
|
suggestionTriggerDelayMs: 300,
|
|
suggestionMinPrefixChars: 8,
|
|
suggestionMaxRequestsPerMinute: 12,
|
|
suggestionDailyBudget: 500
|
|
} as const
|
|
|
|
/** 저장된 튜닝 값을 해당 개정판에서만 변경된 항목으로 올린다. */
|
|
function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
|
|
const raw = activeStore.store as unknown as Record<string, unknown>
|
|
const current = Number(raw.suggestionTuningRevision ?? 0)
|
|
if (Number.isFinite(current) && current >= SUGGESTION_TUNING_REVISION) return
|
|
|
|
if (current < 1) {
|
|
for (const [key, value] of Object.entries(INITIAL_SUGGESTION_TUNING)) {
|
|
activeStore.set(key as keyof AppConfig, value as AppConfig[keyof AppConfig])
|
|
}
|
|
}
|
|
|
|
if (current < 2) {
|
|
const rawBindings = raw.keyBindings as unknown
|
|
const bindings = parseBindingMap(rawBindings)
|
|
const defaults = createDefaultBindingMap()
|
|
const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const
|
|
for (const action of suggestionActions) {
|
|
const spec = findActionSpec(action)
|
|
if (!spec) continue
|
|
bindings[action] = defaults[action] ?? spec.defaultBindings.map((binding) => ({ ...binding }))
|
|
}
|
|
activeStore.set('keyBindings', bindings)
|
|
}
|
|
|
|
if (current < 3) activeStore.set('suggestionRequestTimeoutMs', 8000)
|
|
if (current < 4) {
|
|
activeStore.set('suggestionTriggerDelayMs', 600)
|
|
activeStore.set('suggestionMaxRequestsPerMinute', 6)
|
|
}
|
|
|
|
if (current < 5) migrateSuggestionOverlayBindings(activeStore)
|
|
// 설정 화면에 없는 값인데 설치본에 100ms 가 남아 치는 도중 생성이 시작됐다 — 하한 미만이면 기본값으로.
|
|
if (current < 6) {
|
|
const delay = Number(raw.suggestionTriggerDelayMs)
|
|
if (!Number.isFinite(delay) || delay < 500) activeStore.set('suggestionTriggerDelayMs', 600)
|
|
}
|
|
|
|
activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION)
|
|
logger.info(
|
|
`Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}`
|
|
)
|
|
}
|
|
|
|
/** revision 2 가 심어 둔 옛 기본값 — 사용자가 정확히 이 값 그대로일 때만 옮긴다. */
|
|
const OLD_SUGGESTION_ACCEPT_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowRight, { ctrl: true, alt: true })]
|
|
const OLD_SUGGESTION_DISMISS_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
|
|
|
|
function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]): boolean {
|
|
return a.length === b.length && a.every((binding, index) => bindingsEqual(binding, b[index]))
|
|
}
|
|
|
|
/**
|
|
* 다음 문장 제안 단축키를 1.6.0 배치로 옮긴다.
|
|
*
|
|
* - 수락은 화살표(→)에서 Enter 로, 닫기는 화살표(←)에서 Backspace 로 옮긴다
|
|
* (평범한 Esc 가 주 수단이 됐다).
|
|
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
|
|
* 절대 건드리지 않는다.
|
|
*/
|
|
function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void {
|
|
const raw = activeStore.store as unknown as Record<string, unknown>
|
|
const bindings = parseBindingMap(raw.keyBindings)
|
|
|
|
const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): void => {
|
|
const existing = bindings[actionId] ?? []
|
|
if (!bindingListEquals(existing, oldDefault)) return
|
|
const spec = findActionSpec(actionId)
|
|
if (spec) bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding }))
|
|
}
|
|
|
|
migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT)
|
|
migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT)
|
|
|
|
activeStore.set('keyBindings', bindings)
|
|
}
|
|
|
|
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
|
interface ElectronStore<T> {
|
|
get<K extends keyof T>(key: K): T[K]
|
|
set<K extends keyof T>(key: K, value: T[K]): void
|
|
delete(key: string): void
|
|
store: T
|
|
}
|
|
|
|
const CONFIG_DEFAULTS: AppConfig = {
|
|
theme: 'auto',
|
|
language: 'ko',
|
|
closeToTray: true,
|
|
autoLaunch: false,
|
|
soundEnabled: true,
|
|
selectedDeviceId: null,
|
|
/** 기본 STT 공급자는 로컬 Whisper (오프라인/무료) */
|
|
sttProvider: 'local' as const,
|
|
sttProviderConfigs: {
|
|
local: { modelId: 'large-v3-turbo' },
|
|
'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://127.0.0.1:5000' },
|
|
openai: { modelId: 'whisper-1', apiKey: '', baseUrl: 'https://api.openai.com/v1' },
|
|
groq: { modelId: 'whisper-large-v3-turbo', apiKey: '', baseUrl: 'https://api.groq.com/openai/v1' },
|
|
deepgram: { modelId: 'nova-3', apiKey: '', baseUrl: 'https://api.deepgram.com' },
|
|
assemblyai: { modelId: 'best', apiKey: '', baseUrl: 'https://api.assemblyai.com/v2' },
|
|
google: { modelId: 'gemini-2.0-flash', apiKey: '', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
|
|
custom: { modelId: 'whisper-1', apiKey: '', baseUrl: 'http://127.0.0.1:8000/v1' },
|
|
},
|
|
sttFallbackToLocal: true,
|
|
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB (온보딩에서 사전 다운로드)
|
|
sttModelId: 'large-v3-turbo',
|
|
sttLanguage: 'auto',
|
|
ttsVoiceId: null,
|
|
ttsSpeed: 1.0,
|
|
onlineApiUrl: 'http://127.0.0.1:5000',
|
|
localModelsDir: '',
|
|
llmModelId: 'gemma4:e4b',
|
|
ollamaServerUrl: 'http://127.0.0.1:11434',
|
|
appUsageMode: null,
|
|
authToken: null,
|
|
userEmail: null,
|
|
llmBackend: 'local' as const,
|
|
// 라이브 음성 대화 백엔드 — 'realtime'은 로그인+구독 필요 (OpenAI Realtime WebRTC)
|
|
conversationBackend: 'local' as const,
|
|
defaultLLMAction: 'refine',
|
|
keyBindings: createDefaultBindingMap(),
|
|
hotkeyEnabled: true,
|
|
insertMethod: 'clipboard',
|
|
autoInsert: true,
|
|
maxHistoryEntries: 1000,
|
|
dictationEnabled: true,
|
|
agentModeEnabled: false,
|
|
handsFreeEnabled: false,
|
|
screenContextEnabled: false,
|
|
hfToken: '',
|
|
diarizationEnabled: false,
|
|
supabaseUrl: '',
|
|
supabaseAnonKey: '',
|
|
cloudSyncLastAt: null,
|
|
deviceInstallationId: null,
|
|
cloudSyncAudio: true,
|
|
onboardingCompleted: false,
|
|
// Phase 6/10+: AppConfig 키 기본값 (WS2 SSOT 강화 대응).
|
|
// as never 제거 후 configGet이 이 키들을 반환 — 기존 사용자 config(0.1.x)에
|
|
// 없고 CONFIG_DEFAULTS에도 없으면 undefined → .map() 등에서 main 크래시.
|
|
// v0.2.0-alpha 핫픽스.
|
|
customInstructions: [],
|
|
llmChains: [],
|
|
voiceCommandRules: [],
|
|
voiceCommandsEnabled: false,
|
|
activeInstructionId: '',
|
|
activeChainId: null,
|
|
captionAudioSource: 'mic',
|
|
captionOverlayPosition: null,
|
|
captionRefineEnabled: true,
|
|
captionSttModelId: null,
|
|
updateChannel: 'latest',
|
|
updateDeviceId: '',
|
|
skippedUpdateVersion: null,
|
|
// 입력 인텔리전스 — 옵트인. 켜기 전까지 어떤 입력도 수집하지 않는다.
|
|
inputTelemetryEnabled: false,
|
|
inputTelemetryPaused: false,
|
|
inputLearnTypedText: false,
|
|
inputExcludedApps: [],
|
|
suggestionEnabled: false,
|
|
suggestionModelId: null,
|
|
// 기본값 정본: packages/core/src/input-intelligence.ts SUGGESTION_DEFAULTS
|
|
suggestionTriggerDelayMs: 600,
|
|
suggestionMinPrefixChars: 8,
|
|
suggestionMaxRequestsPerMinute: 6,
|
|
suggestionDailyBudget: 500,
|
|
suggestionOverlayInteractive: true,
|
|
suggestionRequestTimeoutMs: 8000,
|
|
suggestionTuningRevision: 5,
|
|
}
|
|
|
|
let store: ElectronStore<AppConfig> | null = null
|
|
const emitter = new EventEmitter()
|
|
|
|
function createMemoryStore(initial: AppConfig): ElectronStore<AppConfig> {
|
|
let data: AppConfig = { ...initial }
|
|
return {
|
|
get<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
|
return data[key]
|
|
},
|
|
set<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
|
|
data[key] = value
|
|
},
|
|
delete(key: string): void {
|
|
delete (data as unknown as Record<string, unknown>)[key]
|
|
},
|
|
get store(): AppConfig {
|
|
return data
|
|
},
|
|
set store(next: AppConfig) {
|
|
data = { ...next }
|
|
},
|
|
}
|
|
}
|
|
|
|
// ── 키바인딩 마이그레이션 (구 *Shortcut 4개 → keyBindings) ──
|
|
|
|
/** 0.x 저장 형태. 구조·라벨 정본이 keybinding.ts 로 옮겨지기 전의 값이다. */
|
|
interface LegacyShortcut {
|
|
keyCode: number
|
|
ctrl: boolean
|
|
alt: boolean
|
|
shift: boolean
|
|
meta: boolean
|
|
}
|
|
|
|
const LEGACY_SHORTCUT_ACTIONS: Readonly<Record<string, KeyBindingActionId>> = {
|
|
dictationShortcut: 'dictation',
|
|
handsFreeShortcut: 'hands-free',
|
|
commandShortcut: 'command',
|
|
captionShortcut: 'caption',
|
|
}
|
|
|
|
function isLegacyShortcut(value: unknown): value is LegacyShortcut {
|
|
if (typeof value !== 'object' || value === null) return false
|
|
const v = value as Record<string, unknown>
|
|
return (
|
|
typeof v.keyCode === 'number' &&
|
|
typeof v.ctrl === 'boolean' &&
|
|
typeof v.alt === 'boolean' &&
|
|
typeof v.shift === 'boolean' &&
|
|
typeof v.meta === 'boolean'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 저장된 keyBindings 를 복원하고, 남아 있는 구 `*Shortcut` 값을 1회만 이관한다.
|
|
*
|
|
* 구 필드는 이관 직후 삭제하므로 다시 읽히지 않는다.
|
|
* `displayLabel` 은 폐기한다 — 라벨은 formatBindingSegments() 로 파생시킨다.
|
|
*/
|
|
function migrateKeyBindings(activeStore: ElectronStore<AppConfig>): void {
|
|
const raw = activeStore.store as unknown as Record<string, unknown>
|
|
const restored: KeyBindingMap = parseBindingMap(raw.keyBindings)
|
|
|
|
const legacyKeys = Object.keys(LEGACY_SHORTCUT_ACTIONS).filter((key) =>
|
|
isLegacyShortcut(raw[key]),
|
|
)
|
|
if (legacyKeys.length === 0) {
|
|
activeStore.set('keyBindings', restored)
|
|
return
|
|
}
|
|
|
|
for (const key of legacyKeys) {
|
|
const legacy = raw[key] as LegacyShortcut
|
|
const actionId = LEGACY_SHORTCUT_ACTIONS[key]
|
|
if (actionId === undefined) continue
|
|
restored[actionId] = [
|
|
normalizeBinding({
|
|
device: 'keyboard',
|
|
code: legacy.keyCode,
|
|
ctrl: legacy.ctrl,
|
|
alt: legacy.alt,
|
|
shift: legacy.shift,
|
|
meta: legacy.meta,
|
|
}),
|
|
]
|
|
}
|
|
|
|
activeStore.set('keyBindings', restored)
|
|
for (const key of legacyKeys) {
|
|
activeStore.delete(key)
|
|
}
|
|
logger.info(`Migrated ${legacyKeys.length} legacy shortcut(s) to keyBindings`)
|
|
}
|
|
|
|
/** electron-store 없이 설정 CRUD를 가능하게 한다 (테스트 + 초기화 전 안전망). */
|
|
export function initInMemoryConfig(overrides?: Partial<AppConfig>): void {
|
|
store = createMemoryStore({ ...CONFIG_DEFAULTS, ...overrides })
|
|
}
|
|
|
|
export function resetInMemoryConfig(): void {
|
|
store = null
|
|
}
|
|
|
|
export async function initConfigService(): Promise<void> {
|
|
const { default: Store } = await import('electron-store')
|
|
store = new Store<AppConfig>({
|
|
name: 'd3ro-voice-config',
|
|
defaults: CONFIG_DEFAULTS
|
|
})
|
|
migrateKeyBindings(store)
|
|
migrateSuggestionTuning(store)
|
|
logger.info('ConfigService initialized')
|
|
}
|
|
|
|
export function getConfigService(): ElectronStore<AppConfig> | null {
|
|
return store
|
|
}
|
|
|
|
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
|
if (!store) {
|
|
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
|
|
return CONFIG_DEFAULTS[key]
|
|
}
|
|
return store.get(key)
|
|
}
|
|
|
|
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
|
|
if (!store) {
|
|
logger.warn(`ConfigService not initialized — using in-memory store for "${key}"`)
|
|
initInMemoryConfig()
|
|
}
|
|
const activeStore = store
|
|
if (!activeStore) {
|
|
throw new D3ROError(ErrorCode.ConfigWriteFailed, 'Config store unavailable')
|
|
}
|
|
const previousValue = activeStore.get(key)
|
|
activeStore.set(key, value)
|
|
|
|
const event: ConfigChangedEvent = {
|
|
key,
|
|
value,
|
|
previousValue
|
|
}
|
|
emitter.emit('config-changed', event)
|
|
logger.debug(`Config changed: ${key}`)
|
|
}
|
|
|
|
export function configGetAll(): AppConfig {
|
|
if (!store) return { ...CONFIG_DEFAULTS }
|
|
return store.store
|
|
}
|
|
|
|
export function configReset(key?: keyof AppConfig): void {
|
|
if (!store) return
|
|
if (key) {
|
|
store.set(key, CONFIG_DEFAULTS[key])
|
|
} else {
|
|
store.store = { ...CONFIG_DEFAULTS }
|
|
}
|
|
}
|
|
|
|
export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void {
|
|
emitter.on('config-changed', callback)
|
|
return () => emitter.off('config-changed', callback)
|
|
}
|