release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
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

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).
This commit is contained in:
Yun Chan 2026-09-24 19:56:28 +09:00
parent 856e375f3e
commit 2fe20fa7b5
71 changed files with 2469 additions and 366 deletions

View file

@ -1,11 +1,14 @@
// src/main/bootstrap.ts — 초기화 시퀀스
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi'
import { VK } from '@d3ro/core/keybinding'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService, configGet, configSet } from './services/ConfigService'
import { getKeyBindingService } from './services/KeyBindingService'
import { getKeyBindingService, uiohookCodeToVk } from './services/KeyBindingService'
import { acquireGlobalInputHook } from './services/global-input-hook'
import { getVoiceModeService } from './services/VoiceModeService'
import { startLocalLLMAvailability } from './services/LocalLLMService'
import { startLocalLLMAvailability, getLocalLLMService } from './services/LocalLLMService'
import { getHistoryService } from './services/HistoryService'
import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
import { getTextInsertService } from './services/TextInsertService'
@ -33,9 +36,12 @@ import {
hideRecordingTip,
updateRecordingTipState,
showSuggestionOverlay,
updateSuggestionOverlay,
hideSuggestionOverlay,
isSuggestionOverlayVisible,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { decideSuggestionOverlayAction, shouldDismissOnEscape } from './suggestion-overlay-policy'
import { registerAllIpcHandlers } from './ipc'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import type { IPCChannel } from '@d3ro/core/ipc-channels'
@ -156,16 +162,32 @@ async function initInputIntelligence(): Promise<void> {
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
const shouldPresent = hasSomethingToShow && !!state.anchor
telemetry.setSuggestionPresentationActive(shouldPresent)
if (!shouldPresent) {
const action = decideSuggestionOverlayAction(isSuggestionOverlayVisible(), shouldPresent)
if (action === 'hide') {
hideSuggestionOverlay()
} else if (action === 'update') {
// 이미 떠 있으면 내용만 갱신한다 — setBounds/present 를 매 스트리밍 청크마다
// 다시 부르면 X 클릭이 재present 에 가로채이고 패널이 튄다(실측).
updateSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
provenance: state.provenance
})
} else {
showSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
targetTotal: state.targetTotal,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
anchor: state.anchor,
anchorKind: state.anchorKind,
appName: state.appName,
provenance: state.provenance
})
@ -188,15 +210,33 @@ async function initInputIntelligence(): Promise<void> {
sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state)
)
// 제안 수락/순환/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
// 제안 수락/순환/페이지/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
getKeyBindingService().on('triggered', (payload) => {
if (payload.type !== 'pressed') return
if (payload.actionId === 'suggestion-accept') void suggestion.accept()
else if (payload.actionId === 'suggestion-next') suggestion.next()
else if (payload.actionId === 'suggestion-prev') suggestion.previous()
else if (payload.actionId === 'suggestion-page-next') suggestion.pageNext()
else if (payload.actionId === 'suggestion-page-prev') suggestion.pagePrev()
else if (payload.actionId === 'suggestion-dismiss') suggestion.dismiss('dismissed')
})
// 평범한 Esc(수정자 없음)로 제안을 닫는다 — 등록된 바인딩이 아니라 오버레이가 떠
// 있을 때만 반응하는 전용 키다. Escape 는 단독 바인딩이 불가하므로(requiresModifier)
// KeyBindingService 의 등록 바인딩 경로로는 절대 들어오지 않는다 — 원본 keydown을
// 직접 듣는다(InputTelemetryService/KeyBindingService 가 이미 쓰는 것과 같은 패턴).
acquireGlobalInputHook()
uIOhook.on('keydown', (event: UiohookKeyboardEvent) => {
if (uiohookCodeToVk(event.keycode) !== VK.Escape) return
const shouldDismiss = shouldDismissOnEscape(suggestion.isPresentationActive, {
ctrl: event.ctrlKey === true,
alt: event.altKey === true,
shift: event.shiftKey === true,
meta: event.metaKey === true
})
if (shouldDismiss) suggestion.dismiss('dismissed')
})
// Chromium(Electron)은 접근성 지원이 감지될 때만 AX 트리를 만든다. 켜지 않으면
// 우리 앱 자신의 입력창은 UIA 로 읽히지 않아 D3RO 안에서 타이핑할 때 제안이 죽는다.
if (configGet('inputTelemetryEnabled') || configGet('suggestionEnabled')) {
@ -204,6 +244,15 @@ async function initInputIntelligence(): Promise<void> {
logger.info('[bootstrap] accessibility tree enabled for UIA context capture')
}
// 제안이 켜져 있으면 켜진 시점부터 모델을 미리 올려 둔다 — 토글 시에만 워밍업하면
// 앱을 새로 켤 때마다 첫 제안이 콜드 로딩으로 타임아웃된다.
if (configGet('suggestionEnabled')) void suggestion.warmUp()
// Ollama 서버가 나중에 뜨는 경우(느린 부팅 등) 가용해지는 시점에도 워밍업한다.
getLocalLLMService().on('availability-changed', ({ available }) => {
if (available && configGet('suggestionEnabled')) void suggestion.warmUp()
})
telemetry.start()
}

View file

