d3ro-voice/apps/desktop/src/main/services/ConfigService.ts
Yun Chan 2fe20fa7b5
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s
release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

The personal phrase memory stops learning from terminals, code editors and
the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders,
and prunes existing entries that break those rules.

Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0
speech engine without the focus endpoint, Ollama runner windows flashing
while typing, the speech engine starting twice, and cold-model timeouts.
Live captions can be dragged to a remembered position and show a waiting
notice until the first line arrives.

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
2026-09-24 19:56:28 +09:00

374 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,
detectBindingConflicts,
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 = 5
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)
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]))
}
/**
* 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다.
*
* - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다.
* - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다).
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
* 절대 건드리지 않는다(설계 요구사항).
* - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른
* 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다.
*/
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)
for (const actionId of ['suggestion-page-next', 'suggestion-page-prev'] as const) {
const candidate = bindings[actionId] ?? []
const hasConflict = candidate.some(
(binding) => detectBindingConflicts(actionId, binding, bindings).length > 0
)
if (hasConflict) {
logger.warn(
`${actionId} 기본 바인딩이 다른 액션과 충돌해 바인딩 없이 둔다 (사용자가 설정에서 직접 지정할 수 있다)`
)
bindings[actionId] = []
}
}
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,
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,
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)
}