d3ro-voice/apps/desktop/src/main/services/ConfigService.ts
Yun Chan 1b8fe445f3 fix(suggestion): stop generating mid-typing and let a pause bring a suggestion at once
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.
2026-09-27 22:55:26 +09:00

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)
}