@ -6,8 +6,22 @@ import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getCaptionService } from '../services/CaptionService'
import type { CaptionConfig } from '@d3ro/core/types'
import {
endCaptionOverlayDrag,
resetCaptionOverlayPosition,
setCaptionOverlayInteractive,
startCaptionOverlayDrag
} from '../windows/WindowManager'
export function registerCaptionHandlers(): void {
// ── 자막 창 손잡이 (팝업 → 메인) ──
ipcMain.on(IPC_CHANNELS.POPUP_CAPTION.SET_INTERACTIVE, (_event, interactive: unknown) => {
setCaptionOverlayInteractive(interactive === true)
})
ipcMain.on(IPC_CHANNELS.POPUP_CAPTION.DRAG_START, () => startCaptionOverlayDrag())
ipcMain.on(IPC_CHANNELS.POPUP_CAPTION.DRAG_END, () => endCaptionOverlayDrag())
ipcMain.on(IPC_CHANNELS.POPUP_CAPTION.RESET_POSITION, () => resetCaptionOverlayPosition())
// 시스템 오디오 루프백: setDisplayMediaRequestHandler로 audio: 'loopback' 설정
ipcMain.handle(IPC_CHANNELS.SYSTEM_AUDIO.ENABLE_LOOPBACK, async () => {
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {

View file

@ -2,12 +2,16 @@
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import {
bindingsEqual,
createDefaultBindingMap,
detectBindingConflicts,
findActionSpec,
kb,
normalizeBinding,
parseBindingMap
parseBindingMap,
VK
} from '@d3ro/core/keybinding'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from './LoggerService'
@ -15,7 +19,7 @@ import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
const SUGGESTION_TUNING_REVISION = 4
const SUGGESTION_TUNING_REVISION = 5
const INITIAL_SUGGESTION_TUNING = {
suggestionTriggerDelayMs: 300,
@ -55,12 +59,62 @@ function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
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]
@ -131,6 +185,7 @@ const CONFIG_DEFAULTS: AppConfig = {
activeInstructionId: '',
activeChainId: null,
captionAudioSource: 'mic',
captionOverlayPosition: null,
updateChannel: 'latest',
updateDeviceId: '',
skippedUpdateVersion: null,
@ -148,7 +203,7 @@ const CONFIG_DEFAULTS: AppConfig = {
suggestionDailyBudget: 500,
suggestionOverlayInteractive: true,
suggestionRequestTimeoutMs: 8000,
suggestionTuningRevision: 4,
suggestionTuningRevision: 5,
}
let store: ElectronStore<AppConfig> | null = null

View file

@ -18,22 +18,28 @@ import { EventEmitter } from 'events'
import { screen } from 'electron'
import { uIOhook } from 'uiohook-napi'
import type { UiohookKeyboardEvent, UiohookMouseEvent, UiohookWheelEvent } from 'uiohook-napi'
import { sql, gte, eq, desc } from 'drizzle-orm'
import { sql, gte, eq, desc, inArray } from 'drizzle-orm'
import {
INPUT_TELEMETRY_DEFAULTS,
SUGGESTION_CONTEXT_MAX_CHARS,
SUGGESTION_DEFAULTS,
calculateFrictionInsight,
classifyKeyStroke,
computeTypedDelta,
countWords,
emptyActivityBucket,
extractPhrases,
isAppExcluded,
isLearnablePhrase,
LEARNING_EXCLUDED_APPS,
manhattanDistance,
mergeActivityBucket,
rankFlowWindows,
recommendAppExclusion,
summarizeActivity,
textBeforeCaret,
withoutPlaceholderText,
type AnchorKind,
type FocusSnapshot,
type InputActivityBucket,
type InputAppStat,
@ -45,6 +51,7 @@ import {
type InputTelemetryState,
type InputSnapshotSummary,
type AppReadabilityEvidence,
type KeyStrokeClass,
type PersonalPhrase,
type PhraseSource,
type UiRect
@ -68,6 +75,8 @@ export interface TypingContext {
fullText: string
caretOffset: number | null
anchor: UiRect | null
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
anchorKind: AnchorKind
isPassword: boolean
isEditable: boolean
isComposing: boolean
@ -77,6 +86,10 @@ export interface TypingContext {
windowTitle: string | null
idleMs: number
capturedAt: number
/** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */
editedSinceFocus: boolean
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
typedRecently: boolean
}
interface InputTelemetryEvents {
@ -95,6 +108,22 @@ const MAX_SAMPLE_CHARS = 500
const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000
const MAX_READABILITY_APPS = 64
/**
* "실제로 타이핑했다" 로 볼 키 종류.
*
* 화살표/단축키/기능키/엔터/탭 등은 제외한다 — 필드 이동이나 명령일 뿐 텍스트를
* 치는 게 아니다. IME 조합은 포함한다(한/일 입력은 조합 중이 곧 타이핑이다).
*/
const TYPING_KEY_CLASSES = new Set<KeyStrokeClass>([
'letter',
'digit',
'symbol',
'space',
'backspace',
'delete',
'ime'
])
class InputTelemetryService extends EventEmitter {
private _running = false
private _releaseHook: (() => void) | null = null
@ -124,6 +153,18 @@ class InputTelemetryService extends EventEmitter {
* 필드에 이미 있던 텍스트로 제안이 만들어졌다(실측 신고).
*/
private _lastKeyAt = 0
/**
* 마지막 "실제 타이핑" 시각 (TYPING_KEY_CLASSES 에 속하는 키만).
*
* `_lastKeyAt` 은 화살표/단축키를 포함한 모든 키를 기록해 필드 이동만으로도
* 갱신된다. 제안 게이트는 진짜 타이핑만 기준으로 삼아야 한다(실측: 필드에
* 이미 있던 텍스트를 마우스로 클릭만 했는데 제안이 뜸).
*/
private _lastTypedAt = 0
/** 현재 포커스된 (창, 컨트롤 종류, 위치)를 식별하는 키 — 바뀌면 새 필드로 본다. */
private _lastFocusKey = ''
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
private _textAtFocus = ''
private _lastActiveTickAt = 0
private _lastMouse: { x: number; y: number } | null = null
private _lastClickAt = 0
@ -338,6 +379,7 @@ class InputTelemetryService extends EventEmitter {
if (keyClass === 'backspace') bucket.backspaces += 1
this._lastKeyAt = Date.now()
if (TYPING_KEY_CLASSES.has(keyClass)) this._lastTypedAt = this._lastKeyAt
this._scheduleTextSnapshot()
}
@ -518,16 +560,19 @@ class InputTelemetryService extends EventEmitter {
if (!this._running) return null
const uia = getUiaContextService()
const snapshot = await uia.getSnapshot()
const snapshot = withoutPlaceholderText(await uia.getSnapshot())
const now = Date.now()
// 유휴 시간은 키보드 기준이다 (마우스 이동/클릭으로는 제안하지 않는다).
const idleMs = this._lastKeyAt > 0 ? now - this._lastKeyAt : Number.MAX_SAFE_INTEGER
const typedRecently =
this._lastTypedAt > 0 && now - this._lastTypedAt <= SUGGESTION_DEFAULTS.recentTypingWindowMs
const context: TypingContext = {
prefix: '',
fullText: '',
caretOffset: null,
anchor: null,
anchorKind: null,
isPassword: snapshot.isPassword,
isEditable: snapshot.isEditable,
isComposing: snapshot.isComposing,
@ -536,11 +581,15 @@ class InputTelemetryService extends EventEmitter {
appName: this._foreground.appName,
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
idleMs,
capturedAt: snapshot.capturedAt
capturedAt: snapshot.capturedAt,
editedSinceFocus: false,
typedRecently
}
if (snapshot.available) {
const usesCaretRect = snapshot.caretRect !== null
context.anchor = snapshot.caretRect ?? snapshot.elementRect
context.anchorKind = usesCaretRect ? 'caret' : snapshot.elementRect ? 'element' : null
context.caretOffset = snapshot.caretOffset
context.fullText = snapshot.text
// 케어렛 오프셋을 못 얻는 제공자가 많다(실측: Notepad — src=value, caret=null).
@ -558,6 +607,18 @@ class InputTelemetryService extends EventEmitter {
: tail
this._caretFallback = !caretKnown
// 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다).
// 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가
// 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데
// 옛 검색어로 제안이 뜸).
const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect)
const textForFocus = snapshot.isPassword ? '' : snapshot.text
if (focusKey !== this._lastFocusKey) {
this._lastFocusKey = focusKey
this._textAtFocus = textForFocus
}
context.editedSinceFocus = textForFocus !== this._textAtFocus
if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) {
this._applyTypedDelta(snapshot)
}
@ -670,7 +731,15 @@ class InputTelemetryService extends EventEmitter {
raw: string,
meta: { appName: string | null; windowTitle: string | null; source: PhraseSource }
): void {
const text = sanitizeSample(raw)
if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) {
return
}
const text = sanitizeSample(
raw
.split(/\r?\n/u)
.filter((line) => isLearnablePhrase(line))
.join('\n')
)
if (!text) return
try {
@ -699,7 +768,7 @@ class InputTelemetryService extends EventEmitter {
at: now
})
for (const phrase of extractPhrases(text)) {
for (const phrase of extractPhrases(text).filter(isLearnablePhrase)) {
db.insert(personalPhrases)
.values({
id: crypto.randomUUID(),
@ -1197,11 +1266,74 @@ class InputTelemetryService extends EventEmitter {
}
}
/**
* 지금의 학습 규칙(제외 앱·문장 판정)에 맞지 않는 기존 코퍼스를 걷어낸다.
*
* 규칙이 생기기 전에 쌓인 터미널 상태줄·개발 지시가 그래프를 개발 쪽으로 끌고
* 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다.
*/
private _pruneUnlearnableCorpus(): void {
const excluded = (source: string, appName: string | null): boolean =>
source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)
try {
const db = getDatabase()
const phraseIds = db
.select({
id: personalPhrases.id,
phrase: personalPhrases.phrase,
source: personalPhrases.source,
appName: personalPhrases.appName
})
.from(personalPhrases)
.all()
.filter((row) => excluded(row.source, row.appName) || !isLearnablePhrase(row.phrase))
.map((row) => row.id)
const sampleIds = db
.select({
id: typingSamples.id,
text: typingSamples.text,
source: typingSamples.source,
appName: typingSamples.appName
})
.from(typingSamples)
.all()
.filter(
(row) =>
excluded(row.source, row.appName) ||
!row.text.split(/\r?\n/u).some((line) => isLearnablePhrase(line))
)
.map((row) => row.id)
for (let i = 0; i < phraseIds.length; i += 200) {
db.delete(personalPhrases).where(inArray(personalPhrases.id, phraseIds.slice(i, i + 200))).run()
}
for (let i = 0; i < sampleIds.length; i += 200) {
db.delete(typingSamples).where(inArray(typingSamples.id, sampleIds.slice(i, i + 200))).run()
}
db.run(
sql`DELETE FROM phrase_edges
WHERE from_phrase NOT IN (SELECT phrase FROM personal_phrases)
OR to_phrase NOT IN (SELECT phrase FROM personal_phrases)`
)
if (phraseIds.length > 0 || sampleIds.length > 0) {
logger.info(`학습 규칙에 맞지 않는 코퍼스 정리: 문구 ${phraseIds.length}개, 표본 ${sampleIds.length}개`)
}
} catch (error) {
logger.warn(`코퍼스 정리 실패: ${error instanceof Error ? error.message : String(error)}`)
}
}
/** 보존 기간 초과 데이터 정리 (ActivityWatch 와 동일한 로컬 보존 정책). */
private _pruneOldData(now = Date.now()): void {
if (now - this._prunedAt < RETENTION_PRUNE_INTERVAL_MS) return
this._prunedAt = now
this._pruneUnlearnableCorpus()
// 그래프 유지보수도 같은 주기에 돌린다 (용어 공유 엣지 백필).
getPersonalGraphService().runMaintenance()
@ -1252,6 +1384,19 @@ function lastLineOf(text: string): string {
return text.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
}
/**
* 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 위치.
*
* 높이는 뺀다 — 여러 줄 입력창은 타이핑에 따라 높이가 자라, 높이를 포함하면
* 같은 필드인데도 칠 때마다 "새 포커스" 로 오인돼 기준선이 계속 리셋된다.
*/
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 formatLocalDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')

View file

@ -208,11 +208,20 @@ class LocalLLMService extends EventEmitter {
logger.info(`Ollama not running, auto-starting from ${binaryPath}`)
try {
const child = spawn(binaryPath, ['serve'], {
detached: true,
stdio: 'ignore',
windowsHide: true
})
// Windows에서 serve를 detached(DETACHED_PROCESS)로 띄우면 serve에 콘솔이 없어,
// ollama가 모델을 올릴 때마다 띄우는 runner(llama-server)가 새 콘솔 창을 받는다
// (실측: 타이핑 → 제안 → 모델 로드마다 터미널 창이 떠 포커스를 뺏음).
// 공식 트레이 앱이 있으면 그것으로 띄우고, 없으면 detached 없이 숨김 콘솔을 물려준다.
const trayApp =
process.platform === 'win32' ? path.join(path.dirname(binaryPath), 'ollama app.exe') : null
const useTray = trayApp !== null && trayApp !== binaryPath && fs.existsSync(trayApp)
const child = useTray
? spawn(trayApp, [], { detached: true, stdio: 'ignore', windowsHide: true })
: spawn(binaryPath, ['serve'], {
detached: process.platform !== 'win32',
stdio: 'ignore',
windowsHide: true
})
child.unref()
logger.info('Ollama serve spawned (detached) — polling will detect readiness')
return 'starting'

View file

@ -201,6 +201,7 @@ class LocalSTTService extends EventEmitter {
private _state: STTState = STTState.Uninitialized
private _sidecarProcess: ChildProcess | null = null
private _sidecarStarting: Promise<void> | null = null
private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null
private _restartCount: number = 0
@ -590,12 +591,26 @@ class LocalSTTService extends EventEmitter {
// ── Sidecar 관리 ──
/** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */
private async _ensureSidecarRunning(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
/**
* sidecar가 실행 중이 아니면 spawn + 헬스체크 대기.
*
* 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에
* (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로
* 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다.
*/
private _ensureSidecarRunning(): Promise<void> {
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
return Promise.resolve()
}
if (!this._sidecarStarting) {
this._sidecarStarting = (async () => {
await this._spawnSidecar()
await this._waitForHealth()
})().finally(() => {
this._sidecarStarting = null
})
}
return this._sidecarStarting
}
private _emitDownloadProgress(
@ -769,11 +784,12 @@ class LocalSTTService extends EventEmitter {
if (!needsInstall) throw err
}
logger.info('로컬 음성 엔진이 없습니다 — 자동 다운로드를 시작합니다')
await getRuntimeProvisioner().ensure('sidecar')
const launch = getSidecarCommand()
logger.info(`런타임 설치 후 사이드카 경로: ${launch.command} (${launch.source})`)
return launch
// ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 —
// 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다.
logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)')
const binaryPath = await getRuntimeProvisioner().ensure('sidecar')
logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`)
return { command: binaryPath, args: [], source: 'provisioned' }
}
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */

View file

@ -23,7 +23,7 @@ import {
type PhraseSource,
type RelatedCandidate
} from '@d3ro/core/personal-graph'
import { prefixTail } from '@d3ro/core/input-intelligence'
import { isLearnablePhrase, prefixTail } from '@d3ro/core/input-intelligence'
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
import { getDatabase } from '../db'
import { personalPhrases, phraseEdges } from '../db/schema'
@ -51,7 +51,7 @@ class PersonalGraphService {
text: string,
meta: { source: PhraseSource; appName?: string | null; at?: number }
): void {
const sentences = splitSentences(text)
const sentences = splitSentences(text).filter(isLearnablePhrase)
if (sentences.length === 0) return
const at = meta.at ?? Date.now()

View file

@ -13,7 +13,7 @@
import { EventEmitter, once } from 'events'
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { mkdir, rm, stat } from 'node:fs/promises'
import { Readable, Writable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
@ -23,6 +23,7 @@ import * as tar from 'tar'
import { getLogger } from './LoggerService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { RUNTIME_FEED_URL } from '../update-feed'
import { compareVersions } from '../update-policy'
const logger = getLogger('RuntimeProvisioner')
@ -68,6 +69,17 @@ export interface RuntimeStatus {
}
const RUNTIME_DIR_NAME = 'runtime'
/** 설치된 런타임의 버전(runtime.json version)을 남기는 마커 */
const RUNTIME_VERSION_FILE = '.runtime-version'
/**
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다.
* 1.5.0 — UIA 브리지(`/uia/focus`)가 처음 들어간 사이드카. ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
*/
const RUNTIME_MIN_VERSION: Record<RuntimeComponent, string | null> = {
sidecar: '1.5.0',
ffmpeg: null
}
const DOWNLOAD_TIMEOUT_MS = 120_000
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
const PART_DOWNLOAD_ATTEMPTS = 3
@ -106,6 +118,31 @@ class RuntimeProvisioner extends EventEmitter {
return true
}
/**
* 설치된 런타임이 이 앱이 요구하는 최소 버전 이상인지.
*
* 사이드카는 설치본에 넣지 않고 별도로 내려받는다(위 주석) — 그래서 앱이 업데이트돼
* 새 엔드포인트를 요구해도(예: v1.5.0의 `/uia/focus`), 예전에 내려받은 사이드카가
* 남아 있으면 새 앱은 그 구버전 엔진과 계속 통신한다. 마커가 없거나 최소 버전보다
* 낮으면 다시 받는다. 앱 버전과 "같음" 으로 비교하지 않는다 — 그러면 엔진이 그대로인
* 릴리스마다 모든 사용자가 100MB를 다시 받는다.
*/
private _isCurrentVersion(component: RuntimeComponent): boolean {
const minimum = RUNTIME_MIN_VERSION[component]
if (minimum === null) return true
try {
const installed = readFileSync(this._versionMarkerPath(component), 'utf8').trim()
const order = compareVersions(installed, minimum)
return order !== null && order >= 0
} catch {
return false
}
}
private _versionMarkerPath(component: RuntimeComponent): string {
return join(this.componentDir(component), RUNTIME_VERSION_FILE)
}
getStatus(): RuntimeStatus[] {
return RUNTIME_COMPONENTS.map((component) => {
const binary = this.binaryPath(component)
@ -129,7 +166,7 @@ class RuntimeProvisioner extends EventEmitter {
* 동시 호출은 같은 작업을 공유한다.
*/
async ensure(component: RuntimeComponent): Promise<string> {
if (this.isInstalled(component)) {
if (this.isInstalled(component) && this._isCurrentVersion(component)) {
return this.binaryPath(component)
}
@ -190,6 +227,8 @@ class RuntimeProvisioner extends EventEmitter {
)
}
writeFileSync(this._versionMarkerPath(component), index.version, 'utf8')
this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0)
logger.info(
`런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`,

View file

@ -20,9 +20,12 @@ import {
decideSuggestion,
decideSuggestionRefresh,
isAppExcluded,
matchesSessionPrefix,
parseSuggestionCandidates,
sanitizeSuggestionLine,
selectPhraseHints,
TERMINAL_APPS,
type AnchorKind,
type SuggestionCandidate,
type SuggestionProvenance,
type SuggestionSkipReason,
@ -76,18 +79,24 @@ const MAX_RAW_OUTPUT_CHARS = 1200
/**
* 모델을 메모리에 유지하는 시간.
*
* 자동 입력 제안은 반복 호출 경로이므로, 짧은 유휴 구간만 모델을 유지한다.
* 콜드 리로드 실측(11.4s)이 요청 타임아웃(8s)보다 길어, 짧은 유휴 유지 시간은
* 유휴 후 첫 요청을 항상 타임아웃시켰다 — 충분히 길게 유지한다.
*/
const SUGGESTION_KEEP_ALIVE = '2m'
const SUGGESTION_KEEP_ALIVE = '10m'
const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000
class SuggestionService extends EventEmitter {
private _candidates: SuggestionCandidate[] = []
private _activeIndex = 0
private _anchor: UiRect | null = null
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
private _anchorKind: AnchorKind = null
private _appName: string | null = null
private _windowTitle: string | null = null
/** 후보를 만든 시점의 접두 — 접두가 달라지면 stale */
private _generatedForPrefix = ''
/** 이 세션이 채우려는 후보 총량 (모델 세션 12 / 로컬 기억 세션은 현재 개수) */
private _targetTotal = 0
private _lastSkipReason: SuggestionSkipReason | null = null
private _lastDecisionSignature = ''
/** 생성 중 — 후보 도착 전에도 오버레이를 띄운다 */
@ -122,6 +131,10 @@ class SuggestionService extends EventEmitter {
private _provenance: SuggestionProvenance | null = null
private _lastRequestAt = 0
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
private _prevRequestAt = 0
/** 모델이 메모리에 남아 있다고 볼 수 있는 시각 (ms). 이 시각 이후에는 콜드 리로드로 본다. */
private _modelWarmUntil = 0
private _minuteWindowStart = 0
private _minuteCount = 0
private _dayKey = ''
@ -129,6 +142,9 @@ class SuggestionService extends EventEmitter {
private _inFlight = false
private _abort: AbortController | null = null
/** 채우기 루프(2번째 이후 후보) 진행 중 — _inFlight 와 별개다 (예산/타임아웃 카운트 제외). */
private _filling = false
private _fillAbort: AbortController | null = null
private _timeoutTimer: NodeJS.Timeout | null = null
private _lastLatencyMs: number | null = null
private _warmUpPromise: Promise<void> | null = null
@ -182,7 +198,9 @@ class SuggestionService extends EventEmitter {
partialText: this._partialText || null,
candidates: [...this._candidates],
activeIndex: this._activeIndex,
targetTotal: this._targetTotal,
anchor: this._anchor,
anchorKind: this._anchorKind,
appName: this._appName,
updatedAt: now,
lastSkipReason: this._lastSkipReason,
@ -263,6 +281,7 @@ class SuggestionService extends EventEmitter {
})
for await (const chunk of stream) void chunk
if (abort.signal.aborted) return
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
} catch (error) {
if (!abort.signal.aborted) {
@ -275,6 +294,10 @@ class SuggestionService extends EventEmitter {
}
} finally {
this._warmingUp = false
// 'state-changed' 뿐 아니라 'updated' 도 보내야 한다 — 오버레이는 'updated' 를
// 듣고 표시 여부를 판단하는데, 이게 빠지면 타이핑이 멈춘 사이 워밍업이 끝나도
// "준비 중" 오버레이가 20초 TTL 까지 그대로 남는다.
this.emit('updated', this.getState())
this.emit('state-changed', this.getState())
}
}
@ -322,6 +345,16 @@ class SuggestionService extends EventEmitter {
this._logDecisionInputs(context)
this._abortStaleGeneration(context.prefix)
// 세션(페이지 넘기며 보는 고정 목록)이 후보를 보여주는 중이면, 다음 문장이
// 시작된 순간 — 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외 —
// 즉시 세션을 끝낸다(사용자 요청 사양: 계속 자라는 접두를 따라가지 않는다).
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션이 시작된다.
if (this.isVisible && !matchesSessionPrefix(this._generatedForPrefix, context.prefix)) {
this.dismiss('stale')
this.emit('state-changed', this.getState())
return
}
// 연속 실패 쿨다운. 모델이 다른 작업으로 바쁘면(로컬 에이전트 동시 사용 등)
// 요청이 계속 타임아웃되어 스피너만 깜빡인다 — 잠시 요청을 멈춘다.
if (Date.now() < this._cooldownUntil) {
@ -352,17 +385,19 @@ class SuggestionService extends EventEmitter {
}
const decision = decideSuggestion({
enabled: this.isEnabled(),
// 접두가 충분히 자랐으면 "이미 떠 있음" 으로 막지 않는다 (지속 갱신).
// (decideSuggestion 의 overlayVisible 인자는 아래에서 계산한다)
modelAvailable: getLocalLLMService().isAvailable(),
// 생성 중에도 "이미 진행 중" 으로 취급해 중복 요청과 깜빡임을 막는다.
overlayVisible: this.isPresentationActive && !this._shouldRegenerate(context.prefix),
// 생성 중이거나 세션이 떠 있으면 "이미 진행 중" 으로 취급해 중복 요청과
// 깜빡임을 막는다. 접두가 어긋난 세션은 위에서 이미 dismiss 하고 return 했으므로
// 여기 도달했다는 것 자체가 "그대로 유효한 세션" 이라는 뜻이다.
overlayVisible: this.isPresentationActive,
composing: context.isComposing,
hasSelection: context.hasSelection,
isPassword: context.isPassword,
isEditable: context.isEditable,
appName: context.appName,
excludedApps: config.excludedApps,
editedSinceFocus: context.editedSinceFocus,
typedRecently: context.typedRecently,
prefix: context.prefix,
idleMs: context.idleMs,
triggerDelayMs: config.triggerDelayMs,
@ -383,13 +418,6 @@ class SuggestionService extends EventEmitter {
return
}
// 이미 보여준 제안이 현재 접두와 어긋나면 즉시 지운다 (tab-completion 표준 동작).
if (this.isVisible && !this._matchesGeneratedPrefix(context.prefix)) {
this.dismiss('stale')
this.emit('state-changed', this.getState())
return
}
if (decision.action === 'skip') {
logger.debug(`제안 건너: ${decision.reason}`)
this._lastSkipReason = decision.reason
@ -420,6 +448,7 @@ class SuggestionService extends EventEmitter {
}
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
// 워밍업 안내도 수명이 있다 — 모델이 뜬 뒤 갱신이 없으면 스스로 사라진다.
this._armVisibleTtl()
@ -430,6 +459,32 @@ class SuggestionService extends EventEmitter {
return
}
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다.
// 콜드 리로드(실측 11.4s)는 요청 타임아웃(8s)보다 길어 그대로 요청하면 항상 시간 초과한다.
if (now >= this._modelWarmUntil) {
if (this._canUseLocalMemory(context, config, now)) {
const shown = this._publishLocalMemory(
this._currentPrefix(context),
context,
config.maxCandidates,
config.maxChars,
0,
null
)
if (shown) return
}
void this.warmUp()
this._warmingUp = true
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._armVisibleTtl()
this.emit('updated', this.getState())
this._lastSkipReason = 'model-unavailable'
this.emit('state-changed', this.getState())
return
}
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
void this._generate(decision.prefix, context, config.maxCandidates, config.maxChars).catch(
(error: unknown) => {
@ -457,8 +512,12 @@ class SuggestionService extends EventEmitter {
const prefix = context.prefix.replace(/\s+$/u, '')
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
// 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에
// 좌우되지 않아야 한다. 그대로 넘기면 _generate 실패 경로의 _canUseLocalMemory 가
// (클릭만 한 뒤 수동 요청 같은 경우) not-typing 으로 막아 버린다.
const explicitContext: TypingContext = { ...context, editedSinceFocus: true, typedRecently: true }
const config = this.readPolicyConfig()
await this._generate(prefix, context, config.maxCandidates, config.maxChars).catch((error: unknown) => {
await this._generate(prefix, explicitContext, config.maxCandidates, config.maxChars).catch((error: unknown) => {
logger.warn(`수동 제안 생성 예외: ${error instanceof Error ? error.message : String(error)}`)
this._releaseGeneration()
})
@ -480,6 +539,8 @@ class SuggestionService extends EventEmitter {
isEditable: context.isEditable,
appName: context.appName,
excludedApps: config.excludedApps,
editedSinceFocus: context.editedSinceFocus,
typedRecently: context.typedRecently,
prefix: context.prefix,
idleMs: context.idleMs,
triggerDelayMs: config.triggerDelayMs,
@ -560,9 +621,11 @@ class SuggestionService extends EventEmitter {
this._candidates = candidates.map((text, index) => ({ text, rank: index }))
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._generatedForPrefix = trimmedPrefix
this._targetTotal = candidates.length
this._lastSkipReason = null
this._lastLatencyMs = latencyMs
this._provenance = {
@ -638,6 +701,7 @@ class SuggestionService extends EventEmitter {
this._lastRequestedPrefix = prefix.replace(/\s+$/u, '')
this._warmingUp = false
this._provenance = null
this._prevRequestAt = this._lastRequestAt
this._lastRequestAt = Date.now()
this._minuteCount += 1
this._dayCount += 1
@ -647,6 +711,7 @@ class SuggestionService extends EventEmitter {
this._candidates = []
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._armVisibleTtl()
@ -671,13 +736,15 @@ class SuggestionService extends EventEmitter {
const hints = [...memory.relatedHints, ...memory.phraseHints]
.filter((value, index, list) => list.indexOf(value) === index)
.slice(0, 5)
// 세션의 첫 요청은 딱 1개만 청한다 — 한꺼번에 여러 개를 요청하면 느리다(사용자
// 요청). 첫 후보를 보여준 뒤 나머지는 채우기 루프가 하나씩 순차로 더 만든다.
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: trimmedPrefix,
appName: context.appName,
windowTitle: context.windowTitle,
phraseHints: hints,
continuationHints,
candidates: maxCandidates,
candidates: 1,
maxChars
})
@ -697,6 +764,8 @@ class SuggestionService extends EventEmitter {
for await (const chunk of stream) {
if (abort.signal.aborted) break
raw += chunk
// 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다.
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
@ -783,7 +852,7 @@ class SuggestionService extends EventEmitter {
return
}
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, maxCandidates, maxChars)
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
if (candidates.length === 0) {
this._lastSkipReason = 'generation-failed'
logger.info('제안 후보가 비어 있음 (모델 출력 정제 후)')
@ -809,14 +878,15 @@ class SuggestionService extends EventEmitter {
this._lastLatencyMs = latencyMs
this._consecutiveFailures = 0
this._cooldownUntil = 0
this._generating = false
this._partialText = ''
this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index }))
this._activeIndex = 0
this._anchor = context.anchor
this._anchorKind = context.anchorKind
this._appName = context.appName
this._windowTitle = context.windowTitle
this._generatedForPrefix = trimmedPrefix
this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal
this._lastSkipReason = null
this._provenance = {
mode: 'local-model',
@ -827,9 +897,18 @@ class SuggestionService extends EventEmitter {
}
this._record({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
logger.info(`제안 ${candidates.length}개 생성 (${latencyMs}ms, model=${model})`)
logger.info(`제안 첫 후보 생성 (${latencyMs}ms, model=${model}) — 최대 ${SUGGESTION_DEFAULTS.maxCandidatesTotal}개까지 순차로 채운다`)
this._armVisibleTtl()
this.emit('updated', this.getState())
// generating 은 계속 true 로 남는다 — 채우기 루프가 백그라운드에서 나머지를
// 하나씩 청한다("더 온다" 를 UI 에 알린다). _releaseGeneration() 이 이를
// 지우지 않도록 _filling 을 먼저 켠다.
this._filling = true
void this._runFillLoop(token, context, maxChars).catch((error: unknown) => {
logger.warn(`제안 채우기 루프 예외: ${error instanceof Error ? error.message : String(error)}`)
this._filling = false
})
} finally {
// 어떤 경로로 끝나든 진행 플래그를 해제한다 —
// 예외가 어디로 새든 다음 제안이 조용히 막히지 않는다.
@ -837,6 +916,126 @@ class SuggestionService extends EventEmitter {
}
}
// ── 채우기 루프 (2번째 이후 후보, 최대 12개) ────────────
/**
* 첫 후보 공개 뒤 나머지를 하나씩 순차로 채운다.
*
* 종료 조건: 12개 도달, 연속 2번 새 후보 없음, 세대 토큰이 바뀜(세션 종료).
* `_generate` 의 예산/타임아웃/실패 카운트와 무관하다 — 채우기는 그 어느 것도
* 소비하지 않는다(설계). 시간 초과된 채우기 요청은 조용히 다음으로 넘어간다.
*/
private async _runFillLoop(token: number, context: TypingContext, maxChars: number): Promise<void> {
let consecutiveEmpty = 0
while (
token === this._generationToken &&
this._candidates.length < SUGGESTION_DEFAULTS.maxCandidatesTotal &&
consecutiveEmpty < 2
) {
const appended = await this._fillOne(token, context, maxChars)
if (token !== this._generationToken) break
consecutiveEmpty = appended ? 0 : consecutiveEmpty + 1
}
if (token === this._generationToken) {
this._filling = false
this._generating = false
this.emit('updated', this.getState())
}
}
/** 채우기 루프 한 스텝 — 후보 1개를 요청해 고유하면 덧붙인다. */
private async _fillOne(token: number, context: TypingContext, maxChars: number): Promise<boolean> {
const model = this.resolveModel()
if (!model) return false
const abort = new AbortController()
this._fillAbort = abort
const timeoutMs = this.readTimeoutMs()
const timer = setTimeout(() => {
if (!abort.signal.aborted) abort.abort()
}, timeoutMs)
timer.unref?.()
try {
// 세션이 고정한 접두를 쓴다 — context.prefix 는 그사이 바뀌었을 수 있지만,
// 바뀌었다면 이미 위(handleTypingContext)에서 세션이 dismiss 되어 토큰이
// 달라져 있으므로 이 루프는 다음 체크에서 멈춘다.
const prefix = this._generatedForPrefix
const memory = this._collectMemoryHints(prefix, context.appName)
const hints = [...memory.relatedHints, ...memory.phraseHints]
.filter((value, index, list) => list.indexOf(value) === index)
.slice(0, 5)
const avoidCandidates = this._candidates.map((candidate) => candidate.text)
const { systemPrompt, text } = buildSuggestionPrompt({
prefix,
appName: context.appName,
windowTitle: context.windowTitle,
phraseHints: hints,
continuationHints: memory.continuationHints,
avoidCandidates,
candidates: 1,
maxChars
})
let raw = ''
const stream = getLocalLLMService().streamGenerate(text, {
model,
systemPrompt,
temperature: SUGGESTION_DEFAULTS.temperature,
maxTokens: SUGGESTION_DEFAULTS.maxOutputTokens,
signal: abort.signal,
keepAlive: SUGGESTION_KEEP_ALIVE
})
for await (const chunk of stream) {
if (abort.signal.aborted) break
raw += chunk
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
}
if (abort.signal.aborted || token !== this._generationToken) return false
const parsed = parseSuggestionCandidates(raw, prefix, 1, maxChars)
if (parsed.length === 0) return false
const candidateText = parsed[0]
if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false
this._candidates.push({ text: candidateText, rank: this._candidates.length })
this._armVisibleTtl()
this.emit('updated', this.getState())
this._record({
prefix,
text: candidateText,
model,
latencyMs: 0,
candidateCount: this._candidates.length
})
return true
} catch (error) {
if (!abort.signal.aborted) {
logger.debug(
`제안 채우기 요청 실패 — 조용히 다음으로 넘어간다: ${error instanceof Error ? error.message : String(error)}`
)
}
return false
} finally {
clearTimeout(timer)
if (this._fillAbort === abort) this._fillAbort = null
}
}
/** 정규화 후 완전 중복이거나 기존 후보의 접두/확장이면 중복으로 본다. */
private _isDuplicateCandidate(candidate: string): boolean {
const normalized = candidate.replace(/\s+/gu, ' ').trim().toLowerCase()
return this._candidates.some((existing) => {
const existingNormalized = existing.text.replace(/\s+/gu, ' ').trim().toLowerCase()
return (
existingNormalized === normalized ||
existingNormalized.startsWith(normalized) ||
normalized.startsWith(existingNormalized)
)
})
}
/**
* 생성 플래그를 무조건 해제한다 (누수 감시 포함).
*
@ -844,7 +1043,9 @@ class SuggestionService extends EventEmitter {
*/
private _releaseGeneration(): void {
this._inFlight = false
this._generating = false
// 채우기 루프가 막 시작됐으면 generating 을 그대로 둔다 — 아직 더 올 게 있다.
// (루프 자신이 끝날 때 스스로 false 로 내린다)
if (!this._filling) this._generating = false
this._partialText = ''
}
@ -923,10 +1124,6 @@ class SuggestionService extends EventEmitter {
}
}
private _shouldRegenerate(currentPrefix: string): boolean {
return decideSuggestionRefresh(this._generatedForPrefix, currentPrefix) === 'regenerate'
}
private _abortStaleGeneration(currentPrefix: string): void {
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
@ -934,6 +1131,15 @@ class SuggestionService extends EventEmitter {
this._generationToken += 1
this._abort.abort()
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
this._lastRequestAt = this._prevRequestAt
this._minuteCount = Math.max(0, this._minuteCount - 1)
this._dayCount = Math.max(0, this._dayCount - 1)
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
if (this._candidates.length === 0) this.dismiss('stale')
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
}
@ -960,32 +1166,46 @@ class SuggestionService extends EventEmitter {
this.emit('updated', this.getState())
}
private _matchesGeneratedPrefix(currentPrefix: string): boolean {
const current = currentPrefix.replace(/\s+$/u, '')
if (!this._generatedForPrefix) return false
return current === this._generatedForPrefix || current.startsWith(this._generatedForPrefix)
}
// ── 사용자 동작 ───────────────────────────────────────
/** 이전 후보로 순환. */
/** 이전 후보로 순환 (전체 후보를 가로질러, 페이지 무관). */
previous(): SuggestionState {
if (this._candidates.length > 1) {
this._activeIndex =
(this._activeIndex - 1 + this._candidates.length) % this._candidates.length
this.emit('updated', this.getState())
this._moveActive((this._activeIndex - 1 + this._candidates.length) % this._candidates.length)
}
return this.getState()
}
next(): SuggestionState {
if (this._candidates.length > 1) {
this._activeIndex = (this._activeIndex + 1) % this._candidates.length
this.emit('updated', this.getState())
this._moveActive((this._activeIndex + 1) % this._candidates.length)
}
return this.getState()
}
/** 다음 페이지의 첫 항목으로 — 그 페이지에 후보가 없으면 그대로 둔다. */
pageNext(): SuggestionState {
const pageSize = SUGGESTION_DEFAULTS.pageSize
const totalPages = Math.ceil(this._candidates.length / pageSize)
const currentPage = Math.floor(this._activeIndex / pageSize)
if (currentPage < totalPages - 1) this._moveActive((currentPage + 1) * pageSize)
return this.getState()
}
/** 이전 페이지의 첫 항목으로 — 이미 첫 페이지면 그대로 둔다. */
pagePrev(): SuggestionState {
const currentPage = Math.floor(this._activeIndex / SUGGESTION_DEFAULTS.pageSize)
if (currentPage > 0) this._moveActive((currentPage - 1) * SUGGESTION_DEFAULTS.pageSize)
return this.getState()
}
/** 사용자가 후보를 훑는 중에는 표시 수명이 끝나 창이 닫히면 안 된다. */
private _moveActive(index: number): void {
this._activeIndex = index
this._armVisibleTtl()
this.emit('updated', this.getState())
}
dismiss(reason: SuggestionSkipReason = 'dismissed'): void {
if (reason === 'dismissed') {
// 명시적 닫기: 잠깐 조용히 있고, 진행 중 생성은 무효화한다.
@ -1002,16 +1222,24 @@ class SuggestionService extends EventEmitter {
this._partialText = ''
this._activeIndex = 0
this._anchor = null
this._anchorKind = null
this._generatedForPrefix = ''
this._targetTotal = 0
this._provenance = null
this._lastSkipReason = reason
this._abort?.abort()
this._abort = null
// 채우기 루프도 함께 끝낸다 — accept/dismiss/stale 은 전부 세션 종료다(설계).
this._filling = false
this._fillAbort?.abort()
this._fillAbort = null
if (this._timeoutTimer) {
clearTimeout(this._timeoutTimer)
this._timeoutTimer = null
}
this._cancelWarmUp()
// 워밍업은 오버레이 표시와 독립된 요청이다 — 포커스 전환/stale 등으로 오버레이를
// 지울 때마다 취소하면 모델이 영영 안 뜬다. 설정에서 기능을 끌 때만 취소한다.
if (reason === 'disabled') this._cancelWarmUp()
if (wasVisible || reason === 'dismissed') {
this.emit('cleared', { reason })
}
@ -1100,7 +1328,8 @@ class SuggestionService extends EventEmitter {
dailyBudget: configGet('suggestionDailyBudget') || SUGGESTION_DEFAULTS.dailyBudget,
maxCandidates: SUGGESTION_DEFAULTS.maxCandidates,
maxChars: SUGGESTION_MAX_OUTPUT_CHARS,
excludedApps: [...configGet('inputExcludedApps')]
// 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다.
excludedApps: [...configGet('inputExcludedApps'), ...TERMINAL_APPS]
}
}

View file

@ -186,12 +186,14 @@ class VoiceModeService extends EventEmitter {
// 토글이므로 press만 처리하고 release는 버린다.
if (payload.type === 'pressed') this._toggleCaption()
return
case 'history-popup':
case 'command-popup':
// 팝업 액션은 bootstrap이 직접 구독한다.
return
default:
case 'dictation':
case 'hands-free':
case 'command':
break
default:
// 음성 세션 액션만 여기서 처리한다. 팝업·제안 액션은 bootstrap이 직접 구독한다
// (허용 목록이 아니면 새 액션이 생길 때마다 받아쓰기로 오인돼 녹음이 켜진다).
return
}
const holdMode = this._resolveHoldMode(payload.actionId, payload.holdMode)

View file

@ -165,6 +165,9 @@ export const SUGGESTION_NO_THINK_PREFIX = '/no_think'
const SUGGESTION_SYSTEM_PROMPT = `사용자가 지금 입력창에 글을 쓰는 중입니다. 사용자가 마지막으로 쓴 글 뒤에 이어질 다음 문장을 제안하세요.
규칙:
- 당신은 사용자와 대화하는 비서가 아닙니다. 사용자 본인이 다음에 직접 칠 문장만, 사용자 자신의 목소리로 쓰세요.
- 사용자에게 질문하지 말고, 도와주겠다고 제안하지 말고, 사용자를 부르듯 말하지 마세요. ("궁금한 점이 있으신가요?", "도와드릴까요?" 같은 응답 금지)
- 입력이 검색어나 폼 입력처럼 보이면(문장이 아니라 키워드 나열 등), 같은 종류의 텍스트로 짧게 이어 쓰세요.
- 사용자가 언어와 같은 언어로 쓰세요.
- 사용자의 말투와 문체를 유지하세요. 존댓말/반말, 격식/비격식을 바꾸지 마세요.
- 이미 나온 단어를 되풀이하지 말고 이어지는 내용만 쓰세요.
@ -186,6 +189,11 @@ export interface SuggestionPromptInput {
* 자주 쓰는 표현보다 훨씬 강한 문맥 신호다 — 사용자 자신의 실제 이어쓰기다.
*/
continuationHints?: readonly string[]
/**
* 이미 이 세션에서 제안한 문장들 — 채우기 루프가 중복을 피하려고 넘긴다.
* 지시문이 아니라 데이터 섹션으로만 들어간다(기존 continuationHints 와 같은 방식).
*/
avoidCandidates?: readonly string[]
/** 후보 개수 */
candidates?: number
/** 후보당 최대 길이 */
@ -227,6 +235,13 @@ export function buildSuggestionPrompt(input: SuggestionPromptInput): {
sections.push('위 내용은 문체와 맥락 참고용입니다. 그대로 복사하지 말고 이어질 문장을 새로 쓰세요.')
}
if (input.avoidCandidates && input.avoidCandidates.length > 0) {
sections.push('', '[이미 제안한 문장 — 아래와 겹치지 않는 다른 문장을 쓰세요]')
for (const avoid of input.avoidCandidates.slice(0, 12)) {
sections.push(`- ${avoid}`)
}
}
sections.push('', `이어질 다음 문장 ${candidates}개를 한 줄씩 출력하세요.`)
const systemPrompt = `${SUGGESTION_NO_THINK_PREFIX}\n${SUGGESTION_SYSTEM_PROMPT.replace(

View file

@ -0,0 +1,38 @@
// src/main/suggestion-overlay-policy.ts
//
// 제안 오버레이 표시 전략 (순수 함수) — bootstrap.ts 의 suggestion.on('updated', ...)
// 배선에서 쓴다. Electron/서비스 의존이 없는 별도 파일로 뺀 이유: bootstrap.ts 는
// 최상위에서 electron/서비스 모듈을 다수 import 하므로 그대로는 단위 테스트하기
// 어렵다 — 이 판단 로직만 독립적으로 테스트한다.
/**
* 제안 오버레이를 이번 'updated' 이벤트에 어떻게 반영할지 결정한다.
*
* 스트리밍 중에는 ~120ms 마다 'updated' 가 온다. 매번 show(=setBounds+present)를
* 부르면 X 클릭이 재present 에 가로채여 먹히지 않고 패널이 튀었다(실측). 오버레이가
* 이미 떠 있으면 내용만 갱신하고, 새로 띄우거나 숨길 때만 위치를 다시 계산한다.
*/
export function decideSuggestionOverlayAction(
overlayVisible: boolean,
shouldPresent: boolean
): 'show' | 'update' | 'hide' {
if (!shouldPresent) return 'hide'
return overlayVisible ? 'update' : 'show'
}
/**
* 평범한 Esc(수정자 없음) keydown 을 제안 닫기로 연결할지 결정한다.
*
* Escape 는 단독 바인딩이 불가능해(KEY_CATALOG 의 requiresModifier) 등록된 키바인딩
* 경로로는 절대 들어오지 않는다 — bootstrap 이 원본 keydown 을 직접 듣고 이 함수로
* 판단한다. 오버레이가 아무것도 보여주지 않을 때는 반응하지 않는다 — 그때 Esc 를
* 다른 용도로 쓰는 사용자 조작(예: 다른 앱의 대화상자 닫기)을 가로채지 않기 위해서다.
* 수정자가 하나라도 눌려 있으면(Ctrl+Esc 등) 평범한 Esc 가 아니므로 반응하지 않는다.
*/
export function shouldDismissOnEscape(
isPresentationActive: boolean,
modifiers: { ctrl: boolean; alt: boolean; shift: boolean; meta: boolean }
): boolean {
if (!isPresentationActive) return false
return !modifiers.ctrl && !modifiers.alt && !modifiers.shift && !modifiers.meta
}

View file

@ -99,12 +99,6 @@ function provisionedBinary(component: 'sidecar' | 'ffmpeg'): string {
return path.join(getProvisionedRuntimeDir(), component, name)
}
/** 내려받은 사이드카 실행 파일 경로 (없으면 null) */
export function getProvisionedSidecarPath(): string | null {
const candidate = provisionedBinary('sidecar')
return existsSync(candidate) ? candidate : null
}
/** 내려받은 ffmpeg 실행 파일 경로 (없으면 null) */
export function getProvisionedFfmpegPath(): string | null {
const candidate = provisionedBinary('ffmpeg')
@ -170,6 +164,13 @@ export interface SidecarLaunch {
* STT sidecar 실행 경로.
* - packaged: resources/sidecar/sidecar(.exe) — 없으면 명확한 에러 (조용한 폴백 금지)
* - dev: sidecar/.venv python + sidecar/main.py (없으면 시스템 python 폴백)
*
* 설치본에는 엔진을 넣지 않는다(업데이트 게시 크기 한도) — 필요할 때 RuntimeProvisioner가
* 내려받는다. 이미 내려받은 사본이 있는지/최신 버전인지는 여기서 판단하지 않는다:
* 여기서 판단해 버리면 앱이 업데이트로 새 엔드포인트를 요구해도(예: v1.5.0의 `/uia/focus`)
* 예전 사본을 그대로 써 버려 조용히 낡은 채로 남는다. 그래서 packaged인데 번들이 없으면
* 항상 에러를 던져 호출측(LocalSTTService)이 RuntimeProvisioner.ensure()로 넘어가게 한다 —
* 설치/버전 확인은 그 한 곳(RuntimeProvisioner)에서만 한다.
*/
export function getSidecarCommand(): SidecarLaunch {
const sidecarBin = `sidecar${EXE_SUFFIX}`
@ -180,12 +181,6 @@ export function getSidecarCommand(): SidecarLaunch {
return { command: exePath, args: [], source: 'bundled' }
}
// 설치본에는 엔진을 넣지 않는다(업데이트 게시 크기 한도). 필요할 때 내려받은 경로를 쓴다.
const provisioned = getProvisionedSidecarPath()
if (provisioned) {
return { command: provisioned, args: [], source: 'provisioned' }
}
throw new D3ROError(
ErrorCode.STTEngineNotInstalled,
'로컬 음성 엔진이 아직 설치되지 않았습니다. 설정 > STT에서 "엔진 다운로드"를 실행하세요.',
@ -203,16 +198,19 @@ export function getSidecarCommand(): SidecarLaunch {
)
}
// Windows에서는 반드시 pythonw(콘솔 없는 GUI 서브시스템)를 쓴다. venv의 python.exe는
// 런처라서 실제 인터프리터를 손자 프로세스로 다시 띄우는데, 그 손자에는 spawn의
// windowsHide가 전달되지 않아 콘솔 창이 뜬다(실측: 타이핑 중 cmd 창이 떠 포커스를 뺏음).
const venvPython =
process.platform === 'win32'
? path.join(sidecarDir, '.venv', 'Scripts', 'python.exe')
? path.join(sidecarDir, '.venv', 'Scripts', 'pythonw.exe')
: path.join(sidecarDir, '.venv', 'bin', 'python3')
if (existsSync(venvPython)) {
return { command: venvPython, args: [sidecarPath], source: 'venv' }
}
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
const pythonCmd = process.platform === 'win32' ? 'pythonw' : 'python3'
return { command: pythonCmd, args: [sidecarPath], source: 'python' }
}
/** STT 사이드카 HTTP 기본 URL. sidecar는 IPv4 프백에만 바인딩한다. */

View file

@ -5,11 +5,18 @@ import { BrowserWindow, shell, screen, ipcMain, Menu, clipboard } from 'electron
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@d3ro/core/constants'
import { anchorFloatingPanel } from '@d3ro/core/input-intelligence'
import { anchorFloatingPanel, type AnchorKind } from '@d3ro/core/input-intelligence'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import {
formatBindingSegments,
joinBindingSegments,
type BindingPlatform,
type KeyBinding,
type KeyBindingActionId
} from '@d3ro/core/keybinding'
import { getLogger } from '../services/LoggerService'
import { getIsQuitting } from '../lifecycle'
import { configGet } from '../services/ConfigService'
import { configGet, configSet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n'
@ -60,6 +67,8 @@ function getPopupI18nStrings(): Record<string, string> {
errorDefault: t('popup.error.default'),
// caption-overlay
captionLoading: t('popup.caption.loading'),
captionWaiting: t('popup.caption.waiting'),
captionDragHint: t('popup.caption.dragHint'),
// suggestion-overlay
suggestionHintAccept: t('popup.suggestion.hintAccept'),
suggestionHintNext: t('popup.suggestion.hintNext'),
@ -73,6 +82,86 @@ function getPopupI18nStrings(): Record<string, string> {
suggestionRelated: t('popup.suggestion.related'),
suggestionPhrases: t('popup.suggestion.phrases'),
suggestionAppPhrases: t('popup.suggestion.appPhrases'),
// 키 힌트 줄 (동작 낱말 — 실제 키는 사용자 바인딩에서 매번 계산해 붙인다)
suggestionHintMoveLabel: t('popup.suggestion.hintMove'),
suggestionHintPageLabel: t('popup.suggestion.hintPage'),
suggestionHintAcceptLabel: t('popup.suggestion.hintAccept'),
suggestionHintCloseLabel: t('popup.suggestion.hintDismiss'),
suggestionHintGeneratingMore: t('popup.suggestion.hintGeneratingMore'),
}
}
/** win32/darwin 외 플랫폼은 linux 로 간주한다(키캡 표기 정본 — BindingPlatform). */
function resolveBindingPlatform(): BindingPlatform {
if (process.platform === 'darwin') return 'darwin'
if (process.platform === 'win32') return 'win32'
return 'linux'
}
interface SplitBinding {
modifiers: string[]
key: string
}
function splitBinding(binding: KeyBinding, platform: BindingPlatform): SplitBinding {
const labels = formatBindingSegments(binding, platform).map((segment) => segment.label)
return { modifiers: labels.slice(0, -1), key: labels[labels.length - 1] ?? '' }
}
function sameModifiers(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((mod, index) => mod === b[index])
}
export interface SuggestionKeyHints {
/** 이동·페이지·수락이 모두 같은 수정자를 쓰면 그 수정자(예: "Ctrl+Alt") — 한 번만 보여준다 */
shared: string | null
move: string | null
page: string | null
accept: string | null
close: string
}
/**
* 제안 오버레이 키 힌트 — 사용자가 실제로 설정한 바인딩에서 매번 다시 계산한다.
*
* 라벨은 키바인딩 SSOT(formatBindingSegments/joinBindingSegments) 로만 만든다.
* 수정자가 모두 같으면 앞에 한 번만 두고 각 힌트는 키만 보여준다 — 같은 "Ctrl+Alt+" 를
* 네 번 반복하면 한 줄에 들어가지 않고 읽기도 어렵다. 바인딩 없는 액션은 생략한다.
* 닫기는 오버레이 전용 Esc 로 고정한다(보조 바인딩은 설정 화면에서 보인다).
*/
function buildSuggestionKeyHints(): SuggestionKeyHints {
const bindings = configGet('keyBindings')
const platform = resolveBindingPlatform()
const splitOf = (actionId: KeyBindingActionId): SplitBinding | null => {
const binding = bindings[actionId]?.[0]
return binding ? splitBinding(binding, platform) : null
}
const prev = splitOf('suggestion-prev')
const next = splitOf('suggestion-next')
const pagePrev = splitOf('suggestion-page-prev')
const pageNext = splitOf('suggestion-page-next')
const accept = splitOf('suggestion-accept')
const present = [prev, next, pagePrev, pageNext, accept].filter((b): b is SplitBinding => b !== null)
const common = present[0]?.modifiers ?? []
const shareAll =
present.length > 0 && common.length > 0 && present.every((b) => sameModifiers(b.modifiers, common))
const label = (b: SplitBinding): string =>
shareAll ? b.key : joinBindingSegments([...b.modifiers, b.key], platform)
const pair = (a: SplitBinding | null, b: SplitBinding | null): string | null => {
if (!a && !b) return null
if (a && b) return shareAll ? `${a.key}${b.key}` : `${label(a)} / ${label(b)}`
return label((a ?? b) as SplitBinding)
}
return {
shared: shareAll ? joinBindingSegments(common, platform) : null,
move: pair(prev, next),
page: pair(pagePrev, pageNext),
accept: accept ? label(accept) : null,
close: 'Esc'
}
}
@ -651,17 +740,38 @@ export function isCommandPopupVisible(): boolean {
// ── CaptionOverlay 팝업 (Phase 10.1) ─────────────────
const CAPTION_OVERLAY_HEIGHT = 140
/** 기본 위치: 주 모니터 작업 영역 아래 가운데. */
function defaultCaptionOverlayBounds(): Electron.Rectangle {
const { workArea } = screen.getPrimaryDisplay()
const width = Math.round(workArea.width * 0.8)
return {
x: workArea.x + Math.round((workArea.width - width) / 2),
y: workArea.y + workArea.height - CAPTION_OVERLAY_HEIGHT - 40,
width,
height: CAPTION_OVERLAY_HEIGHT
}
}
/** 저장된 위치가 지금 연결된 어느 모니터 안에 들어오면 쓴다 (모니터를 뺐으면 기본 위치). */
function initialCaptionOverlayBounds(): Electron.Rectangle {
const fallback = defaultCaptionOverlayBounds()
const saved = configGet('captionOverlayPosition')
if (!saved) return fallback
const handle = { x: saved.x + Math.round(fallback.width / 2), y: saved.y + 20 }
const onScreen = screen.getAllDisplays().some(({ workArea }) =>
handle.x >= workArea.x && handle.x < workArea.x + workArea.width &&
handle.y >= workArea.y && handle.y < workArea.y + workArea.height
)
return onScreen ? { ...fallback, x: saved.x, y: saved.y } : fallback
}
function createCaptionOverlayWindow(): BrowserWindow {
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
const overlayWidth = Math.round(screenWidth * 0.8)
const overlayHeight = 120
const bounds = initialCaptionOverlayBounds()
const win = new BrowserWindow({
width: overlayWidth,
height: overlayHeight,
x: Math.round((screenWidth - overlayWidth) / 2),
y: screenHeight - overlayHeight - 40,
...bounds,
show: false,
frame: false,
transparent: true,
@ -709,18 +819,77 @@ export function showCaptionOverlay(): void {
}
export function hideCaptionOverlay(): void {
endCaptionOverlayDrag()
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
sendToPopupWindow(captionOverlayWindow, IPC_CHANNELS.POPUP_CAPTION.HIDE, {})
captionOverlayWindow.setIgnoreMouseEvents(true, { forward: true })
captionOverlayWindow.hide()
}
}
export function sendToCaptionOverlay(channel: string, data: unknown): void {
if (captionOverlayWindow && !captionOverlayWindow.isDestroyed()) {
sendToPopupWindow(captionOverlayWindow, channel, data)
// 상태 이벤트에 문구를 실어 보낸다 — 빠지면 오버레이가 영어 기본 문구로 떨어진다.
const payload =
channel === IPC_CHANNELS.CAPTION.STATE_CHANGED && data !== null && typeof data === 'object'
? { ...data, _i18n: getPopupI18nStrings() }
: data
sendToPopupWindow(captionOverlayWindow, channel, payload)
}
}
// ── 자막 창 끌어서 옮기기 ──
// 창은 기본적으로 클릭 통과다. 렌더러의 손잡이 위에 있는 동안만 마우스를 받고,
// 끄는 동안은 커서를 따라 창을 옮긴다(포커스를 받지 않는 창이라 OS 제목줄 끌기에 기대지 않는다).
let captionDragTimer: NodeJS.Timeout | null = null
let captionDragOffset: { x: number; y: number } | null = null
/** 버튼 뗌 신호가 유실돼도 창이 커서에 영원히 붙어 있지 않게 한다 */
const CAPTION_DRAG_MAX_MS = 30_000
export function setCaptionOverlayInteractive(interactive: boolean): void {
if (!captionOverlayWindow || captionOverlayWindow.isDestroyed()) return
if (!interactive && captionDragTimer) return
if (interactive) captionOverlayWindow.setIgnoreMouseEvents(false)
else captionOverlayWindow.setIgnoreMouseEvents(true, { forward: true })
}
export function startCaptionOverlayDrag(): void {
const win = captionOverlayWindow
if (!win || win.isDestroyed() || captionDragTimer) return
const cursor = screen.getCursorScreenPoint()
const [x, y] = win.getPosition()
captionDragOffset = { x: cursor.x - x, y: cursor.y - y }
const startedAt = Date.now()
captionDragTimer = setInterval(() => {
if (win.isDestroyed() || !captionDragOffset || Date.now() - startedAt > CAPTION_DRAG_MAX_MS) {
endCaptionOverlayDrag()
return
}
const point = screen.getCursorScreenPoint()
win.setPosition(point.x - captionDragOffset.x, point.y - captionDragOffset.y)
}, 16)
}
export function endCaptionOverlayDrag(): void {
if (!captionDragTimer) return
clearInterval(captionDragTimer)
captionDragTimer = null
captionDragOffset = null
const win = captionOverlayWindow
if (!win || win.isDestroyed()) return
const [x, y] = win.getPosition()
configSet('captionOverlayPosition', { x, y })
win.setIgnoreMouseEvents(true, { forward: true })
}
export function resetCaptionOverlayPosition(): void {
endCaptionOverlayDrag()
configSet('captionOverlayPosition', null)
const win = captionOverlayWindow
if (win && !win.isDestroyed()) win.setBounds(defaultCaptionOverlayBounds())
}
// ── SuggestionOverlay 업 (입력 인텔리전스) ──────────
/**
@ -731,8 +900,11 @@ export function sendToCaptionOverlay(channel: string, data: unknown): void {
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략)
*/
const SUGGESTION_OVERLAY_WIDTH = 460
/** 후보 5개 + 스크롤을 담을 높이 (목록은 내부 스크롤) */
const SUGGESTION_OVERLAY_HEIGHT = 258
/**
* 한 페이지(3개 × 고정 44px) + 진행률 줄 + 키 안내 줄이 스크롤 없이 들어가는 높이.
* style.css 고정 치수로 렌더링해 잰 값(패널 하단 204 + root 패딩 4) — CSS 를 바꾸면 다시 잰다.
*/
const SUGGESTION_OVERLAY_HEIGHT = 208
function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
const interactive = configGet('suggestionOverlayInteractive') !== false
@ -795,26 +967,36 @@ export function getSuggestionOverlayWindow(): BrowserWindow {
return suggestionOverlayWindow
}
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
export function showSuggestionOverlay(payload: {
interface SuggestionOverlayProvenance {
mode: 'local-model' | 'local-memory'
continuationCount: number
relatedCount: number
phraseCount: number
appPhraseCount: number
}
interface SuggestionOverlayContent {
candidates: Array<{ text: string; rank: number }>
activeIndex: number
/** 후보 도착 전(생성 중) — 팝업이 로딩 행을 보여준다 */
/** 이 세션이 채우려는 후보 총량 — 페이지/진행률 표시의 분모 */
targetTotal?: number
/** 후보 도착 전(생성 중) 이거나, 후보가 있어도 채우기 루프가 더 만드는 중 */
generating?: boolean
/** 모델 적재 중 — 팝업이 "준비 중" 을 보여준다 */
warmingUp?: boolean
/** 스트리밍 중 부분 텍스트 */
partialText?: string | null
provenance?: {
mode: 'local-model' | 'local-memory'
continuationCount: number
relatedCount: number
phraseCount: number
appPhraseCount: number
} | null
anchor: { x: number; y: number; width: number; height: number } | null
appName: string | null
}): void {
provenance?: SuggestionOverlayProvenance | null
}
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
export function showSuggestionOverlay(
payload: SuggestionOverlayContent & {
anchor: { x: number; y: number; width: number; height: number } | null
anchorKind?: AnchorKind
appName: string | null
}
): void {
const win = getSuggestionOverlayWindow()
const cursor = screen.getCursorScreenPoint()
const anchorPoint = payload.anchor
@ -824,6 +1006,7 @@ export function showSuggestionOverlay(payload: {
const position = anchorFloatingPanel(
payload.anchor,
payload.anchorKind ?? null,
cursor,
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
display.workArea
@ -839,33 +1022,26 @@ export function showSuggestionOverlay(payload: {
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
candidates: payload.candidates,
activeIndex: payload.activeIndex,
targetTotal: payload.targetTotal ?? payload.candidates.length,
generating: payload.generating === true,
warmingUp: payload.warmingUp === true,
partialText: payload.partialText ?? null,
appName: payload.appName,
provenance: payload.provenance ?? null,
_i18n: getPopupI18nStrings()
_i18n: getPopupI18nStrings(),
_keyHints: buildSuggestionKeyHints()
})
presentPopup(win, 'screen-saver')
}
export function updateSuggestionOverlay(payload: {
candidates: Array<{ text: string; rank: number }>
activeIndex: number
generating?: boolean
warmingUp?: boolean
partialText?: string | null
provenance?: {
mode: 'local-model' | 'local-memory'
continuationCount: number
relatedCount: number
phraseCount: number
appPhraseCount: number
} | null
}): void {
export function updateSuggestionOverlay(payload: SuggestionOverlayContent): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, payload)
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, {
...payload,
targetTotal: payload.targetTotal ?? payload.candidates.length,
_keyHints: buildSuggestionKeyHints()
})
}
}

View file

@ -8,6 +8,10 @@
</head>
<body>
<div id="root">
<div id="handle" class="drag-handle">
<span class="grip" aria-hidden="true"></span>
<span id="handleHint" class="handle-hint"></span>
</div>
<div id="container" class="caption-overlay">
<div id="lines"></div>
</div>

View file

@ -33,6 +33,8 @@
* @param {{id: string, text: string, timestamp: number, isFinal: boolean}} segment
*/
function addSegment(segment) {
// 첫 자막이 오면 준비 안내를 걷는다 (그 전까지는 계속 보여 빈 화면을 만들지 않는다).
removeStatusLine()
// delta 줄이 있으면 제거 (확정 줄로 교체)
removeDeltaLine()
@ -172,26 +174,92 @@
})
// 상태 변경
// starting: 음성 모델을 준비하는 중
// active : 듣고 있지만 첫 자막은 아직 — 소리를 모아 첫 인식을 마칠 때까지 몇 초 걸린다.
// active 가 되자마자 안내를 지우면 그 몇 초가 빈 화면이라 "고장" 처럼 보였다.
window.popupAPI.on('caption:stateChanged', function (data) {
if (data._i18n) i18nStrings = data._i18n
if (data._i18n) {
i18nStrings = data._i18n
if (handleHint) handleHint.textContent = i18nStrings.captionDragHint || ''
}
if (data.state === 'starting') {
// 로딩 표시
clearAllLines()
var loadingEl = document.createElement('div')
loadingEl.className = 'caption-line loading'
loadingEl.id = 'caption-loading'
loadingEl.textContent = i18nStrings.captionLoading || '⏳ Loading STT model...'
loadingEl.style.fontSize = config.fontSize + 'px'
linesContainer.appendChild(loadingEl)
showStatusLine(i18nStrings.captionLoading)
} else if (data.state === 'active') {
// 로딩 표시 제거
var existing = document.getElementById('caption-loading')
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing)
}
if (lines.length === 0) showStatusLine(i18nStrings.captionWaiting)
} else if (data.state === 'inactive' || data.state === 'stopping') {
clearAllLines()
}
})
}
// ── 준비 안내 줄 ──────────────────────────────────────
function showStatusLine(text) {
if (!text) return
var el = document.getElementById('caption-loading')
if (!el) {
el = document.createElement('div')
el.className = 'caption-line loading'
el.id = 'caption-loading'
linesContainer.appendChild(el)
}
el.style.fontSize = config.fontSize + 'px'
el.textContent = text
}
function removeStatusLine() {
var el = document.getElementById('caption-loading')
if (el && el.parentNode) el.parentNode.removeChild(el)
}
// ── 끌어서 옮기기 ──────────────────────────────────────
// 창은 클릭 통과라 평소엔 아래 앱을 가리지 않는다. 마우스 이동은 전달받으므로
// 올라오면 손잡이를 보여 주고, 손잡이 위에서만 마우스를 받는다.
var root = document.getElementById('root')
var handle = document.getElementById('handle')
var handleHint = document.getElementById('handleHint')
var hoverTimer = null
var dragging = false
function send(channel, value) {
if (window.popupAPI) window.popupAPI.send(channel, value)
}
document.addEventListener('mousemove', function () {
if (!root) return
root.classList.add('hovering')
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(function () {
if (!dragging) root.classList.remove('hovering')
}, 1500)
})
if (handle) {
handle.addEventListener('mouseenter', function () {
send('captionPopup:setInteractive', true)
})
handle.addEventListener('mouseleave', function () {
if (!dragging) send('captionPopup:setInteractive', false)
})
handle.addEventListener('pointerdown', function (event) {
if (event.button !== 0) return
dragging = true
handle.setPointerCapture(event.pointerId)
if (root) root.classList.add('dragging')
send('captionPopup:dragStart')
})
var finishDrag = function () {
if (!dragging) return
dragging = false
if (root) root.classList.remove('dragging')
send('captionPopup:dragEnd')
}
handle.addEventListener('pointerup', finishDrag)
handle.addEventListener('lostpointercapture', finishDrag)
handle.addEventListener('dblclick', function () {
send('captionPopup:resetPosition')
})
}
})()

View file

@ -26,6 +26,7 @@ html, body {
}
#root {
position: relative;
width: 100%;
height: 100%;
display: flex;
@ -33,6 +34,48 @@ html, body {
justify-content: center;
}
/* ── 끌어서 옮기는 손잡이 — 마우스가 자막 창 위에 있을 때만 드러난다 ── */
.drag-handle {
position: absolute;
top: 6px;
left: 50%;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
height: 22px;
padding: 0 10px;
border-radius: 999px;
background: rgba(15, 17, 22, 0.88);
border: 1px solid var(--d3-accent-glow-dim);
color: rgba(244, 244, 245, 0.85);
font-size: 11px;
white-space: nowrap;
cursor: grab;
opacity: 0;
transform: translate(-50%, -2px);
transition: opacity 120ms ease-out, transform 120ms ease-out;
}
#root.hovering .drag-handle,
#root.dragging .drag-handle {
opacity: 1;
transform: translate(-50%, 0);
}
#root.dragging .drag-handle {
cursor: grabbing;
border-color: var(--d3-accent-main);
}
.grip {
width: 14px;
height: 8px;
background-image: radial-gradient(circle, currentColor 1px, transparent 1.5px);
background-size: 4.5px 4px;
opacity: 0.8;
}
.caption-overlay {
width: 100%;
padding: 12px 24px;

View file

@ -9,20 +9,23 @@
<body>
<div id="root">
<div id="panel" class="suggestion-panel">
<div class="header">
<div id="status" class="status" hidden>
<span id="spinner" class="spinner" aria-hidden="true"></span>
<span id="statusText" class="status-text"></span>
</div>
<button id="close" class="close" type="button" title="Close">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button id="close" class="close" type="button">
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true">
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="status" class="status" hidden>
<span class="spinner" aria-hidden="true"></span>
<span id="statusText" class="status-text"></span>
</div>
<div id="candidates" class="candidates"></div>
<div id="provenance" class="provenance" aria-live="polite"></div>
<div id="hints" class="hints"></div>
<div id="footer" class="footer" hidden>
<div class="footer-row">
<span id="progress" class="progress" aria-live="polite"></span>
<span id="provenance" class="provenance"></span>
</div>
<div id="keyHints" class="key-hints"></div>
</div>
</div>
</div>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->

View file

@ -3,18 +3,23 @@
//
// 상태 세 가지를 화면에 드러낸다:
// warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중")
// generating - 토큰이 만들어지는 중 (스피너 + 도착한 부분 텍스트)
// 후보 도착 - 최대 5개, 목록은 스크롤 가능
// generating - 아직 후보가 하나도 없는 첫 생성 중 (스피너 + 도착한 부분 텍스트)
// 후보 도착 - 한 페이지(3개)만 보여준다. 뒤로 더 채워지는 중이면(최대 12개)
// 헤더 스피너 대신 진행률 줄에 작은 표시만 한다(중복 스피너 방지).
//
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/닫기)은 전역 키바인딩이
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/페이지/닫기)은 전역 키바인딩이
// 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다.
;(function () {
'use strict'
var PAGE_SIZE = 3
var candidatesContainer = document.getElementById('candidates')
var provenanceContainer = document.getElementById('provenance')
var hintsContainer = document.getElementById('hints')
var progressContainer = document.getElementById('progress')
var keyHintsContainer = document.getElementById('keyHints')
var footer = document.getElementById('footer')
var statusRow = document.getElementById('status')
var statusText = document.getElementById('statusText')
var panel = document.getElementById('panel')
@ -23,23 +28,27 @@
/** @type {string[]} */
var candidates = []
var activeIndex = 0
var targetTotal = 0
var generating = false
var warmingUp = false
var partialText = null
var provenance = null
var i18nStrings = {}
var keyHints = null
var generatingSince = 0
var tickTimer = null
function render() {
candidatesContainer.textContent = ''
var busy = warmingUp || generating
// 후보가 이미 있으면(채우기 루프가 배경에서 더 만드는 중이어도) 헤더 스피너는
// 끈다 — 진행률 줄의 작은 표시가 그 역할을 대신한다(중복 표시 방지).
var busy = warmingUp || (generating && candidates.length === 0)
if (statusRow) statusRow.hidden = !busy
if (statusText) {
if (warmingUp) {
statusText.textContent = i18nStrings.suggestionWarming || '...'
} else if (generating) {
} else if (busy) {
// 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다.
var seconds = generatingSince
? Math.max(1, Math.round((Date.now() - generatingSince) / 1000))
@ -51,70 +60,124 @@
}
}
if (footer) footer.hidden = candidates.length === 0
if (candidates.length === 0) {
// 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌).
if (partialText) {
var streaming = document.createElement('div')
streaming.className = 'suggestion-item streaming'
streaming.textContent = partialText
candidatesContainer.appendChild(streaming)
candidatesContainer.appendChild(buildItem('div', 'suggestion-item streaming', '1', partialText))
}
renderHints()
renderProvenance()
return
}
for (var i = 0; i < candidates.length; i++) {
var item = document.createElement('button')
var page = Math.floor(activeIndex / PAGE_SIZE)
var pageStart = page * PAGE_SIZE
var pageEnd = Math.min(pageStart + PAGE_SIZE, candidates.length)
for (var i = pageStart; i < pageEnd; i++) {
var item = buildItem(
'button',
i === activeIndex ? 'suggestion-item active' : 'suggestion-item',
String(i + 1),
candidates[i]
)
item.type = 'button'
item.className = i === activeIndex ? 'suggestion-item active' : 'suggestion-item'
item.setAttribute('data-index', String(i))
item.textContent = candidates[i]
item.addEventListener('click', onItemClick)
candidatesContainer.appendChild(item)
}
renderHints()
renderProgress()
renderKeyHints()
renderProvenance()
}
function buildItem(tag, className, number, text) {
var item = document.createElement(tag)
item.className = className
var num = document.createElement('span')
num.className = 'suggestion-num'
num.textContent = number
var body = document.createElement('span')
body.className = 'suggestion-text'
body.textContent = text
item.appendChild(num)
item.appendChild(body)
return item
}
function renderProgress() {
if (!progressContainer) return
progressContainer.textContent = ''
var page = Math.floor(activeIndex / PAGE_SIZE)
var from = page * PAGE_SIZE + 1
var to = Math.min((page + 1) * PAGE_SIZE, candidates.length)
var range = document.createElement('span')
range.textContent = from + '–' + to + ' / ' + candidates.length
progressContainer.appendChild(range)
if (generating) {
var template = i18nStrings.suggestionHintGeneratingMore || i18nStrings.suggestionHintGenerating || ''
var more = document.createElement('span')
more.className = 'progress-more'
var dot = document.createElement('span')
dot.className = 'spinner'
dot.setAttribute('aria-hidden', 'true')
var label = document.createElement('span')
label.textContent = template.replace('{{max}}', String(targetTotal || candidates.length))
more.appendChild(dot)
more.appendChild(label)
progressContainer.appendChild(more)
}
}
function kbd(text) {
var el = document.createElement('kbd')
el.textContent = text
return el
}
function renderKeyHints() {
if (!keyHintsContainer) return
keyHintsContainer.textContent = ''
if (!keyHints) return
// 이동·페이지·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +").
if (keyHints.shared) {
var shared = document.createElement('span')
shared.className = 'key-hint shared'
shared.appendChild(kbd(keyHints.shared))
shared.appendChild(document.createTextNode('+'))
keyHintsContainer.appendChild(shared)
}
var entries = [
[keyHints.move, i18nStrings.suggestionHintMoveLabel, ''],
[keyHints.page, i18nStrings.suggestionHintPageLabel, ''],
[keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''],
[keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint']
]
for (var i = 0; i < entries.length; i++) {
// 바인딩이 없는 액션은 힌트 자체를 생략한다.
if (!entries[i][0]) continue
var hint = document.createElement('span')
hint.className = 'key-hint' + entries[i][2]
hint.appendChild(kbd(entries[i][0]))
if (entries[i][1]) hint.appendChild(document.createTextNode(entries[i][1]))
keyHintsContainer.appendChild(hint)
}
}
function renderProvenance() {
if (!provenanceContainer) return
provenanceContainer.textContent = ''
if (!provenance) return
var parts = []
// 출처는 종류만 보여준다 — 근거 개수는 설정의 제안 기록에서 본다.
var sourceLabel = provenance.mode === 'local-memory'
? i18nStrings.suggestionSourceMemory
: i18nStrings.suggestionSourceModel
if (sourceLabel) parts.push(sourceLabel)
var counts = [
['continuationCount', 'suggestionContinuations'],
['relatedCount', 'suggestionRelated'],
['phraseCount', 'suggestionPhrases'],
['appPhraseCount', 'suggestionAppPhrases']
]
for (var i = 0; i < counts.length; i++) {
var count = provenance[counts[i][0]] || 0
var countLabel = i18nStrings[counts[i][1]]
if (count > 0 && countLabel) parts.push(countLabel + ' ' + count)
}
provenanceContainer.textContent = parts.join(' · ')
}
function renderHints() {
hintsContainer.textContent = ''
if (candidates.length <= 1 && !generating) return
var hint = document.createElement('span')
hint.className = 'hint'
if (candidates.length > 0) {
var nextLabel = i18nStrings.suggestionHintNext || 'Next'
hint.textContent = nextLabel + ' ' + (activeIndex + 1) + '/' + candidates.length
} else {
hint.textContent = i18nStrings.suggestionHintGenerating || '…'
}
hintsContainer.appendChild(hint)
provenanceContainer.textContent = sourceLabel || ''
}
function onItemClick(event) {
@ -127,10 +190,12 @@
function applyPayload(payload) {
if (!payload) return
if (payload._i18n) i18nStrings = payload._i18n
if (payload._keyHints) keyHints = payload._keyHints
candidates = (payload.candidates || []).map(function (candidate) {
return candidate && candidate.text ? candidate.text : String(candidate)
})
activeIndex = payload.activeIndex || 0
if (payload.targetTotal !== undefined) targetTotal = payload.targetTotal || 0
if (payload.generating !== undefined) generating = payload.generating === true
if (payload.warmingUp !== undefined) warmingUp = payload.warmingUp === true
if (payload.partialText !== undefined) partialText = payload.partialText || null
@ -152,16 +217,17 @@
function handleShow(payload) {
applyPayload(payload)
if (payload && payload.generating) generatingSince = Date.now()
if (payload && payload.generating && candidates.length === 0) generatingSince = Date.now()
startTick()
if (panel) panel.classList.add('visible')
render()
}
function handleUpdate(payload) {
var wasGenerating = generating
var wasBusy = generating && candidates.length === 0
applyPayload(payload)
if (generating && !wasGenerating) generatingSince = Date.now()
var isBusy = generating && candidates.length === 0
if (isBusy && !wasBusy) generatingSince = Date.now()
startTick()
render()
}
@ -179,9 +245,13 @@
provenance = null
candidates = []
activeIndex = 0
targetTotal = 0
keyHints = null
candidatesContainer.textContent = ''
if (footer) footer.hidden = true
if (provenanceContainer) provenanceContainer.textContent = ''
hintsContainer.textContent = ''
if (progressContainer) progressContainer.textContent = ''
if (keyHintsContainer) keyHintsContainer.textContent = ''
}
if (closeButton) {

View file

@ -2,6 +2,9 @@
*
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*
* 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내]
* 창 높이(WindowManager SUGGESTION_OVERLAY_HEIGHT)는 이 고정 치수에서 나온다.
*/
:root {
@ -20,13 +23,18 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
background: transparent;
overflow: hidden;
user-select: none;
-webkit-app-region: no-drag;
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
#root {
@ -36,10 +44,10 @@ body {
}
.suggestion-panel {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
padding: 6px;
border-radius: 10px;
background: var(--d3-bg-result);
border: 1px solid var(--d3-border-result);
@ -54,41 +62,39 @@ body {
transform: translateY(0);
}
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
.header {
display: flex;
align-items: center;
gap: 8px;
min-height: 18px;
}
/* ── 닫기 — 구석에 떠 있는 작은 버튼 (빈 헤더 줄을 두지 않는다) ── */
.close {
margin-left: auto;
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d3-text-secondary);
cursor: pointer;
transition: background 90ms ease-out, color 90ms ease-out;
opacity: 0.7;
transition: background 90ms ease-out, color 90ms ease-out, opacity 90ms ease-out;
}
.close:hover {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
opacity: 1;
}
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
/* ── 상태 (후보가 아직 없을 때만: 준비 중 / 생성 중) ── */
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
min-height: 32px;
padding: 0 28px 0 8px;
}
.spinner {
@ -110,99 +116,172 @@ body {
.status-text {
color: var(--d3-text-secondary);
font-size: 12px;
font-style: italic;
}
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
/* ── 후보 (한 페이지 = 3개, 각 최대 2줄, 고정 높이라 채워져도 창이 흔들리지 않는다) ── */
.candidates {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 176px;
overflow-y: auto;
overscroll-behavior: contain;
}
.candidates::-webkit-scrollbar {
width: 8px;
}
.candidates::-webkit-scrollbar-thumb {
background: var(--d3-accent-dim);
border-radius: 4px;
}
.candidates::-webkit-scrollbar-track {
background: transparent;
.candidates:empty {
display: none;
}
.suggestion-item {
display: block;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
height: 44px;
padding: 0 28px 0 8px;
border: 0;
border-radius: 6px;
border-radius: 7px;
background: transparent;
color: var(--d3-text-result);
font: inherit;
font-size: 14px;
line-height: 1.45;
text-align: left;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background 90ms ease-out;
}
.suggestion-item:hover {
background: var(--d3-accent-dim);
}
.suggestion-item.active {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
box-shadow: inset 2px 0 0 var(--d3-accent-main);
}
.suggestion-item.loading {
.suggestion-num {
flex: 0 0 16px;
color: var(--d3-text-secondary);
font-style: italic;
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.active .suggestion-num {
color: var(--d3-accent-main);
font-weight: 600;
}
.suggestion-text {
display: -webkit-box;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 400;
line-height: 1.4;
word-break: keep-all;
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.suggestion-item.active .suggestion-text {
font-weight: 500;
}
/* 첫 후보가 스트리밍으로 도착하는 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.streaming {
color: var(--d3-text-result);
cursor: default;
opacity: 0.85;
white-space: normal;
}
.suggestion-item.streaming::after {
.suggestion-item.streaming .suggestion-text::after {
content: '\258C';
animation: d3-caret-blink 1s steps(1) infinite;
color: var(--d3-accent-main);
}
.provenance {
min-height: 15px;
padding: 0 8px;
color: var(--d3-text-secondary);
font-size: 11px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes d3-caret-blink {
50% {
opacity: 0;
}
}
.hints {
/* ── 아래쪽: 진행률·출처 한 줄 + 키 안내 한 줄 ── */
.footer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 2px;
flex-direction: column;
gap: 5px;
margin-top: 6px;
padding: 6px 8px 2px;
border-top: 1px solid var(--d3-border-result);
color: var(--d3-text-secondary);
font-size: 11px;
font-size: 10.5px;
line-height: 14px;
}
.hint {
.footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
white-space: nowrap;
}
.progress {
display: inline-flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.progress-more {
display: inline-flex;
align-items: center;
gap: 5px;
}
.progress-more .spinner {
width: 8px;
height: 8px;
border-width: 1.5px;
}
.provenance {
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
.key-hints {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
overflow: hidden;
}
.key-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
.key-hint.shared {
gap: 3px;
}
.key-hint.close-hint {
margin-left: auto;
}
kbd {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 4px;
border: 1px solid var(--d3-border-result);
border-bottom-width: 2px;
border-radius: 4px;
color: var(--d3-text-result);
font-family: inherit;
font-size: 10px;
line-height: 1;
}