diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d18d4c..ea004a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 page by itself, and at the last candidate the selection waits while more are still being generated. The separate Ctrl+Alt+Left/Right page shortcuts are gone; they also collided with the display-rotation shortcut of Intel graphics drivers. +- The live-caption model is chosen next to the other caption settings in the General + tab. + +### Fixed +- **Accepting a suggestion with Ctrl+Alt+Enter did not paste.** The paste was sent + while Ctrl+Alt were still held, so the app received Ctrl+Alt+V. The panel now closes + at once and the text is pasted as soon as the keys are released. +- **Clicking a suggestion did nothing** when a new candidate arrived between pressing + and releasing the mouse button; a candidate is now accepted on press. +- **Suggestions rarely appeared in chat boxes that grow as you type.** The growing box + was taken for a different field on every keystroke, which kept blocking suggestions + as "not typing". Fields are now told apart by window and control, and a mouse click + re-checks whether you have typed since. ### Planned - macOS / Linux support diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 98f0265..aa22194 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -7,6 +7,7 @@ import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService, configGet, configSet } from './services/ConfigService' import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService' import { acquireGlobalInputHook } from './services/global-input-hook' +import { trackModifierKeys } from './services/modifier-state' import { getVoiceModeService } from './services/VoiceModeService' import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService' import { getHistoryService } from './services/HistoryService' @@ -224,6 +225,8 @@ async function initInputIntelligence(): Promise { // KeyBindingService 의 등록 바인딩 경로로는 절대 들어오지 않는다 — 원본 keydown을 // 직접 듣는다(InputTelemetryService/KeyBindingService 가 이미 쓰는 것과 같은 패턴). acquireGlobalInputHook() + // 단축키로 수락할 때 Ctrl/Alt 를 뗄 때까지 기다리려면 누른 순간부터 추적해야 한다. + trackModifierKeys() uIOhook.on('keydown', (event: UiohookKeyboardEvent) => { if (uiohookCodeToVk(event.keycode) !== VK.Escape) return const shouldDismiss = shouldDismissOnEscape(suggestion.isPresentationActive, { diff --git a/apps/desktop/src/main/services/InputTelemetryService.ts b/apps/desktop/src/main/services/InputTelemetryService.ts index f73fc39..d21dd38 100644 --- a/apps/desktop/src/main/services/InputTelemetryService.ts +++ b/apps/desktop/src/main/services/InputTelemetryService.ts @@ -165,6 +165,8 @@ class InputTelemetryService extends EventEmitter { private _lastFocusKey = '' /** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */ private _textAtFocus = '' + /** 클릭 뒤 첫 스냅샷에서 기준선을 다시 잡는다 */ + private _rebaseFocusText = false private _lastActiveTickAt = 0 private _lastMouse: { x: number; y: number } | null = null private _lastClickAt = 0 @@ -407,6 +409,8 @@ class InputTelemetryService extends EventEmitter { this._lastClickAt = now this._lastClickButton = button this._lastClickPos = position + // 클릭은 다른 칸으로 옮겨 갔을 수 있다 — 다음 스냅샷의 텍스트를 새 기준으로 삼는다. + this._rebaseFocusText = true } private _handleMouseUp(event: UiohookMouseEvent): void { @@ -607,15 +611,16 @@ class InputTelemetryService extends EventEmitter { : tail this._caretFallback = !caretKnown - // 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다). - // 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가 - // 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데 - // 옛 검색어로 제안이 뜸). - const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect) + // 포커스 식별: 칸이 바뀌었거나 클릭이 있었으면 그 시점 텍스트를 기준선으로 저장한다 + // (비밀번호는 비운다). 마우스로 칸에 들어오기만 하고 아무것도 안 쳤으면 기준선과 + // 현재 텍스트가 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 + // 했는데 옛 검색어로 제안이 뜸). + const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.controlName ?? null) const textForFocus = snapshot.isPassword ? '' : snapshot.text - if (focusKey !== this._lastFocusKey) { + if (focusKey !== this._lastFocusKey || this._rebaseFocusText) { this._lastFocusKey = focusKey this._textAtFocus = textForFocus + this._rebaseFocusText = false } context.editedSinceFocus = textForFocus !== this._textAtFocus @@ -1385,16 +1390,14 @@ function lastLineOf(text: string): string { } /** - * 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 위치. + * 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 컨트롤 이름. * - * 높이는 뺀다 — 여러 줄 입력창은 타이핑에 따라 높이가 자라, 높이를 포함하면 - * 같은 필드인데도 칠 때마다 "새 포커스" 로 오인돼 기준선이 계속 리셋된다. + * 위치·크기는 쓰지 않는다 — 채팅 입력창은 글이 늘면 자라고 움직여, 넣으면 같은 칸인데도 + * 칠 때마다 "새 칸" 으로 오인돼 기준선이 리셋되고 제안이 거의 막혔다(실측: Agent + * Switchboard 에서 not-typing 반복). 같은 창 안의 칸 이동은 클릭 재기준으로 잡는다. */ -function computeFocusKey(hwnd: number | null, controlType: string | null, elementRect: UiRect | null): string { - const rect = elementRect - ? `${Math.round(elementRect.x)},${Math.round(elementRect.y)},${Math.round(elementRect.width)}` - : 'none' - return `${hwnd ?? ''}|${controlType ?? ''}|${rect}` +function computeFocusKey(hwnd: number | null, controlType: string | null, controlName: string | null): string { + return `${hwnd ?? ''}|${controlType ?? ''}|${controlName ?? ''}` } function formatLocalDate(date: Date): string { diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index 29e1a1e..fab3597 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -41,6 +41,7 @@ import { buildSuggestionPrompt } from './llm-prompts' import { getInputTelemetryService, type TypingContext } from './InputTelemetryService' import { getPersonalGraphService } from './PersonalGraphService' import { getTextInsertService } from './TextInsertService' +import { waitForModifiersReleased } from './modifier-state' const logger = getLogger('SuggestionService') @@ -1074,6 +1075,8 @@ class SuggestionService extends EventEmitter { context.isPassword, context.isComposing, context.anchor ? 'A' : '-', + context.editedSinceFocus, + context.typedRecently, getLocalLLMService().isAvailable(), this.isEnabled() ].join('|') @@ -1082,7 +1085,8 @@ class SuggestionService extends EventEmitter { this._lastDecisionSignature = signature logger.info( `제안 판단 입력: avail=${context.available} edit=${context.isEditable} pw=${context.isPassword} ` + - `comp=${context.isComposing} prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` + + `comp=${context.isComposing} edited=${context.editedSinceFocus} typed=${context.typedRecently} ` + + `prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` + `app=${context.appName ?? '-'} enabled=${this.isEnabled()} ` + `model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}` ) @@ -1244,11 +1248,24 @@ class SuggestionService extends EventEmitter { } const candidate = this._candidates[this._activeIndex] if (!candidate) return { ok: false, reason: 'already-visible' } + const text = candidate.text + const appName = this._appName + const windowTitle = this._windowTitle + + // 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다. + this.dismiss('accepted') + this.emit('state-changed', this.getState()) + + // 단축키로 수락했다면 사용자가 아직 Ctrl+Alt 를 쥐고 있다. 그대로 Ctrl+V 를 보내면 + // 대상 앱에 Ctrl+Alt+V 가 들어가 붙여넣기가 되지 않는다(실측) — 뗄 때까지 기다린다. + if (!(await waitForModifiersReleased())) { + logger.warn('수정자 키가 떼어지지 않은 채 제안을 삽입한다 (1.5초 초과)') + } const method = configGet('insertMethod') try { const result = await getTextInsertService().insertText( - candidate.text, + text, method === 'keyboard' ? 'keyboard' : 'clipboard' ) if (!result.success) { @@ -1260,16 +1277,9 @@ class SuggestionService extends EventEmitter { return { ok: false, reason: 'generation-failed' } } - this._markAccepted(candidate.text) + this._markAccepted(text) // 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨). - getInputTelemetryService().recordExternalText(candidate.text, { - appName: this._appName, - windowTitle: this._windowTitle, - source: 'suggestion' - }) - - this.dismiss('accepted') - this.emit('state-changed', this.getState()) + getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' }) return { ok: true } } diff --git a/apps/desktop/src/main/services/modifier-state.ts b/apps/desktop/src/main/services/modifier-state.ts new file mode 100644 index 0000000..97eff1d --- /dev/null +++ b/apps/desktop/src/main/services/modifier-state.ts @@ -0,0 +1,50 @@ +// src/main/services/modifier-state.ts +// +// 사용자가 물리적으로 누르고 있는 수정자 키(Ctrl/Alt/Shift/Win)를 추적한다. +// +// 단축키로 무언가를 삽입할 때(예: Ctrl+Alt+Enter 로 제안 수락) 동작은 키를 누르는 +// 순간 실행되는데, 그때 사용자는 아직 Ctrl+Alt 를 쥐고 있다. 그 상태로 Ctrl+V 를 +// 보내면 대상 앱에는 Ctrl+Alt+V 가 들어가 붙여넣기가 되지 않는다(실측). 삽입 전에 +// 손을 뗄 때까지 잠깐 기다린다. + +import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi' +import { acquireGlobalInputHook } from './global-input-hook' + +/** uiohook 키 코드 (UiohookKey.Ctrl/CtrlRight/Alt/AltRight/Shift/ShiftRight/Meta/MetaRight) */ +const MODIFIER_KEYCODES: ReadonlySet = new Set([29, 3613, 56, 3640, 42, 54, 3675, 3676]) + +const held = new Set() +let attached = false + +function attach(): void { + if (attached) return + attached = true + acquireGlobalInputHook() + uIOhook.on('keydown', (event: UiohookKeyboardEvent) => { + if (MODIFIER_KEYCODES.has(event.keycode)) held.add(event.keycode) + }) + uIOhook.on('keyup', (event: UiohookKeyboardEvent) => { + held.delete(event.keycode) + }) +} + +/** 추적을 시작한다 — 단축키가 눌리기 전에 붙어 있어야 누른 상태를 안다. */ +export function trackModifierKeys(): void { + attach() +} + +/** + * 수정자 키가 모두 떼어질 때까지 기다린다. + * + * 시간 안에 떼지 않으면(떼는 이벤트를 놓친 경우 포함) 추적 상태를 비우고 false 를 + * 돌려준다 — 호출자는 그래도 진행한다(영원히 막히면 안 된다). + */ +export async function waitForModifiersReleased(timeoutMs = 1500): Promise { + const deadline = Date.now() + timeoutMs + while (held.size > 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 15)) + } + if (held.size === 0) return true + held.clear() + return false +} diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index 5e038f7..3c2f53e 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -51,9 +51,6 @@ import type { } from '@d3ro/core/types' import { CodexOAuthGuideModal } from './CodexOAuthGuideModal' -/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */ -const SAME_AS_DICTATION = '__same__' - interface STTTabProps { config: Partial updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void @@ -365,30 +362,6 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen - {/* 실시간 자막 전용 모델 — 1초마다 다시 인식하므로 받아쓰기와 다른 모델이 나을 수 있다 */} - - {t('settings.captionModel')} - - - {t('settings.captionModel.desc')} - - - {/* 모델 다운로드 진행 바 또는 다운로드 버튼 */} {(() => { const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo')) diff --git a/apps/desktop/src/renderer/components/SettingsModal.tsx b/apps/desktop/src/renderer/components/SettingsModal.tsx index f67f72a..626a84c 100644 --- a/apps/desktop/src/renderer/components/SettingsModal.tsx +++ b/apps/desktop/src/renderer/components/SettingsModal.tsx @@ -35,7 +35,7 @@ import { useI18n, LOCALE_META } from '@d3ro/i18n' import type { Locale } from '@d3ro/i18n' import type { KeyBindingActionGroup } from '@d3ro/core/keybinding' import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding' -import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types' +import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types' import { useKeyBindingMap } from '../hooks/useKeyBindingMap' interface SettingsModalProps { @@ -64,6 +64,9 @@ const ACTION_GROUP_LABEL_KEYS: Readonly> = const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input'] +/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */ +const SAME_AS_DICTATION = '__same__' + export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() const [activeTab, setActiveTab] = useState(initialTab) @@ -108,6 +111,15 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr const [micTesting, setMicTesting] = useState(false) const [micLevel, setMicLevel] = useState(0) + // 실시간 자막 모델 선택지 — 받아 둔 로컬 Whisper 모델만 + const [downloadedSttModels, setDownloadedSttModels] = useState([]) + useEffect(() => { + if (!open) return + window.electronAPI.stt.getModels().then((res) => { + if (res.success && res.data) setDownloadedSttModels(res.data.filter((m) => m.downloaded)) + }) + }, [open]) + // Ollama 상태 const [ollamaStatus, setOllamaStatus] = useState(null) const [ollamaModels, setOllamaModels] = useState([]) @@ -481,6 +493,27 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr + + {t('settings.captionModel')} + + + {t('settings.captionModel.desc')} + + +