fix(suggestion): paste after the shortcut keys are released and keep typing detection stable
Ctrl+Alt+Enter pasted while Ctrl+Alt were still down, so the target app got Ctrl+Alt+V; accepting now closes the panel and waits for the modifiers to be released. Candidates are accepted on pointer press because the list is redrawn as new candidates stream in, which swallowed clicks. The typing gate identified the focused field by its bounds, so chat boxes that grow while typing looked like a new field on every keystroke and were reported as "not typing". Fields are now keyed by window, control type and name, and a mouse click re-baselines the text instead. The decision log includes both gate values. The live-caption model selector moves to the caption section of the General tab, next to the other caption settings.
This commit is contained in:
parent
4b0f685941
commit
da0da98285
8 changed files with 144 additions and 55 deletions
13
CHANGELOG.md
13
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
|
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
|
being generated. The separate Ctrl+Alt+Left/Right page shortcuts are gone; they also
|
||||||
collided with the display-rotation shortcut of Intel graphics drivers.
|
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
|
### Planned
|
||||||
- macOS / Linux support
|
- macOS / Linux support
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
import { initConfigService, configGet, configSet } from './services/ConfigService'
|
||||||
import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService'
|
import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService'
|
||||||
import { acquireGlobalInputHook } from './services/global-input-hook'
|
import { acquireGlobalInputHook } from './services/global-input-hook'
|
||||||
|
import { trackModifierKeys } from './services/modifier-state'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService'
|
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService'
|
||||||
import { getHistoryService } from './services/HistoryService'
|
import { getHistoryService } from './services/HistoryService'
|
||||||
|
|
@ -224,6 +225,8 @@ async function initInputIntelligence(): Promise<void> {
|
||||||
// KeyBindingService 의 등록 바인딩 경로로는 절대 들어오지 않는다 — 원본 keydown을
|
// KeyBindingService 의 등록 바인딩 경로로는 절대 들어오지 않는다 — 원본 keydown을
|
||||||
// 직접 듣는다(InputTelemetryService/KeyBindingService 가 이미 쓰는 것과 같은 패턴).
|
// 직접 듣는다(InputTelemetryService/KeyBindingService 가 이미 쓰는 것과 같은 패턴).
|
||||||
acquireGlobalInputHook()
|
acquireGlobalInputHook()
|
||||||
|
// 단축키로 수락할 때 Ctrl/Alt 를 뗄 때까지 기다리려면 누른 순간부터 추적해야 한다.
|
||||||
|
trackModifierKeys()
|
||||||
uIOhook.on('keydown', (event: UiohookKeyboardEvent) => {
|
uIOhook.on('keydown', (event: UiohookKeyboardEvent) => {
|
||||||
if (uiohookCodeToVk(event.keycode) !== VK.Escape) return
|
if (uiohookCodeToVk(event.keycode) !== VK.Escape) return
|
||||||
const shouldDismiss = shouldDismissOnEscape(suggestion.isPresentationActive, {
|
const shouldDismiss = shouldDismissOnEscape(suggestion.isPresentationActive, {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,8 @@ class InputTelemetryService extends EventEmitter {
|
||||||
private _lastFocusKey = ''
|
private _lastFocusKey = ''
|
||||||
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
||||||
private _textAtFocus = ''
|
private _textAtFocus = ''
|
||||||
|
/** 클릭 뒤 첫 스냅샷에서 기준선을 다시 잡는다 */
|
||||||
|
private _rebaseFocusText = false
|
||||||
private _lastActiveTickAt = 0
|
private _lastActiveTickAt = 0
|
||||||
private _lastMouse: { x: number; y: number } | null = null
|
private _lastMouse: { x: number; y: number } | null = null
|
||||||
private _lastClickAt = 0
|
private _lastClickAt = 0
|
||||||
|
|
@ -407,6 +409,8 @@ class InputTelemetryService extends EventEmitter {
|
||||||
this._lastClickAt = now
|
this._lastClickAt = now
|
||||||
this._lastClickButton = button
|
this._lastClickButton = button
|
||||||
this._lastClickPos = position
|
this._lastClickPos = position
|
||||||
|
// 클릭은 다른 칸으로 옮겨 갔을 수 있다 — 다음 스냅샷의 텍스트를 새 기준으로 삼는다.
|
||||||
|
this._rebaseFocusText = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleMouseUp(event: UiohookMouseEvent): void {
|
private _handleMouseUp(event: UiohookMouseEvent): void {
|
||||||
|
|
@ -607,15 +611,16 @@ class InputTelemetryService extends EventEmitter {
|
||||||
: tail
|
: tail
|
||||||
this._caretFallback = !caretKnown
|
this._caretFallback = !caretKnown
|
||||||
|
|
||||||
// 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다).
|
// 포커스 식별: 칸이 바뀌었거나 클릭이 있었으면 그 시점 텍스트를 기준선으로 저장한다
|
||||||
// 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가
|
// (비밀번호는 비운다). 마우스로 칸에 들어오기만 하고 아무것도 안 쳤으면 기준선과
|
||||||
// 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데
|
// 현재 텍스트가 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만
|
||||||
// 옛 검색어로 제안이 뜸).
|
// 했는데 옛 검색어로 제안이 뜸).
|
||||||
const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect)
|
const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.controlName ?? null)
|
||||||
const textForFocus = snapshot.isPassword ? '' : snapshot.text
|
const textForFocus = snapshot.isPassword ? '' : snapshot.text
|
||||||
if (focusKey !== this._lastFocusKey) {
|
if (focusKey !== this._lastFocusKey || this._rebaseFocusText) {
|
||||||
this._lastFocusKey = focusKey
|
this._lastFocusKey = focusKey
|
||||||
this._textAtFocus = textForFocus
|
this._textAtFocus = textForFocus
|
||||||
|
this._rebaseFocusText = false
|
||||||
}
|
}
|
||||||
context.editedSinceFocus = textForFocus !== this._textAtFocus
|
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 {
|
function computeFocusKey(hwnd: number | null, controlType: string | null, controlName: string | null): string {
|
||||||
const rect = elementRect
|
return `${hwnd ?? ''}|${controlType ?? ''}|${controlName ?? ''}`
|
||||||
? `${Math.round(elementRect.x)},${Math.round(elementRect.y)},${Math.round(elementRect.width)}`
|
|
||||||
: 'none'
|
|
||||||
return `${hwnd ?? ''}|${controlType ?? ''}|${rect}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLocalDate(date: Date): string {
|
function formatLocalDate(date: Date): string {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { buildSuggestionPrompt } from './llm-prompts'
|
||||||
import { getInputTelemetryService, type TypingContext } from './InputTelemetryService'
|
import { getInputTelemetryService, type TypingContext } from './InputTelemetryService'
|
||||||
import { getPersonalGraphService } from './PersonalGraphService'
|
import { getPersonalGraphService } from './PersonalGraphService'
|
||||||
import { getTextInsertService } from './TextInsertService'
|
import { getTextInsertService } from './TextInsertService'
|
||||||
|
import { waitForModifiersReleased } from './modifier-state'
|
||||||
|
|
||||||
const logger = getLogger('SuggestionService')
|
const logger = getLogger('SuggestionService')
|
||||||
|
|
||||||
|
|
@ -1074,6 +1075,8 @@ class SuggestionService extends EventEmitter {
|
||||||
context.isPassword,
|
context.isPassword,
|
||||||
context.isComposing,
|
context.isComposing,
|
||||||
context.anchor ? 'A' : '-',
|
context.anchor ? 'A' : '-',
|
||||||
|
context.editedSinceFocus,
|
||||||
|
context.typedRecently,
|
||||||
getLocalLLMService().isAvailable(),
|
getLocalLLMService().isAvailable(),
|
||||||
this.isEnabled()
|
this.isEnabled()
|
||||||
].join('|')
|
].join('|')
|
||||||
|
|
@ -1082,7 +1085,8 @@ class SuggestionService extends EventEmitter {
|
||||||
this._lastDecisionSignature = signature
|
this._lastDecisionSignature = signature
|
||||||
logger.info(
|
logger.info(
|
||||||
`제안 판단 입력: avail=${context.available} edit=${context.isEditable} pw=${context.isPassword} ` +
|
`제안 판단 입력: 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()} ` +
|
`app=${context.appName ?? '-'} enabled=${this.isEnabled()} ` +
|
||||||
`model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}`
|
`model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}`
|
||||||
)
|
)
|
||||||
|
|
@ -1244,11 +1248,24 @@ class SuggestionService extends EventEmitter {
|
||||||
}
|
}
|
||||||
const candidate = this._candidates[this._activeIndex]
|
const candidate = this._candidates[this._activeIndex]
|
||||||
if (!candidate) return { ok: false, reason: 'already-visible' }
|
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')
|
const method = configGet('insertMethod')
|
||||||
try {
|
try {
|
||||||
const result = await getTextInsertService().insertText(
|
const result = await getTextInsertService().insertText(
|
||||||
candidate.text,
|
text,
|
||||||
method === 'keyboard' ? 'keyboard' : 'clipboard'
|
method === 'keyboard' ? 'keyboard' : 'clipboard'
|
||||||
)
|
)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -1260,16 +1277,9 @@ class SuggestionService extends EventEmitter {
|
||||||
return { ok: false, reason: 'generation-failed' }
|
return { ok: false, reason: 'generation-failed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
this._markAccepted(candidate.text)
|
this._markAccepted(text)
|
||||||
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
||||||
getInputTelemetryService().recordExternalText(candidate.text, {
|
getInputTelemetryService().recordExternalText(text, { appName, windowTitle, source: 'suggestion' })
|
||||||
appName: this._appName,
|
|
||||||
windowTitle: this._windowTitle,
|
|
||||||
source: 'suggestion'
|
|
||||||
})
|
|
||||||
|
|
||||||
this.dismiss('accepted')
|
|
||||||
this.emit('state-changed', this.getState())
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
apps/desktop/src/main/services/modifier-state.ts
Normal file
50
apps/desktop/src/main/services/modifier-state.ts
Normal file
|
|
@ -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<number> = new Set([29, 3613, 56, 3640, 42, 54, 3675, 3676])
|
||||||
|
|
||||||
|
const held = new Set<number>()
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -51,9 +51,6 @@ import type {
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
|
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
|
||||||
|
|
||||||
/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */
|
|
||||||
const SAME_AS_DICTATION = '__same__'
|
|
||||||
|
|
||||||
interface STTTabProps {
|
interface STTTabProps {
|
||||||
config: Partial<AppConfig>
|
config: Partial<AppConfig>
|
||||||
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
|
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
|
||||||
|
|
@ -365,30 +362,6 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* 실시간 자막 전용 모델 — 1초마다 다시 인식하므로 받아쓰기와 다른 모델이 나을 수 있다 */}
|
|
||||||
<FormControl size="small" fullWidth>
|
|
||||||
<InputLabel>{t('settings.captionModel')}</InputLabel>
|
|
||||||
<Select
|
|
||||||
label={t('settings.captionModel')}
|
|
||||||
value={config.captionSttModelId ?? SAME_AS_DICTATION}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateConfig('captionSttModelId', e.target.value === SAME_AS_DICTATION ? null : e.target.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MenuItem value={SAME_AS_DICTATION}>{t('settings.captionModel.same')}</MenuItem>
|
|
||||||
{localModels
|
|
||||||
.filter((m) => m.downloaded)
|
|
||||||
.map((m) => (
|
|
||||||
<MenuItem key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Typography sx={{ mt: 0.5, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
|
|
||||||
{t('settings.captionModel.desc')}
|
|
||||||
</Typography>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{/* 모델 다운로드 진행 바 또는 다운로드 버튼 */}
|
{/* 모델 다운로드 진행 바 또는 다운로드 버튼 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))
|
const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import { useI18n, LOCALE_META } from '@d3ro/i18n'
|
||||||
import type { Locale } from '@d3ro/i18n'
|
import type { Locale } from '@d3ro/i18n'
|
||||||
import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
|
import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
|
||||||
import { auditKeyBindingMap, KEYBINDING_ACTIONS } 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'
|
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
|
|
@ -64,6 +64,9 @@ const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> =
|
||||||
|
|
||||||
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input']
|
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 {
|
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
|
||||||
const { t, locale, setLocale } = useI18n()
|
const { t, locale, setLocale } = useI18n()
|
||||||
const [activeTab, setActiveTab] = useState(initialTab)
|
const [activeTab, setActiveTab] = useState(initialTab)
|
||||||
|
|
@ -108,6 +111,15 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
const [micTesting, setMicTesting] = useState(false)
|
const [micTesting, setMicTesting] = useState(false)
|
||||||
const [micLevel, setMicLevel] = useState(0)
|
const [micLevel, setMicLevel] = useState(0)
|
||||||
|
|
||||||
|
// 실시간 자막 모델 선택지 — 받아 둔 로컬 Whisper 모델만
|
||||||
|
const [downloadedSttModels, setDownloadedSttModels] = useState<STTModel[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
window.electronAPI.stt.getModels().then((res) => {
|
||||||
|
if (res.success && res.data) setDownloadedSttModels(res.data.filter((m) => m.downloaded))
|
||||||
|
})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
// Ollama 상태
|
// Ollama 상태
|
||||||
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
|
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
|
||||||
const [ollamaModels, setOllamaModels] = useState<LLMModel[]>([])
|
const [ollamaModels, setOllamaModels] = useState<LLMModel[]>([])
|
||||||
|
|
@ -481,6 +493,27 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl size="small">
|
||||||
|
<InputLabel>{t('settings.captionModel')}</InputLabel>
|
||||||
|
<Select
|
||||||
|
label={t('settings.captionModel')}
|
||||||
|
value={config.captionSttModelId ?? SAME_AS_DICTATION}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig('captionSttModelId', e.target.value === SAME_AS_DICTATION ? null : e.target.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MenuItem value={SAME_AS_DICTATION}>{t('settings.captionModel.same')}</MenuItem>
|
||||||
|
{downloadedSttModels.map((m) => (
|
||||||
|
<MenuItem key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Typography sx={{ mt: 0.5, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
|
||||||
|
{t('settings.captionModel.desc')}
|
||||||
|
</Typography>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
sx={{ mr: 0, alignItems: 'flex-start' }}
|
sx={{ mr: 0, alignItems: 'flex-start' }}
|
||||||
control={
|
control={
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,9 @@
|
||||||
)
|
)
|
||||||
item.type = 'button'
|
item.type = 'button'
|
||||||
item.setAttribute('data-index', String(i))
|
item.setAttribute('data-index', String(i))
|
||||||
item.addEventListener('click', onItemClick)
|
// click 이 아니라 누르는 순간 수락한다 — 후보가 1초마다 추가되며 목록을 다시 그려,
|
||||||
|
// 누르고 떼는 사이에 버튼이 바뀌면 click 이 성립하지 않았다(실측: 클릭 수락 무반응).
|
||||||
|
item.addEventListener('pointerdown', onItemPress)
|
||||||
candidatesContainer.appendChild(item)
|
candidatesContainer.appendChild(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,7 +181,9 @@
|
||||||
provenanceContainer.textContent = sourceLabel || ''
|
provenanceContainer.textContent = sourceLabel || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function onItemClick(event) {
|
function onItemPress(event) {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
event.preventDefault()
|
||||||
var target = event.currentTarget
|
var target = event.currentTarget
|
||||||
var index = Number(target.getAttribute('data-index'))
|
var index = Number(target.getAttribute('data-index'))
|
||||||
if (!window.popupAPI) return
|
if (!window.popupAPI) return
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue