diff --git a/CHANGELOG.md b/CHANGELOG.md index a5d73da..4ee00c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud-optional backup (encrypted, opt-in) - Plugin system for custom pipelines +## [1.6.0] - 2026-09-24 + +> Published from an annotated tag. Installer and update metadata are served by the +> canonical Forgejo feed; no binaries are committed to this repository. + +### Added +- **Up to 12 next-sentence suggestions, three per page.** The first candidate appears + as soon as it is ready and the rest are generated one at a time in the background. + `Ctrl+Alt+Up`/`Down` move between candidates, `Ctrl+Alt+Left`/`Right` flip pages, + `Ctrl+Alt+Enter` accepts, and `Esc` closes the panel while it is showing. Accepting, + typing on, or closing stops generation. Existing shortcuts that were still on the + 1.5.0 defaults move to this layout automatically; customised ones are kept. +- **A key guide inside the panel** built from your current shortcuts, so it follows + whatever you rebind. +- **The live caption window can be moved.** Hover it to reveal a small handle, drag + it anywhere, and it reopens there next time; double-click the handle to return it to + the bottom of the screen. The rest of the window still lets clicks through. +- **Live captions say they are getting ready** from the moment you start them until + the first line arrives, instead of showing an empty screen for several seconds. + +### Changed +- **The suggestion panel is redesigned**: numbered candidates with a clear selection + bar, two-line items that no longer break Korean words mid-word, no empty header row, + and a two-line footer for progress and keys. The panel stays where it first appeared + while candidates stream in, and sits outside the input box when the app cannot report + a caret position. +- **The suggestion model stays loaded for 10 minutes and warms up on launch**, and on + Ollama reconnect, so the first suggestion no longer times out on a cold reload. +- **The personal phrase memory only learns everyday writing.** Terminals, code editors + and the Agent Switchboard coding hub are excluded, and lines that are mostly symbols + or an empty field's placeholder text are ignored. Phrases learned under the old rules + that break these rules are removed on the next launch. +- Suggestions are never offered in terminals. + +### Fixed +- **Suggestion shortcuts started dictation.** The voice service treated every + shortcut it did not know as dictation, so the new suggestion keys switched on + recording. +- **Suggestions never appeared after updating to 1.5.0** on installs whose local + speech engine was downloaded by an earlier version: the old engine lacked the + focus-reading endpoint. An engine older than the app requires is now downloaded + again automatically. +- **Suggestions were cancelled while typing Korean** because the syllable still being + composed changed the text; cancelled requests also no longer use up the rate limit. +- **The panel appeared after a mouse click without typing**, could not be closed with + X while generating, and showed a spinner next to finished candidates. +- **Model runner windows flashed while typing** when D3RO had started Ollama itself. + It now starts Ollama through Ollama's own tray app. +- **The speech engine could start twice** when two parts of the app needed it at the + same moment. + ## [1.5.0] - 2026-09-23 > Published from an annotated tag. Installer and update metadata are served by the diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index d3ad687..286c0ac 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.5.0" + "version": "1.6.0" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 4c915af..72fb84f 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index 92e9fea..a98b2d0 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.5.0 + 1.6.0 enable enable diff --git a/apps/desktop/package.json b/apps/desktop/package.json index da1617d..19c6e35 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.5.0", + "version": "1.6.0", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index b68a059..a5b9643 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -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 { 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 { 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 { 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() } diff --git a/apps/desktop/src/main/ipc/caption-handlers.ts b/apps/desktop/src/main/ipc/caption-handlers.ts index 0c53df3..d3cbe24 100644 --- a/apps/desktop/src/main/ipc/caption-handlers.ts +++ b/apps/desktop/src/main/ipc/caption-handlers.ts @@ -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) => { diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index e705d93..399d321 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -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): 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): void { + const raw = activeStore.store as unknown as Record + 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 { get(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 | null = null diff --git a/apps/desktop/src/main/services/InputTelemetryService.ts b/apps/desktop/src/main/services/InputTelemetryService.ts index eabc3e2..f73fc39 100644 --- a/apps/desktop/src/main/services/InputTelemetryService.ts +++ b/apps/desktop/src/main/services/InputTelemetryService.ts @@ -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([ + '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') diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index fe683c0..ced05f9 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -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' diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 6de371c..0d8abeb 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -201,6 +201,7 @@ class LocalSTTService extends EventEmitter { private _state: STTState = STTState.Uninitialized private _sidecarProcess: ChildProcess | null = null + private _sidecarStarting: Promise | 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 { - if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) { - await this._spawnSidecar() - await this._waitForHealth() + /** + * sidecar가 실행 중이 아니면 spawn + 헬스체크 대기. + * + * 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에 + * (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로 + * 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다. + */ + private _ensureSidecarRunning(): Promise { + 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 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */ diff --git a/apps/desktop/src/main/services/PersonalGraphService.ts b/apps/desktop/src/main/services/PersonalGraphService.ts index 0298479..6059515 100644 --- a/apps/desktop/src/main/services/PersonalGraphService.ts +++ b/apps/desktop/src/main/services/PersonalGraphService.ts @@ -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() diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts index 35e012f..2982fe7 100644 --- a/apps/desktop/src/main/services/RuntimeProvisioner.ts +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -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 = { + 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 { - 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)`, diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index da2d4cc..349bbd4 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -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 | 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 { + 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 { + 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] } } diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index 78eff77..99f6fac 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -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) diff --git a/apps/desktop/src/main/services/llm-prompts.ts b/apps/desktop/src/main/services/llm-prompts.ts index b8a9324..9de97c9 100644 --- a/apps/desktop/src/main/services/llm-prompts.ts +++ b/apps/desktop/src/main/services/llm-prompts.ts @@ -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( diff --git a/apps/desktop/src/main/suggestion-overlay-policy.ts b/apps/desktop/src/main/suggestion-overlay-policy.ts new file mode 100644 index 0000000..454a562 --- /dev/null +++ b/apps/desktop/src/main/suggestion-overlay-policy.ts @@ -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 +} diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts index e60e4f7..97d1a98 100644 --- a/apps/desktop/src/main/utils/paths.ts +++ b/apps/desktop/src/main/utils/paths.ts @@ -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 프백에만 바인딩한다. */ diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 479b4cd..0fd397c 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -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 { 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 { 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() + }) } } diff --git a/apps/desktop/src/renderer/popups/caption-overlay/index.html b/apps/desktop/src/renderer/popups/caption-overlay/index.html index 520866a..ee5c0c0 100644 --- a/apps/desktop/src/renderer/popups/caption-overlay/index.html +++ b/apps/desktop/src/renderer/popups/caption-overlay/index.html @@ -8,6 +8,10 @@
+
+ + +
diff --git a/apps/desktop/src/renderer/popups/caption-overlay/script.js b/apps/desktop/src/renderer/popups/caption-overlay/script.js index b9a6f92..25bce1a 100644 --- a/apps/desktop/src/renderer/popups/caption-overlay/script.js +++ b/apps/desktop/src/renderer/popups/caption-overlay/script.js @@ -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') + }) + } })() diff --git a/apps/desktop/src/renderer/popups/caption-overlay/style.css b/apps/desktop/src/renderer/popups/caption-overlay/style.css index 126139b..de2bdd9 100644 --- a/apps/desktop/src/renderer/popups/caption-overlay/style.css +++ b/apps/desktop/src/renderer/popups/caption-overlay/style.css @@ -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; diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/index.html b/apps/desktop/src/renderer/popups/suggestion-overlay/index.html index aa21fe7..ae4839f 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/index.html +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/index.html @@ -9,20 +9,23 @@
-
- - + +
-
-
+
diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js index b9b9e87..d490a2b 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js @@ -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) { diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css index 18d0b42..42d8d8e 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css @@ -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; +} diff --git a/apps/desktop/tests/main/services/ConfigService.test.ts b/apps/desktop/tests/main/services/ConfigService.test.ts index 113e1d4..db001b3 100644 --- a/apps/desktop/tests/main/services/ConfigService.test.ts +++ b/apps/desktop/tests/main/services/ConfigService.test.ts @@ -48,14 +48,134 @@ describe('ConfigService suggestion tuning migration', () => { ) await initConfigService() - expect(configGet('suggestionTuningRevision')).toBe(4) + expect(configGet('suggestionTuningRevision')).toBe(5) expect(configGet('suggestionTriggerDelayMs')).toBe(600) expect(configGet('suggestionMaxRequestsPerMinute')).toBe(6) expect(configGet('suggestionMinPrefixChars')).toBe(17) expect(configGet('suggestionDailyBudget')).toBe(777) expect(configGet('suggestionRequestTimeoutMs')).toBe(23000) + // 옛 기본값이 아닌 커스터마이즈(Ctrl+A)는 revision 5 마이그레이션도 건드리지 않는다. expect(configGet('keyBindings')['suggestion-accept']).toEqual(savedBindings['suggestion-accept']) resetInMemoryConfig() }) + + function makeTestStore>(persisted: Partial) { + return class TestStore { + store: T + + constructor(options: { defaults: T }) { + this.store = { ...options.defaults, ...persisted } as T + } + + get(key: K): T[K] { + return this.store[key] + } + + set(key: K, value: T[K]): void { + this.store[key] = value + } + + delete(key: string): void { + delete this.store[key as keyof T] + } + } + } + + it('revision 5: 정확히 옛 기본값(accept=Ctrl+Alt+→, dismiss=Ctrl+Alt+←)이면 새 기본값으로 옮기고 페이지 이동을 그 자리에 채운다', async () => { + const OLD_ACCEPT = [{ device: 'keyboard' as const, code: 39, ctrl: true, alt: true, shift: false, meta: false }] + const OLD_NEXT = [{ device: 'keyboard' as const, code: 40, ctrl: true, alt: true, shift: false, meta: false }] + const OLD_PREV = [{ device: 'keyboard' as const, code: 38, ctrl: true, alt: true, shift: false, meta: false }] + const OLD_DISMISS = [{ device: 'keyboard' as const, code: 37, ctrl: true, alt: true, shift: false, meta: false }] + + vi.doMock('electron-store', () => ({ + default: makeTestStore({ + suggestionTuningRevision: 4, + keyBindings: { + 'suggestion-accept': OLD_ACCEPT, + 'suggestion-next': OLD_NEXT, + 'suggestion-prev': OLD_PREV, + 'suggestion-dismiss': OLD_DISMISS + } + }) + })) + const { configGet, initConfigService, resetInMemoryConfig } = await import( + '../../../src/main/services/ConfigService' + ) + await initConfigService() + + const bindings = configGet('keyBindings') + expect(bindings['suggestion-accept']).toEqual([ + { device: 'keyboard', code: 0x0d, ctrl: true, alt: true, shift: false, meta: false } + ]) + expect(bindings['suggestion-dismiss']).toEqual([ + { device: 'keyboard', code: 0x08, ctrl: true, alt: true, shift: false, meta: false } + ]) + // 옮기지 않는 액션은 그대로 남는다. + expect(bindings['suggestion-next']).toEqual(OLD_NEXT) + expect(bindings['suggestion-prev']).toEqual(OLD_PREV) + // accept/dismiss 가 비켜난 자리를 새 페이지 이동 액션이 충돌 없이 채운다. + expect(bindings['suggestion-page-next']).toEqual([ + { device: 'keyboard', code: 0x27, ctrl: true, alt: true, shift: false, meta: false } + ]) + expect(bindings['suggestion-page-prev']).toEqual([ + { device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false } + ]) + expect(configGet('suggestionTuningRevision')).toBe(5) + + resetInMemoryConfig() + }) + + it('revision 5: 옛 기본값이 아닌 커스터마이즈는 절대 건드리지 않는다', async () => { + const CUSTOM_ACCEPT = [{ device: 'keyboard' as const, code: 0x41, ctrl: true, alt: false, shift: false, meta: false }] + const CUSTOM_DISMISS = [{ device: 'keyboard' as const, code: 0x44, ctrl: true, alt: false, shift: false, meta: false }] + + vi.doMock('electron-store', () => ({ + default: makeTestStore({ + suggestionTuningRevision: 4, + keyBindings: { + 'suggestion-accept': CUSTOM_ACCEPT, + 'suggestion-dismiss': CUSTOM_DISMISS + } + }) + })) + const { configGet, initConfigService, resetInMemoryConfig } = await import( + '../../../src/main/services/ConfigService' + ) + await initConfigService() + + const bindings = configGet('keyBindings') + expect(bindings['suggestion-accept']).toEqual(CUSTOM_ACCEPT) + expect(bindings['suggestion-dismiss']).toEqual(CUSTOM_DISMISS) + + resetInMemoryConfig() + }) + + it('revision 5: 새 페이지 이동 기본값이 다른 액션과 충돌하면 바인딩 없이 둔다', async () => { + // suggestion-next 를 페이지-다음의 새 기본값(Ctrl+Alt+→)과 똑같이 커스터마이즈해 충돌을 만든다. + const CONFLICTING_NEXT = [{ device: 'keyboard' as const, code: 0x27, ctrl: true, alt: true, shift: false, meta: false }] + + vi.doMock('electron-store', () => ({ + default: makeTestStore({ + suggestionTuningRevision: 4, + keyBindings: { + 'suggestion-next': CONFLICTING_NEXT + } + }) + })) + const { configGet, initConfigService, resetInMemoryConfig } = await import( + '../../../src/main/services/ConfigService' + ) + await initConfigService() + + const bindings = configGet('keyBindings') + expect(bindings['suggestion-page-next']).toEqual([]) + // 충돌이 없는 page-prev 는 정상적으로 기본값을 받는다. + expect(bindings['suggestion-page-prev']).toEqual([ + { device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false } + ]) + expect(bindings['suggestion-next']).toEqual(CONFLICTING_NEXT) + + resetInMemoryConfig() + }) }) diff --git a/apps/desktop/tests/main/services/SuggestionService.test.ts b/apps/desktop/tests/main/services/SuggestionService.test.ts index f6a59fa..7bbd6b2 100644 --- a/apps/desktop/tests/main/services/SuggestionService.test.ts +++ b/apps/desktop/tests/main/services/SuggestionService.test.ts @@ -47,6 +47,7 @@ const context = { fullText: PREFIX, caretOffset: PREFIX.length, anchor: null, + anchorKind: null, isPassword: false, isEditable: true, isComposing: false, @@ -55,7 +56,9 @@ const context = { appName: 'notepad.exe', windowTitle: 'notes', idleMs: 1000, - capturedAt: Date.now() + capturedAt: Date.now(), + editedSinceFocus: true, + typedRecently: true } interface InternalSuggestionService { @@ -112,7 +115,7 @@ afterEach(async () => { }) describe('SuggestionService warm-up', () => { - it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => { + it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 10분간 유지한다', async () => { localLlm.streamGenerate.mockImplementation(async function* () { yield 'ok' }) @@ -126,11 +129,63 @@ describe('SuggestionService warm-up', () => { expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) expect(localLlm.streamGenerate).toHaveBeenCalledWith( 'hi', - expect.objectContaining({ maxTokens: 1, keepAlive: '2m' }) + expect.objectContaining({ maxTokens: 1, keepAlive: '10m' }) ) expect(warmingStates).toEqual([true, false]) }) + it('콜드 모델(워밍업 전)에서는 요청 결정이어도 생성 대신 워밍업을 트리거한다', async () => { + localLlm.streamGenerate.mockImplementation(async function* () { + yield 'ok' + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService & { + _warmUpPromise: Promise | null + } + const generateSpy = vi.spyOn(internal, '_generate') + + service.handleTypingContext({ ...context, idleMs: 1000 }) + + expect(generateSpy).not.toHaveBeenCalled() + expect(localLlm.streamGenerate).toHaveBeenCalledWith('hi', expect.objectContaining({ maxTokens: 1 })) + const state = service.getState() + expect(state.warmingUp).toBe(true) + expect(state.lastSkipReason).toBe('model-unavailable') + + await internal._warmUpPromise + }) + + it('dismiss(stale)는 진행 중인 워밍업을 취소하지 않는다', async () => { + let resolveChunk: (() => void) | null = null + localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { + return (async function* () { + await new Promise((resolve, reject) => { + resolveChunk = resolve + options.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }) + }) + yield 'ok' + })() + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService & { + _warmUpAbort: AbortController | null + } + + const warmUpPromise = service.warmUp() + await Promise.resolve() + expect(internal._warmUpAbort?.signal.aborted).toBe(false) + + service.dismiss('stale') + + expect(internal._warmUpAbort?.signal.aborted).toBe(false) + resolveChunk?.() + await warmUpPromise + + expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) + }) + it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => { vi.useFakeTimers() localLlm.isAvailable.mockReturnValue(false) @@ -224,10 +279,13 @@ describe('SuggestionService warm-up', () => { await (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240) const published = states.find((state) => state.candidates.length > 0) - expect(published).toMatchObject({ generating: false, partialText: null }) + // partialText 는 후보 공개와 함께 비워지지만, generating 은 계속 true 로 남는다 — + // 첫 후보 공개 뒤 채우기 루프가 백그라운드에서 나머지(최대 12개)를 마저 + // 청하는 중이라는 신호다(설계). + expect(published).toMatchObject({ generating: true, partialText: null }) }) - it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', async () => { + it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않고, 보여줄 후보가 없으면 오버레이를 지운다', async () => { localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => waitForAbort(options.signal) ) @@ -245,7 +303,81 @@ describe('SuggestionService warm-up', () => { expect(internal._consecutiveFailures).toBe(0) expect(internal._cooldownUntil).toBe(0) expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1) - expect(cleared).toEqual([]) + // 스피너만 뜬 채로 남지 않도록, 후보가 없는 stale 취소는 오버레이를 닫는다. + expect(cleared).toEqual(['stale']) + }) + + it('stale 취소는 속도 제한 예산을 환급해 다음 요청이 rate-limited 되지 않는다', async () => { + localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => + waitForAbort(options.signal) + ) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService & { + _minuteCount: number + _dayCount: number + _prevRequestAt: number + _lastSkipReason: string | null + } + + // 분당 카운터 창을 먼저 굳힌다 — 그렇지 않으면 이 인스턴스의 첫 getState() 호출 + // (_generate 내부의 emit('updated', ...) 에서 일어난다) 이 창을 "지금" 으로 + // 다시 잡으며 방금 늘린 카운트를 0으로 되돌린다 (실제 흐름에선 handleTypingContext + // 가 먼저 창을 굳혀 두므로 일어나지 않는 순서 문제). + service.getState() + + const generation = internal._generate(PREFIX, context, 3, 240) + await Promise.resolve() + + expect(internal._minuteCount).toBe(1) + const requestedAt = internal._lastRequestAt + expect(requestedAt).toBeGreaterThan(0) + + internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다') + await generation + + // 취소된 요청이 쓴 예산이 되돌아간다. + expect(internal._minuteCount).toBe(0) + expect(internal._lastRequestAt).toBe(internal._prevRequestAt) + expect(internal._lastRequestAt).toBeLessThan(requestedAt) + + // 되돌아간 예산으로 바로 다음 요청은 rate-limited 로 막히지 않는다. + service.handleTypingContext({ ...context, idleMs: 1000 }) + expect(internal._lastSkipReason).not.toBe('rate-limited') + }) + + it('pageNext는 다음 페이지 첫 항목으로, 후보가 없는 페이지면 그대로 둔다', async () => { + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> } + internal._candidates = Array.from({ length: 5 }, (_v, i) => ({ text: `후보${i}`, rank: i })) + + expect(service.getState().activeIndex).toBe(0) + service.pageNext() + expect(service.getState().activeIndex).toBe(3) + service.pageNext() + // 5개뿐이라 다음 페이지가 없다 — 그대로 둔다. + expect(service.getState().activeIndex).toBe(3) + service.pagePrev() + expect(service.getState().activeIndex).toBe(0) + service.pagePrev() + expect(service.getState().activeIndex).toBe(0) + }) + + it('next/previous는 페이지와 무관하게 전체 후보를 가로질러 순환한다', async () => { + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> } + internal._candidates = Array.from({ length: 4 }, (_v, i) => ({ text: `후보${i}`, rank: i })) + + service.next() + service.next() + service.next() + expect(service.getState().activeIndex).toBe(3) + service.next() + expect(service.getState().activeIndex).toBe(0) + service.previous() + expect(service.getState().activeIndex).toBe(3) }) it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => { @@ -272,3 +404,165 @@ describe('SuggestionService warm-up', () => { expect(cleared).toEqual([]) }) }) + +describe('SuggestionService 세션 채우기 (최대 12개, 순차 · 페이지)', () => { + it('첫 요청은 1개만 청하고, 성공하면 채우기 루프가 하나씩 최대 12개까지 채운다', async () => { + // 접두/확장 중복 판정 때문에 숫자 접미사(1, 10, 11…)는 서로를 중복으로 오판한다 + // ("이어지는 문장 1" 이 "이어지는 문장 10" 의 접두이므로) — 서로소인 글자를 쓴다. + const LETTERS = 'ABCDEFGHIJKL' + let call = 0 + localLlm.streamGenerate.mockImplementation(async function* () { + const letter = LETTERS[call % LETTERS.length] + call += 1 + yield `이어지는 문장 ${letter}` + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + + await internal._generate(PREFIX, context, 3, 240) + // 세션의 첫 요청은 정확히 1개만 청한다 (한꺼번에 여러 개를 청하면 느리다 — 사용자 요청). + expect(localLlm.streamGenerate).toHaveBeenCalledWith( + expect.stringContaining('1개'), + expect.anything() + ) + + await vi.waitFor(() => { + expect(service.getState().candidates).toHaveLength(12) + }) + expect(service.getState().generating).toBe(false) + expect(service.getState().targetTotal).toBe(12) + expect(localLlm.streamGenerate).toHaveBeenCalledTimes(12) + }) + + it('연속 2번 새 후보가 없으면(전부 중복) 채우기를 멈춘다', async () => { + localLlm.streamGenerate.mockImplementation(async function* () { + yield '같은 문장입니다.' + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => { + expect(service.getState().generating).toBe(false) + }) + + expect(service.getState().candidates).toHaveLength(1) + // 첫 요청 1번 + 중복으로 끝난 채우기 시도 2번 = 3번. + expect(localLlm.streamGenerate).toHaveBeenCalledTimes(3) + }) + + it('중복(완전 일치·접두/확장)은 건너뛰고 고유한 후보만 덧붙인다', async () => { + const sequence = ['같은 문장입니다.', '같은 문장입니다.', '다른 문장입니다.'] + let call = 0 + localLlm.streamGenerate.mockImplementation(async function* () { + const text = sequence[Math.min(call, sequence.length - 1)] + call += 1 + yield text + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => { + expect(service.getState().generating).toBe(false) + }) + + expect(service.getState().candidates.map((c) => c.text)).toEqual([ + '같은 문장입니다.', + '다른 문장입니다.' + ]) + }) + + it('dismiss는 진행 중인 채우기 요청을 취소하고 루프를 멈춘다', async () => { + let fillSignal: AbortSignal | undefined + let firstServed = false + localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => { + if (!firstServed) { + firstServed = true + return (async function* () { + yield '첫 후보입니다.' + })() + } + fillSignal = options.signal + return waitForAbort(options.signal) + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => expect(fillSignal).toBeDefined()) + + expect(fillSignal?.aborted).toBe(false) + service.dismiss('dismissed') + + expect(fillSignal?.aborted).toBe(true) + expect(service.getState().candidates).toHaveLength(0) + }) + + it('세션의 첫 요청만 분당/일일 예산을 쓴다 — 채우기 요청은 쓰지 않는다', async () => { + const LETTERS = 'ABCDEFGHIJKL' + let call = 0 + localLlm.streamGenerate.mockImplementation(async function* () { + const letter = LETTERS[call % LETTERS.length] + call += 1 + yield `문장 ${letter}` + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService & { + _minuteCount: number + _dayCount: number + } + // 분당 카운터 창을 먼저 굳힌다 (다른 테스트와 같은 이유 — 위 주석 참조). + service.getState() + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => expect(service.getState().candidates).toHaveLength(12)) + + expect(internal._minuteCount).toBe(1) + expect(internal._dayCount).toBe(1) + }) + + it('세션이 떠 있는 동안 접두가 자라면(다음 문장 시작) 즉시 세션을 끝낸다', async () => { + localLlm.streamGenerate.mockImplementation(async function* () { + yield '첫 후보입니다.' + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + const cleared: string[] = [] + service.on('cleared', ({ reason }) => cleared.push(reason)) + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => expect(service.getState().visible).toBe(true)) + + service.handleTypingContext({ ...context, prefix: `${PREFIX} 추가로 입력했습니다`, idleMs: 1000 }) + + expect(service.getState().visible).toBe(false) + expect(cleared).toContain('stale') + }) + + it('세션이 떠 있는 동안 마지막 글자만 IME 조합으로 바뀌면 세션을 유지한다', async () => { + localLlm.streamGenerate.mockImplementation(async function* () { + yield '첫 후보입니다.' + }) + const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') + const service = getSuggestionService() + const internal = service as unknown as InternalSuggestionService + const cleared: string[] = [] + service.on('cleared', ({ reason }) => cleared.push(reason)) + + await internal._generate(PREFIX, context, 3, 240) + await vi.waitFor(() => expect(service.getState().visible).toBe(true)) + + const mutatedLastChar = `${PREFIX.slice(0, -1)}요` + service.handleTypingContext({ ...context, prefix: mutatedLastChar, idleMs: 1000 }) + + expect(service.getState().visible).toBe(true) + expect(cleared).not.toContain('stale') + }) +}) diff --git a/apps/desktop/tests/main/services/input-flow-services.test.ts b/apps/desktop/tests/main/services/input-flow-services.test.ts index c31b0a1..8f73d3e 100644 --- a/apps/desktop/tests/main/services/input-flow-services.test.ts +++ b/apps/desktop/tests/main/services/input-flow-services.test.ts @@ -262,6 +262,7 @@ function typingContext(overrides: Partial { fullText: '오늘 회의 결과를', caretOffset: 9, anchor: { x: 1, y: 1, width: 1, height: 1 }, + anchorKind: 'caret', isPassword: false, isEditable: true, isComposing: false, + hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: '회의록', idleMs: 300, - capturedAt: now + capturedAt: now, + editedSinceFocus: true, + typedRecently: true }) expect(getSuggestionService().getState()).toMatchObject({ @@ -566,9 +573,9 @@ describe('입력 플로우 서비스', () => { service._publishLocalMemory( '오늘 회의 결과를', { - prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, isPassword: false, + prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, anchorKind: null, isPassword: false, isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: null, - idleMs: 300, capturedAt: now + idleMs: 300, capturedAt: now, editedSinceFocus: true, typedRecently: true }, 3, 160, diff --git a/apps/desktop/tests/main/services/input-intelligence.test.ts b/apps/desktop/tests/main/services/input-intelligence.test.ts index c729e73..d1ffc0b 100644 --- a/apps/desktop/tests/main/services/input-intelligence.test.ts +++ b/apps/desktop/tests/main/services/input-intelligence.test.ts @@ -22,6 +22,7 @@ import { decideSuggestionRefresh, emptyActivityBucket, endsSentence, + extendsPrefix, extractPhrases, isAppExcluded, isWordBoundaryKey, @@ -37,9 +38,57 @@ import { type PersonalPhrase, type SuggestionPolicyInput } from '@d3ro/core/input-intelligence' +import { + LEARNING_EXCLUDED_APPS, + TERMINAL_APPS, + isAppExcluded, + isLearnablePhrase, + withoutPlaceholderText, + emptyFocusSnapshot +} from '@d3ro/core/input-intelligence' const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false } +describe('개인 코퍼스 학습 규칙', () => { + it('실측된 터미널 상태줄·타임스탬프는 문장으로 치지 않는다', () => { + for (const junk of [ + '◑ OPUS 5', + '00 ◷9', + '5 medium │ CTX ▕░░░░░░░░░░░░▏ 0% 0K/1M', + '0 tokens ─────────────', + '5 분 5', + '7 분 12' + ]) { + expect(isLearnablePhrase(junk), junk).toBe(false) + } + }) + + it('일상 문장은 언어와 무관하게 통과한다', () => { + for (const prose of ['하람이랑 열심히 놀고 있어요', 'Thank you', '달빛에 비치는 캐릭터', 'api 목록에도 안']) { + expect(isLearnablePhrase(prose), prose).toBe(true) + } + }) + + it('터미널·에디터·코딩 에이전트는 학습에서 빠지고, 터미널만 제안에서도 빠진다', () => { + expect(isAppExcluded('WindowsTerminal.exe', LEARNING_EXCLUDED_APPS)).toBe(true) + expect(isAppExcluded('Agent Switchboard.exe', LEARNING_EXCLUDED_APPS)).toBe(true) + expect(isAppExcluded('Code.exe', LEARNING_EXCLUDED_APPS)).toBe(true) + expect(isAppExcluded('KakaoTalk.exe', LEARNING_EXCLUDED_APPS)).toBe(false) + expect(isAppExcluded('WindowsTerminal.exe', TERMINAL_APPS)).toBe(true) + expect(isAppExcluded('Agent Switchboard.exe', TERMINAL_APPS)).toBe(false) + }) + + it('입력창 이름과 같은 텍스트(안내 문구)는 빈 칸으로 본다', () => { + const base = { ...emptyFocusSnapshot('-', 0), available: true, isEditable: true, caretOffset: 6 } + const placeholder = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '메시지 입력' }) + expect(placeholder.text).toBe('') + expect(placeholder.caretOffset).toBeNull() + + const typed = withoutPlaceholderText({ ...base, controlName: '메시지 입력', text: '안녕하세요' }) + expect(typed.text).toBe('안녕하세요') + }) +}) + describe('classifyKeyStroke', () => { it('문자/숫자/기능 키를 종류로 나눈다', () => { expect(classifyKeyStroke(0x41, NO_MODS)).toBe('letter') // A @@ -155,6 +204,8 @@ describe('decideSuggestion', () => { isEditable: true, appName: 'chrome.exe', excludedApps: [], + editedSinceFocus: true, + typedRecently: true, prefix: '오늘 회의에서 논의한 내용을 정리해서', idleMs: SUGGESTION_DEFAULTS.triggerDelayMs + 50, triggerDelayMs: SUGGESTION_DEFAULTS.triggerDelayMs, @@ -258,6 +309,25 @@ describe('decideSuggestion', () => { reason: 'already-visible' }) }) + + it('포커스만 옮겨 왔을 뿐(편집 없음) 이면 지운다 (마우스 클릭만으로 옛 텍스트가 제안되던 문제)', () => { + // 실측: YouTube 검색창(이미 19자 옛 검색어가 있는)을 클릭만 했는데 제안이 떴다. + expect(decideSuggestion(policy({ editedSinceFocus: false }))).toEqual({ + action: 'clear', + reason: 'not-typing' + }) + }) + + it('편집은 했지만 최근에 실제로 타이핑한 적이 없으면 지운다', () => { + expect(decideSuggestion(policy({ typedRecently: false }))).toEqual({ + action: 'clear', + reason: 'not-typing' + }) + }) + + it('편집도 했고 최근 타이핑도 있으면 통과한다', () => { + expect(decideSuggestion(policy({ editedSinceFocus: true, typedRecently: true })).action).toBe('request') + }) }) describe('표시 중 제안 재생성 정책', () => { @@ -274,6 +344,31 @@ describe('표시 중 제안 재생성 정책', () => { it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => { expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale') }) + + it('IME 조합으로 마지막 글자만 바뀌면 stale 이 아니라 keep 이다', () => { + // 생성 시점엔 "하" 로 끝났는데, 조합이 이어져 지금은 "한" 으로 끝난 경우. + expect(decideSuggestionRefresh('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe( + 'keep' + ) + }) +}) + +describe('extendsPrefix — IME 마지막 글자 조합 보정', () => { + it('마지막 글자만 조합 중 문자로 바뀌어도 연속 확장으로 본다', () => { + expect(extendsPrefix('회의록을 정리하고 공유하려고 하', '회의록을 정리하고 공유하려고 한')).toBe(true) + }) + + it('마지막 글자를 빼도 접두가 다르면 확장이 아니다', () => { + expect(extendsPrefix('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe(false) + }) + + it('완전히 같은 접두는 확장이다', () => { + expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다')).toBe(true) + }) + + it('접두 뒤로 자란 경우도 확장이다', () => { + expect(extendsPrefix('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe(true) + }) }) describe('isAppExcluded', () => { @@ -336,14 +431,21 @@ describe('anchorFloatingPanel', () => { const workArea = { x: 0, y: 0, width: 1920, height: 1080 } const size = { width: 460, height: 96 } - it('케어 아래에 붙인다', () => { - const position = anchorFloatingPanel({ x: 400, y: 300, width: 2, height: 20 }, { x: 0, y: 0 }, size, workArea) + it('케어 앵커는 아래에 붙인다', () => { + const position = anchorFloatingPanel( + { x: 400, y: 300, width: 2, height: 20 }, + 'caret', + { x: 0, y: 0 }, + size, + workArea + ) expect(position).toEqual({ x: 400, y: 326 }) }) - it('아래 공간이 없으면 위로 뒤집는다', () => { + it('케어 앵커는 아래 공간이 없으면 위로 뒤집는다', () => { const position = anchorFloatingPanel( { x: 400, y: 1000, width: 2, height: 20 }, + 'caret', { x: 0, y: 0 }, size, workArea @@ -351,9 +453,10 @@ describe('anchorFloatingPanel', () => { expect(position.y).toBe(1000 - 6 - 96) }) - it('작업영역 밖으로 나가지 않는다', () => { + it('케어 앵커는 작업영역 밖으로 나가지 않는다', () => { const position = anchorFloatingPanel( { x: 1900, y: 10, width: 2, height: 20 }, + 'caret', { x: 0, y: 0 }, size, workArea @@ -363,9 +466,44 @@ describe('anchorFloatingPanel', () => { }) it('앵커가 없으면 커서를 쓴다', () => { - const position = anchorFloatingPanel(null, { x: 200, y: 500 }, size, workArea) + const position = anchorFloatingPanel(null, null, { x: 200, y: 500 }, size, workArea) expect(position).toEqual({ x: 200, y: 506 }) }) + + describe('요소 앵커 (케어렛을 못 얻어 elementRect 로 폴백한 경우)', () => { + it('아래에 맞으면 요소 바깥 아래에 붙인다', () => { + const element = { x: 400, y: 300, width: 300, height: 40 } + const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea) + expect(position).toEqual({ x: 400, y: 300 + 40 + 6 }) + }) + + it('아래가 안 맞고 위가 맞으면 요소 바깥 위에 붙인다', () => { + const element = { x: 400, y: 1080 - 50, width: 300, height: 40 } + const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea) + expect(position).toEqual({ x: 400, y: element.y - 6 - size.height }) + }) + + it('위아래 다 안 맞으면 오른쪽 바깥에 붙인다', () => { + const element = { x: 0, y: 0, width: 1400, height: 1076 } + const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea) + expect(position).toEqual({ x: element.x + element.width + 6, y: element.y }) + }) + + it('위아래오른쪽 다 안 맞으면 왼쪽 바깥에 붙인다', () => { + const element = { x: 1820, y: 0, width: 100, height: 1080 } + const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea) + expect(position).toEqual({ x: element.x - 6 - size.width, y: element.y }) + }) + + it('네 방향 다 안 맞으면 요소 안쪽 우하단 모서리로 물러난다', () => { + const element = { x: 0, y: 0, width: 1920, height: 1080 } + const position = anchorFloatingPanel(element, 'element', { x: 0, y: 0 }, size, workArea) + expect(position).toEqual({ + x: element.x + element.width - size.width - 6, + y: element.y + element.height - size.height - 6 + }) + }) + }) }) describe('마우스 이동', () => { diff --git a/apps/desktop/tests/main/services/llm-prompts.test.ts b/apps/desktop/tests/main/services/llm-prompts.test.ts index 933fe1e..b1cb68a 100644 --- a/apps/desktop/tests/main/services/llm-prompts.test.ts +++ b/apps/desktop/tests/main/services/llm-prompts.test.ts @@ -24,6 +24,7 @@ import { DEFAULT_TARGET_LANGUAGE, BASE_SYSTEM_PROMPTS, SUGGESTION_NO_THINK_PREFIX, + SUGGESTION_SYSTEM_PROMPT, } from '../../../src/main/services/llm-prompts' beforeEach(() => { @@ -212,4 +213,38 @@ describe('buildSuggestionPrompt', () => { expect(systemPrompt).toContain('400') expect(text).toContain('5') }) + + it('비서처럼 되묻거나 도와주겠다고 하지 말라는 규칙이 시스템 프롬프트에 있고 사용자 텍스트에는 없다', () => { + // 실측: 모델이 "혹시 이 영상 내용에 대해 궁금한 점이 있으신가요?" 처럼 + // 사용자에게 되묻는 비서형 응답을 낸 회귀를 막는다. + const { systemPrompt, text } = buildSuggestionPrompt({ prefix: '오늘 회의에서 논의한 내용을 정리해서' }) + + expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/비서가 아닙니다/) + expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/질문하지 말고/) + expect(SUGGESTION_SYSTEM_PROMPT).toMatch(/검색어나 폼 입력/) + expect(systemPrompt).toContain('비서가 아닙니다') + expect(systemPrompt).toContain('질문하지 말고') + expect(systemPrompt).toContain('검색어나 폼 입력') + + expect(text).not.toContain('비서가 아닙니다') + expect(text).not.toContain('질문하지 말고') + expect(text).not.toContain('검색어나 폼 입력') + }) + + it('avoidCandidates는 데이터 섹션으로만 들어가고 이미 나온 후보를 모두 나열한다', () => { + const { systemPrompt, text } = buildSuggestionPrompt({ + prefix: '오늘 회의에서 논의한 내용을 정리해서', + avoidCandidates: ['공유드리겠습니다.', '전달드리겠습니다.'] + }) + + expect(text).toContain('공유드리겠습니다.') + expect(text).toContain('전달드리겠습니다.') + expect(text).not.toContain('규칙:') + expect(systemPrompt).not.toContain('공유드리겠습니다.') + }) + + it('avoidCandidates가 없으면 해당 섹션이 아예 없다', () => { + const { text } = buildSuggestionPrompt({ prefix: 'abc' }) + expect(text).not.toContain('이미 제안한 문장') + }) }) diff --git a/apps/desktop/tests/main/suggestion-overlay-policy.test.ts b/apps/desktop/tests/main/suggestion-overlay-policy.test.ts new file mode 100644 index 0000000..d0b6328 --- /dev/null +++ b/apps/desktop/tests/main/suggestion-overlay-policy.test.ts @@ -0,0 +1,41 @@ +// tests/main/suggestion-overlay-policy.test.ts +// bootstrap.ts 의 suggestion.on('updated') 배선이 쓰는 표시 전략 순수 함수 테스트. +// 스트리밍 중 매 청크마다 show(=setBounds+present) 를 다시 부르면 X 클릭이 가로채이고 +// 패널이 튀던 문제(실측)를 막는 결정을 검증한다. + +import { describe, it, expect } from 'vitest' +import { decideSuggestionOverlayAction, shouldDismissOnEscape } from '../../src/main/suggestion-overlay-policy' + +describe('decideSuggestionOverlayAction', () => { + it('보여줄 것이 없으면 오버레이가 떠 있든 아니든 hide', () => { + expect(decideSuggestionOverlayAction(false, false)).toBe('hide') + expect(decideSuggestionOverlayAction(true, false)).toBe('hide') + }) + + it('아직 떠 있지 않으면 show (위치를 새로 계산)', () => { + expect(decideSuggestionOverlayAction(false, true)).toBe('show') + }) + + it('이미 떠 있으면 update (재배치/재present 없이 내용만)', () => { + expect(decideSuggestionOverlayAction(true, true)).toBe('update') + }) +}) + +const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false } + +describe('shouldDismissOnEscape', () => { + it('아무것도 안 떠 있으면 평범한 Esc 도 아무 일도 하지 않는다', () => { + expect(shouldDismissOnEscape(false, NO_MODS)).toBe(false) + }) + + it('떠 있고 수정자가 없으면 닫는다', () => { + expect(shouldDismissOnEscape(true, NO_MODS)).toBe(true) + }) + + it('떠 있어도 수정자가 있으면(Ctrl+Esc 등) 반응하지 않는다', () => { + expect(shouldDismissOnEscape(true, { ...NO_MODS, ctrl: true })).toBe(false) + expect(shouldDismissOnEscape(true, { ...NO_MODS, alt: true })).toBe(false) + expect(shouldDismissOnEscape(true, { ...NO_MODS, shift: true })).toBe(false) + expect(shouldDismissOnEscape(true, { ...NO_MODS, meta: true })).toBe(false) + }) +}) diff --git a/apps/desktop/tests/main/utils/paths.test.ts b/apps/desktop/tests/main/utils/paths.test.ts index 54be575..80b7488 100644 --- a/apps/desktop/tests/main/utils/paths.test.ts +++ b/apps/desktop/tests/main/utils/paths.test.ts @@ -75,7 +75,7 @@ describe('paths (packaged)', () => { configurable: true, }) - expect(() => getSidecarCommand()).toThrowError(/사이드카를 찾을 수 없습니다/) + expect(() => getSidecarCommand()).toThrowError(/로컬 음성 엔진이 아직 설치되지 않았습니다/) }) it('uses the packaged sidecar executable when present', () => { diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index 0144c96..4273262 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.5.0" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1050000 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.6.0" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1060000 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index 3f221d2..deacf95 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1050000; + CURRENT_PROJECT_VERSION = 1060000; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1050000; + CURRENT_PROJECT_VERSION = 1060000; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.6.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1060000.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1060000.txt new file mode 100644 index 0000000..eea2aff --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1060000.txt @@ -0,0 +1 @@ +Maintenance update. This release focuses on sentence suggestions and live captions in the desktop app; there are no mobile feature changes. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1060000.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1060000.txt new file mode 100644 index 0000000..995228d --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1060000.txt @@ -0,0 +1 @@ +유지보수 업데이트입니다. 이번 변경은 데스크톱 앱의 문장 제안·실시간 자막에 집중되어 있으며, 모바일 앱의 기능 변경은 없습니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index 5549a1c..b1d22e1 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index 28ffb61..9cfe444 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.5.0", + "version": "1.6.0", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index 070b1fb..4f7b2b1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index c30cb8e..f1d9376 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.5.0 + v1.6.0 diff --git a/apps/web/src/lib/desktop-release.ts b/apps/web/src/lib/desktop-release.ts index b988637..bb2456d 100644 --- a/apps/web/src/lib/desktop-release.ts +++ b/apps/web/src/lib/desktop-release.ts @@ -4,10 +4,10 @@ // 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는 // 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다. -export const DESKTOP_VERSION = '1.5.0' +export const DESKTOP_VERSION = '1.6.0' /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ -export const DESKTOP_RELEASE_DATE = '2026-09-23' +export const DESKTOP_RELEASE_DATE = '2026-09-24' const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_OWNER = 'yunchan' diff --git a/docs/map/00-index.md b/docs/map/00-index.md index a896c26..4f9fc9b 100644 --- a/docs/map/00-index.md +++ b/docs/map/00-index.md @@ -2,12 +2,12 @@ > Status: ACTIVE > Last full audit: 2026-09-13 -> Last update: 2026-09-23 — **v1.5.0 릴리스.** CHANGELOG `[1.5.0]` 을 확정하고 버전 SSOT를 1.5.0(android/iOS 1050000)으로 올렸으며, `site/src/release.ts` 와 `apps/web/src/lib/desktop-release.ts` 다운로드 링크를 1.5.0으로 동기화했다. 이 릴리스는 입력 인텔리전스(`INPUT-01`~`INPUT-18`, 전부 데스크톱 `[~]`), 커스텀 인스트럭션 수정(AI-04/05), `LocalLLMService` 요청별 취소·상한을 포함한다. 인증서가 없어 무서명 업데이터 게시 예외(GAP-REL-06)를 유지한다. 게시 결과: canonical feed `latest.yml`=1.5.0(설치본 91.2MiB / 95,612,108 bytes, sha512 일치, 익명 206), `runtime-latest`/`portable-latest`=1.5.0(원격 sha256 검증), 사이트 `https://d3ro.chanpaca.net/release-identity.json`=commit `5c11ee2`/1.5.0. 게시 중 portable 별칭이 낡은 바이트와 새 바이트로 갈라지는 버그(GAP-REL-12)를 발견해 게시 스크립트를 수정했다. +> Last update: 2026-09-24 — **v1.6.0 릴리스.** 버전 SSOT를 1.6.0(android/iOS 1060000)으로 올렸다. 다음 문장 제안을 1개씩 순차 생성해 최대 12개·3개씩 페이지로 보여 주고(Ctrl+Alt+↑↓ 이동, ←→ 페이지, Enter 수락, Esc 닫기; 기존 기본 단축키는 revision 5에서 이관), 오버레이를 재설계했다(`INPUT-06`/`INPUT-07`). 개인 문구 코퍼스는 터미널·에디터·코딩 에이전트와 비문장·플레이스홀더를 학습하지 않고 기존 데이터도 같은 규칙으로 정리한다(`INPUT-04`/`INPUT-05`). 제안 단축키가 받아쓰기를 켜던 버그, 구버전 사이드카가 `/uia/focus` 없이 남던 버그(런타임 최소 버전 1.5.0), Ollama 러너 콘솔 창, 사이드카 이중 기동을 고쳤다. 실시간 자막 창은 끌어서 옮길 수 있고 첫 자막 전까지 준비 안내를 보인다. 무서명 로컬 게시 예외(GAP-REL-06)를 유지한다. > > Previous update: 2026-09-22 — **Gemma/Ollama 폭주 방어 계약을 기록했다.** 19:14:12 부팅 워밍업이 `keep_alive: 30m`으로 `gemma4:e4b`를 19:44:12까지 VRAM 3,226,342,521 bytes / context 4096으로 강제 상주시킨 것이 관측됐으며, 같은 시점 Windows GPU Engine PID 표본에는 Ollama의 활성 compute가 없었다. 즉 당시 상태는 무한 추론이 아니라 강제 residency였다. 19:11:17~19:11:58의 자동 제안 연속 생성은 기존 900 ms·12/min·5 candidates·128 tokens·한 글자 재생성 정책이 허용한 burst였다. 현재 구현 계약은 부팅 warmup 제거, `keep_alive: 2m`, 제안 600 ms / 최소 5 s 간격 / 기본 6회·hard max 12회 per min / 3 candidates / 64 tokens / 12-char growth / 8 s timeout, 그리고 요청별 취소·상한·종료 정리다. 독립 표적 검증은 6 test files / 69 tests passed / 0 failed, 변경 코드·테스트 ESLint와 `git diff --check`도 exit 0이다. Raw Ollama에서는 cold bounded 요청이 client hard timeout 15.044 s에 취소된 뒤 `/api/ps`가 비었고 `/api/version`은 80 ms에 회복했다. 명시 warmup은 HTTP 200 / 16.639 s, 후속 warm 요청은 body `options.num_predict=1`, `keep_alive='2m'`로 553 ms HTTP 200 / `done:true` / `eval_count:1` / `done_reason:length`였고 `/api/ps` expiry는 약 119.9 s였다. **19:48:59 +09:00에는 새 generate/unload/kill/retry 없이 충분히 지난 뒤 한 번의 `/api/ps`가 HTTP 200 / 45.8 ms / `{models:[]}`였고 `/api/version`은 HTTP 200 / 7.3 ms / `0.32.13`이었다.** 이는 raw API 수준의 expiry 뒤 unload 확인일 뿐 앱 재시작·GUI·실제 타이핑 증거는 아니므로 상태는 `[~]`로 유지한다 (`11` GAP-LLM-04, GAP-INPUT-06). > > Previous update: 2026-09-21 — **Input intelligence (입력 레메트리 + 다음 문장 제안) 신규**. 데스크톱에 입력 수집기(`InputTelemetryService`), UIA 컨텍스트 브리지(사이드카 `GET /uia/focus`), 제안 서비스(`SuggestionService`), 어렛 커 오버레이, 설정 > 입력 탭(동의·정책·주간 인사이트·개인 문구)을 추가했다. 카탈로그에 `INPUT-01`~`INPUT-08`(전부 데스크톱 `[~]` — 유닛 48건은 GREEN 이지만 **실앱 타이핑 검증 전**), 백로그에 GAP-INPUT-01~05 + GAP-LLM-03, §7 에 CONSTRAINT-INPUT-01(키 내용 미저장 — ActivityWatch 정책 채택)을 기록. 설계 근거는 조사 기반이다: 어렛은 `GetGUIThreadInfo` 가 아니라 UIA `TextPattern.GetSelection`(Chromium 은 `TextPattern2` 미구현), 타이핑 스트는 키코드 복원이 아니라 UIA 스냅샷 diff(한/일 IME 대응), 디바운스/토큰 한도는 인라인 컴플리션 실측값(Continue 350 / Tabby 250 / twinny 300 ms, 출력 64~256 토큰). 의존성: `koffi` 3.3.1(포그라운드 창 FFI), 사이드카 `uiautomation` 2.0.29 + `comtypes`. 데스크톱 유닛 총계 1409(+49), Electron ABI 실행에서 신규 실패 0건. 당시의 24.7초/4.9초 지연 설명과 `keep_alive: 30m`·부팅 워밍업 처방은 **현재 상태가 아닌 과거 가설/완화 이력**이며, 최신 운영 결론은 위 2026-09-22 항목과 `11` GAP-LLM-04를 따른다. 직전: LLM instruction-prompt fix (`9c2b4d4`): the custom-instruction path inserted the instruction's own wording instead of the processed result and had **never worked in any shipped release** (`v0.1.0-alpha`..`v1.4.0`, introduced `fea923d` 2026-04-05, not a regression). `llm-prompts.ts` is now the SSOT for prompt resolution and placeholder substitution, shared by `VoiceModeService` / `ChainService` / `LLM.PROCESS`. AI-04/05/06/07 are demoted to `[~]` on desktop — fixed with unit tests, but **not verified in a running app** and the four related `tests/red/*.usecase.test.ts` could not execute (`better-sqlite3` ABI). New: GAP-LLM-01 (no target-language setting), GAP-LLM-02 (this fix unverified); GAP-INFRA-06 amended (the ABI masks verification, not just dev-env switching cost); GAP-I18N-01 amended (`popup.error.default` missing in 10 locales). Earlier the same day: CAP-16 (desktop key bindings rebuilt on one `@d3ro/core/keybinding` SSOT — multiple bindings per action, mouse buttons, `HOTKEY` → `KEYBINDING` IPC group), verified on Windows by a manual run, so CAP-16 and CAP-02 are `[x]` and GAP-KEY-01 is closed. Still open: GAP-KEY-02/03, GAP-QA-02, GAP-I18N-01/02, GAP-INFRA-06, GAP-LLM-01/02, GAP-INPUT-01~05; `11` §7 holds accepted design constraints (things deliberately kept, not gaps) -> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.5.0` (`release/product-version.json`, released 2026-09-23) +> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.6.0` (`release/product-version.json`, released 2026-09-24) > Purpose: let any agent (or human) answer two questions in under a minute: > 1. **What infrastructure exists?** (build, CI, services, APIs, data, packages, deploy) > 2. **How far is each feature developed?** (per surface, with file anchors and status) diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 69a77fe..129eb81 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -194,7 +194,7 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md). | File | Purpose | |---|---| -| `release/product-version.json` | version `1.5.0`, `androidVersionCode`/`iosBuildNumber` `1050000`, releaseDate `2026-09-23`, desktop license keyId | +| `release/product-version.json` | version `1.6.0`, `androidVersionCode`/`iosBuildNumber` `1060000`, releaseDate `2026-09-24`, desktop license keyId | | `release/android-release-identity.json` | package `com.d3ro.voice`, Play app ID, app-signing SHA-256, upload cert SHA-256, evidence keyId, AdMob unit IDs | | `release/desktop-license-public.pem` | Ed25519 public key for desktop offline licenses | | `release/mobile-release-evidence-public.pem` | Ed25519 public key for mobile release evidence | diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index ba3cd75..b5a9c20 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -167,10 +167,10 @@ end-to-end behaviour has **not been verified by typing in a real app** (`11` GAP | INPUT-01 | Keyboard/mouse telemetry capture (opt-in) | [~] | [-] | [-] | [-] | `InputTelemetryService` — keystroke/click/scroll counters, mouse travel as the Manhattan sum of per-axis pixel deltas, active time, per-hour×app buckets flushed every 5 s. Key **contents** are never stored (ActivityWatch `aw-watcher-input` data-minimisation policy, adopted deliberately — see `11` §7). Hook ownership is ref-counted so `KeyBindingService` keeps working (`global-input-hook.ts`). | | INPUT-02 | Foreground-app attribution | [~] | [-] | [-] | [-] | `utils/win32-foreground.ts` via `koffi` FFI (title/pid/exe/bounds), sampled at most 1×/s. `get-windows` was rejected: it needs an install script this repo does not run. | | INPUT-03 | Weekly input insights | [~] | [-] | [-] | [-] | `INPUT_TELEMETRY.getSummary` aggregates `input_activity` into totals, daily series, top hours and top apps; rendered in Settings → Input and as a dashboard card. Daily average mouse travel is converted px → m using the display scale factor. | -| INPUT-04 | Typed-text learning (UIA, password-excluded) | [~] | [-] | [-] | [-] | Text is read from the focused field via `GET /uia/focus` (sidecar UIA bridge) and diffed longest-common-prefix/suffix, so **IME-committed Hangul/kana is counted correctly** — keycodes cannot reconstruct CJK text. UIA sends `hasSelection` only, derived by TextPattern range Start/End comparison without calling `GetText` on the selection range or adding a selected-text payload; the existing focused-field text can still include a selection. A non-collapsed selection immediately clears suggestions as `selection-active`. `IsPassword` is checked before any read (fail-closed); IME composition suppresses both stats and suggestions. | -| INPUT-05 | Personal phrase corpus (typed + voice) | [~] | [-] | [-] | [-] | Sentence-level phrases from typed text and from voice history (`HistoryService.create` feeds `recordExternalText`), ranked by frequency/recency as prompt hints; users can delete individual phrases. | -| INPUT-06 | Next-sentence suggestion (ghost text) | [~] | [-] | [-] | [-] | `SuggestionService` + `buildSuggestionPrompt` (`llm-prompts.ts` SSOT, instruction stays in the system prompt). The 2026-09-22 guard contract is 600 ms debounce, ≥5 s between requests, 6 requests/min by default (hard-config maximum 12), 3 candidates, 64 output tokens, 12-character growth before regeneration, 8 s request timeout and `keep_alive: 2m`; boot warmup is removed. Each request has its own cancellation signal. Presentation-active includes candidates, `generating`, `warmingUp` and `partialText`; clear/dismiss aborts, invalidates the generation token, clears TTL state and emits `cleared`/hide, and a final success resets `generating=false`/`partialText=null`. Focused evidence for the lifecycle and Windows-child-process change: five test files / 80 tests passed; desktop typecheck/lint, Python `py_compile`, and `git diff --check` exited 0 (core 131-test pass was independently verified earlier). Status remains `[~]`: this is not app-restart, GUI overlay, or real automatic-typing evidence. | -| INPUT-07 | Caret-anchored suggestion overlay | [~] | [-] | [-] | [-] | `suggestion-overlay` popup placed by `anchorFloatingPanel` (caret → element → cursor fallback, flip above when the caret is near the bottom, clamped to the work area). Non-focusable; click-through unless `suggestionOverlayInteractive`. While actually visible it continues periodic UIA validation after 5 s and revalidates 120 ms after mouse-up; unavailable/non-editable focus or a non-collapsed selection aborts and hides it. X first hides the renderer panel, then main IPC directly hides `BrowserWindow` and dismisses the service, so late tokened results cannot revive it. Accept/next/prev/dismiss are four global key bindings (`suggestion-accept`/`next`/`prev`/`dismiss`, default `Ctrl+Alt+→/↓/↑/←`), and the overlay has a mouse close button. Up to three candidates are shown in a scrollable list with a warm-up/generating spinner. | +| INPUT-04 | Typed-text learning (UIA, password-excluded) | [~] | [-] | [-] | [-] | Text is read from the focused field via `GET /uia/focus` (sidecar UIA bridge) and diffed longest-common-prefix/suffix, so **IME-committed Hangul/kana is counted correctly** — keycodes cannot reconstruct CJK text. UIA sends `hasSelection` only, derived by TextPattern range Start/End comparison without calling `GetText` on the selection range or adding a selected-text payload; the existing focused-field text can still include a selection. A non-collapsed selection immediately clears suggestions as `selection-active`. `IsPassword` is checked before any read (fail-closed); IME composition suppresses both stats and suggestions. **2026-09-24:** text equal to the control name (empty-field placeholder, e.g. "메시지 입력") is treated as empty (`withoutPlaceholderText`); a suggestion requires `editedSinceFocus` + typing within `recentTypingWindowMs` (8 s), otherwise `not-typing` — clicking into a pre-filled field no longer triggers suggestions. | +| INPUT-05 | Personal phrase corpus (typed + voice) | [~] | [-] | [-] | [-] | Sentence-level phrases from typed text and from voice history (`HistoryService.create` feeds `recordExternalText`), ranked by frequency/recency as prompt hints; users can delete individual phrases. **2026-09-24:** typed text from `LEARNING_EXCLUDED_APPS` (terminals, code editors, Agent Switchboard) is never learned, and every phrase must pass `isLearnablePhrase` (no box/block/geometric glyphs, ≥60 % letters). `_pruneUnlearnableCorpus` re-applies both rules to existing phrases/samples/edges on start and every retention cycle (first run on the author machine removed 69 phrases / 91 samples of terminal status lines and agent chats). | +| INPUT-06 | Next-sentence suggestion (ghost text) | [~] | [-] | [-] | [-] | `SuggestionService` + `buildSuggestionPrompt` (`llm-prompts.ts` SSOT, instruction stays in the system prompt). The 2026-09-22 guard contract is 600 ms debounce, ≥5 s between requests, 6 requests/min by default (hard-config maximum 12), 3 candidates, 64 output tokens, 12-character growth before regeneration, 8 s request timeout and `keep_alive: 2m`; boot warmup is removed. Each request has its own cancellation signal. Presentation-active includes candidates, `generating`, `warmingUp` and `partialText`; clear/dismiss aborts, invalidates the generation token, clears TTL state and emits `cleared`/hide, and a final success resets `generating=false`/`partialText=null`. Focused evidence for the lifecycle and Windows-child-process change: five test files / 80 tests passed; desktop typecheck/lint, Python `py_compile`, and `git diff --check` exited 0 (core 131-test pass was independently verified earlier). Status remains `[~]`: this is not app-restart, GUI overlay, or real automatic-typing evidence. **2026-09-24 (supersedes the numbers above):** `keep_alive: 10m`, warm-up on boot, on Ollama reconnect and whenever the model is presumed cold (cold reload measured 11.4 s > 8 s timeout); a session asks for 1 candidate, then a sequential fill loop appends one unique candidate at a time up to `maxCandidatesTotal` 12 (avoid-list in the prompt, stops after 2 empty fills); only the first request consumes rate/daily budget; stale aborts refund the budget; any further typing ends the session (`matchesSessionPrefix`, IME last-syllable tolerant); prompt forbids assistant-style questions. Terminals (`TERMINAL_APPS`) never get suggestions. | +| INPUT-07 | Caret-anchored suggestion overlay | [~] | [-] | [-] | [-] | `suggestion-overlay` popup placed by `anchorFloatingPanel` (caret → element → cursor fallback, flip above when the caret is near the bottom, clamped to the work area). Non-focusable; click-through unless `suggestionOverlayInteractive`. While actually visible it continues periodic UIA validation after 5 s and revalidates 120 ms after mouse-up; unavailable/non-editable focus or a non-collapsed selection aborts and hides it. X first hides the renderer panel, then main IPC directly hides `BrowserWindow` and dismisses the service, so late tokened results cannot revive it. Accept/next/prev/dismiss are four global key bindings (`suggestion-accept`/`next`/`prev`/`dismiss`, default `Ctrl+Alt+→/↓/↑/←`), and the overlay has a mouse close button. Up to three candidates are shown in a scrollable list with a warm-up/generating spinner. **2026-09-24:** one page of 3 fixed-height (2-line) numbered items, footer = range/progress + source + key guide derived from the live bindings (`buildSuggestionKeyHints`, shared modifier shown once). Keys: Ctrl+Alt+↑/↓ move, Ctrl+Alt+←/→ page (`suggestion-page-next/prev`), Ctrl+Alt+Enter accept, plain Esc closes while visible (overlay-scoped, not a binding), Ctrl+Alt+Backspace secondary dismiss; old default bindings migrate at tuning revision 5. Streaming updates never reposition/re-present the window (`decideSuggestionOverlayAction`); placement uses `anchorKind` — caret: below the line, element: outside the element (below → above → right → left → inner bottom-right). Navigation re-arms the visible TTL. Window 460×208. | | INPUT-08 | Per-app exclusions & consent controls | [~] | [-] | [-] | [-] | `inputExcludedApps` (executable names, case-insensitive) blocks both collection context and suggestions; telemetry master switch, pause, text-learning toggle and "delete collected data" all live in Settings → Input. 30-day retention prune runs on start. | | INPUT-09 | Flow Radar | [~] | [-] | [-] | [-] | `rankFlowWindows` ranks hourly aggregate activity density, character volume and edit stability into potential-focus time windows. It is not a real-session detector or session record. | | INPUT-10 | Edit Friction | [~] | [-] | [-] | [-] | `calculateFrictionInsight` derives friction from char/backspace quantities and reports edits per 100 chars; it does not infer sentiment or productivity. | diff --git a/package-lock.json b/package-lock.json index 1e10a66..157808c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-monorepo", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-monorepo", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "workspaces": [ "apps/desktop", @@ -25,7 +25,7 @@ }, "apps/admin": { "name": "@d3ro/admin", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@d3ro/desktop", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -159,7 +159,7 @@ }, "apps/web": { "name": "@d3ro/web", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -17214,7 +17214,7 @@ }, "packages/api-client": { "name": "@d3ro/api-client", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -17231,7 +17231,7 @@ }, "packages/core": { "name": "@d3ro/core", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -17242,7 +17242,7 @@ }, "packages/i18n": { "name": "@d3ro/i18n", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -17253,7 +17253,7 @@ }, "packages/ui": { "name": "@d3ro/ui", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@d3ro/core": "*" @@ -17273,7 +17273,7 @@ }, "packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/package.json b/package.json index c855068..9c583da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d3ro-voice-monorepo", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "author": "D3RO", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index a1d6d78..a5d24b5 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/api-client", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index a2a5778..f7ef9ea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/core", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "license": "MIT", diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index a26a266..f2a99b7 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -26,6 +26,16 @@ export interface UiRect { height: number } +/** + * 앵커 rect 가 케어렛(한 줄 위치)인지 포커스 요소 전체인지. + * + * 케어렛을 못 주는 제공자가 많아(Chrome, Windows Terminal 등 caret=-1) 그때는 + * elementRect 로 폴백하는데, 두 경우는 배치 전략이 달라야 한다 — 케어렛은 "그 + * 줄 아래" 에 붙이면 되지만, elementRect(멀티라인/큰 입력창 전체)는 그 규칙을 + * 그대로 쓰면 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다. + */ +export type AnchorKind = 'caret' | 'element' | null + /** 마우스 이동 누적 전에 무시할 최소 이동량(px). 미세抖动 노이즈 제거. */ export const MOUSE_MOVE_NOISE_FLOOR_PX = 2 @@ -37,16 +47,24 @@ export function manhattanDistance(from: { x: number; y: number }, to: { x: numbe /** * 오버레이 커 좌표 계산. * - * 앵커(케어렛/포커스 요소) 아래에 붙이는 것이 기본 — IME 후보창과 같은 관례다. - * 아래 공간이 없으면 위로 뒤집고, 마지막으로 작업영역 안으로 클램프한다. + * 케어렛 앵커(또는 앵커 없음 → 커서)는 앵커 아래에 붙이는 것이 기본 — IME 후보창과 + * 같은 관례다. 아래 공간이 없으면 위로 뒤집는다. + * 요소 앵커(케어렛을 못 얻어 elementRect 로 폴백한 경우)는 요소 "바깥" 에 붙인다 — + * 아래/위/오른쪽/왼쪽 순으로 작업영역에 들어맞는 첫 방향을 쓴다. + * 마지막으로 항상 작업영역 안으로 클램프한다. */ export function anchorFloatingPanel( anchor: UiRect | null, + anchorKind: AnchorKind, cursor: { x: number; y: number }, size: { width: number; height: number }, workArea: UiRect, gap = 6 ): { x: number; y: number } { + if (anchorKind === 'element' && anchor && anchor.width >= 0 && anchor.height >= 0) { + return anchorOutsideElement(anchor, size, workArea, gap) + } + const rect: UiRect = anchor && anchor.width >= 0 && anchor.height >= 0 ? anchor @@ -64,6 +82,56 @@ export function anchorFloatingPanel( } } +function fitsInWorkArea( + x: number, + y: number, + size: { width: number; height: number }, + workArea: UiRect +): boolean { + return ( + x >= workArea.x && + y >= workArea.y && + x + size.width <= workArea.x + workArea.width && + y + size.height <= workArea.y + workArea.height + ) +} + +/** + * 요소 앵커를 요소 "바깥" 에 배치한다. + * + * 케어렛을 못 얻어 elementRect(입력창 전체)로 폴백했을 때, 기존 "앵커 아래/위" 규칙을 + * 그대로 쓰면 큰/여러 줄 요소에서는 텍스트와 멀리 떨어지거나 텍스트 위에 겹친다 + * (실측: 2·3번째 생성에서 패널이 튐). 아래→위→오른쪽→왼쪽 순으로 작업영역에 + * 맞는 첫 방향을 쓰고, 전부 안 맞으면 요소 안쪽 우하단 모서리로 물러난다. + */ +function anchorOutsideElement( + element: UiRect, + size: { width: number; height: number }, + workArea: UiRect, + gap: number +): { x: number; y: number } { + const candidates: Array<{ x: number; y: number }> = [ + { x: element.x, y: element.y + element.height + gap }, // 아래 + { x: element.x, y: element.y - gap - size.height }, // 위 + { x: element.x + element.width + gap, y: element.y }, // 오른쪽 + { x: element.x - gap - size.width, y: element.y } // 왼쪽 + ] + + for (const candidate of candidates) { + if (fitsInWorkArea(candidate.x, candidate.y, size, workArea)) return candidate + } + + const fallback = { + x: element.x + element.width - size.width - gap, + y: element.y + element.height - size.height - gap + } + + return { + x: clamp(fallback.x, workArea.x, workArea.x + workArea.width - size.width), + y: clamp(fallback.y, workArea.y, workArea.y + workArea.height - size.height) + } +} + function clamp(value: number, min: number, max: number): number { if (max < min) return min return Math.max(min, Math.min(value, max)) @@ -401,6 +469,8 @@ export type SuggestionSkipReason = | 'unchanged' /** 연속 실패 후 쿨다운 중 (모델이 다른 작업으로 바쁠 수 있다) */ | 'cooldown' + /** 포커스만 옮겨 왔을 뿐 이 필드에서 실제로 타이핑하지 않았다 (마우스 클릭 등) */ + | 'not-typing' export type SuggestionDecision = | { action: 'request'; prefix: string } @@ -419,6 +489,10 @@ export interface SuggestionPolicyInput { isEditable: boolean appName: string | null excludedApps: readonly string[] + /** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 필드에 들어오기만 한 경우 false) */ + editedSinceFocus: boolean + /** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */ + typedRecently: boolean /** 어렛 앞 스트 */ prefix: string idleMs: number @@ -446,6 +520,14 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi if (input.appName && isAppExcluded(input.appName, input.excludedApps)) { return { action: 'clear', reason: 'excluded-app' } } + // 포커스만 옮겨 왔을 뿐(마우스 클릭 등) 이 필드에서 아무것도 치지 않았으면 제안하지 않는다. + // + // 유휴 판정이 키보드 기준이라, 필드에 이미 차 있던 텍스트로 클릭만 해도 (마지막 + // 키 입력이 오래전이라) idleMs 조건을 통과해 제안이 뜨던 문제(실측: YouTube 검색창 + // 클릭만 했는데 옛 검색어로 제안이 뜸). + if (!input.editedSinceFocus || !input.typedRecently) { + return { action: 'clear', reason: 'not-typing' } + } const prefix = input.prefix.replace(/\s+$/u, '') if (!prefix) return { action: 'clear', reason: 'empty-prefix' } @@ -481,6 +563,34 @@ export function decideSuggestion(input: SuggestionPolicyInput): SuggestionDecisi return { action: 'request', prefix } } +/** + * 생성 접두가 현재 접두의 연속 확장인지 판정한다. + * + * 마지막 글자는 아직 조합 중인 IME 음절일 수 있어, 그 한 글자를 뺀 접두까지도 + * 연속 확장으로 인정한다 (예: 생성 시점 "하" → 현재 "한"). + */ +export function extendsPrefix(generatedPrefix: string, currentPrefix: string): boolean { + const generated = generatedPrefix.replace(/\s+$/u, '') + const current = currentPrefix.replace(/\s+$/u, '') + if (current.startsWith(generated)) return true + return generated.length > 0 && current.startsWith(generated.slice(0, -1)) +} + +/** + * 표시 중인 제안 세션(페이지 넘기며 보는 후보 목록)이 여전히 이 접두에 유효한지. + * + * `extendsPrefix` 와 달리 성장(이어 치기)은 허용하지 않는다 — "다음 문장이 시작됐다" + * 는 곧 세션 종료다(설계). 마지막 글자만 IME 조합으로 바뀐 경우만 예외로 둔다 + * (생성 시점 "하" → 지금 "한": 길이는 같고 마지막 글자만 다르다). + */ +export function matchesSessionPrefix(generatedPrefix: string, currentPrefix: string): boolean { + const generated = generatedPrefix.replace(/\s+$/u, '') + const current = currentPrefix.replace(/\s+$/u, '') + if (current === generated) return true + if (generated.length === 0) return false + return current.length === generated.length && current.slice(0, -1) === generated.slice(0, -1) +} + /** * 표시 중인 제안을 계속 둘지, 새로 만들지, 즉시 버릴지 결정한다. * @@ -496,7 +606,7 @@ export function decideSuggestionRefresh( const generated = generatedPrefix.replace(/\s+$/u, '') const current = currentPrefix.replace(/\s+$/u, '') if (!generated) return 'regenerate' - if (!current.startsWith(generated)) return 'stale' + if (!extendsPrefix(generated, current)) return 'stale' return current.length - generated.length >= minimumGrowth ? 'regenerate' : 'keep' } @@ -513,6 +623,84 @@ export function isAppExcluded(appName: string, excludedApps: readonly string[]): return false } +/** + * 터미널 — 화면 버퍼가 곧 "입력창" 으로 읽혀 상태줄·명령·출력이 친 글로 잡힌다 + * (실측: Claude Code 상태줄 `◑ OPUS 5`, `5 medium │ CTX ▕░░▏` 가 학습됨). + * 제안도 셸 프롬프트 위에 뜨므로 여기서는 제안과 학습을 모두 하지 않는다. + */ +export const TERMINAL_APPS: readonly string[] = Object.freeze([ + 'WindowsTerminal', + 'wt', + 'OpenConsole', + 'conhost', + 'cmd', + 'powershell', + 'pwsh', + 'mintty', + 'alacritty', + 'wezterm-gui', + 'Hyper', + 'Tabby', + 'Warp' +]) + +/** + * 학습에서 빼는 앱 — 터미널 + 코드 에디터 + 코딩 에이전트 허브. + * + * 개인 문구 코퍼스는 사용자의 자연어 문체를 배우는 곳이다. 코드와 에이전트에게 + * 보낸 개발 지시가 섞이면 카카오톡에서도 개발 문장이 제안된다(실측: 코퍼스 138개 중 + * 대부분이 터미널·Agent Switchboard 발). 제안 자체는 에디터/에이전트에서도 허용한다. + */ +export const LEARNING_EXCLUDED_APPS: readonly string[] = Object.freeze([ + ...TERMINAL_APPS, + 'Code', + 'Code - Insiders', + 'Cursor', + 'Windsurf', + 'Antigravity', + 'Zed', + 'devenv', + 'idea64', + 'pycharm64', + 'webstorm64', + 'rider64', + 'clion64', + 'goland64', + 'studio64', + 'sublime_text', + 'Agent Switchboard' +]) + +/** 상자·블록·도형·기타 기호·딩뱃 — 터미널 UI/상태줄의 지문이다. */ +const NON_PROSE_GLYPH_PATTERN = /[←-⇿─-➿⬀-⯿]/u +/** 공백을 뺀 글자 중 문자(모든 언어)가 이 비율 이상이어야 문장으로 본다. */ +const MIN_LETTER_RATIO = 0.6 + +/** + * 개인 코퍼스에 넣어도 되는 문장인가. + * + * 문장이 아닌 것(타임스탬프 `5 분 5`, 상태줄 `00 ◷9`, 박스 선)을 거른다. + * 앱 단위 제외(LEARNING_EXCLUDED_APPS)와 별개로 모든 출처에 적용한다. + */ +export function isLearnablePhrase(text: string): boolean { + if (NON_PROSE_GLYPH_PATTERN.test(text)) return false + const compact = text.replace(/\s+/gu, '') + if (compact.length < 2) return false + const letters = compact.match(/\p{L}/gu)?.length ?? 0 + return letters / compact.length >= MIN_LETTER_RATIO +} + +/** + * 빈 입력창의 안내 문구(placeholder)를 텍스트로 돌려주는 제공자가 있다 + * (실측: KakaoTalk "메시지 입력", ChatGPT "ChatGPT에 메시지 보내기" 가 친 글로 학습됨). + * 텍스트가 컨트롤 이름과 같으면 빈 칸으로 취급한다. + */ +export function withoutPlaceholderText(snapshot: FocusSnapshot): FocusSnapshot { + const name = snapshot.controlName?.trim() + if (!name || snapshot.text.trim() !== name) return snapshot + return { ...snapshot, text: '', caretOffset: null } +} + /** * 프롬프트에 넣을 컨텍스트 길이 상한. * @@ -986,8 +1174,16 @@ export interface SuggestionState { partialText: string | null candidates: SuggestionCandidate[] activeIndex: number + /** + * 이 세션이 채우려는 후보 총량 — 모델 세션은 maxCandidatesTotal(12), + * 로컬 기억 세션은 더 생성되지 않으므로 현재 candidates 수와 같다. + * UI 가 "4–6 / 9" 같은 진행률을 보여주는 근거. + */ + targetTotal: number /** 앵커 rect (케어렛 → 요소 → 마우스 폴백은 메인이 계산) */ anchor: UiRect | null + /** 앵커가 케어렛인지 요소 전체인지 — 배치 전략(caret vs element)을 결정한다 */ + anchorKind: AnchorKind appName: string | null updatedAt: number lastSkipReason: SuggestionSkipReason | null @@ -1031,7 +1227,16 @@ export const SUGGESTION_DEFAULTS = { minIntervalMs: 5000, maxRequestsPerMinute: 6, dailyBudget: 500, + /** 로컬 기억 경로(폴백)에서 한 번에 만드는 후보 수 — 채우기 루프가 없다. */ maxCandidates: 3, + /** + * 한 세션(모델 경로)이 채우기 루프로 쌓을 수 있는 후보 총량. + * + * 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다. + */ + maxCandidatesTotal: 12, + /** 오버레이 한 페이지에 보여줄 후보 수. */ + pageSize: 3, maxOutputTokens: 64, /** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */ regenerateAfterChars: 12, @@ -1066,7 +1271,15 @@ export const SUGGESTION_DEFAULTS = { /** 연속 실패가 이 횟수에 도달하면 잠시 요청을 멈춘다 */ failureCooldownThreshold: 2, /** 쿨다운 시간 (ms) */ - failureCooldownMs: 60000 + failureCooldownMs: 60000, + /** + * 이 시간 안에 실제 타이핑(letter/digit/symbol/space/backspace/delete/ime)이 + * 있어야 "지금 타이핑 중" 으로 본다. + * + * 마우스로 필드에 들어오기만 해도 (마지막 키 입력은 오래전이라) 유휴 조건을 + * 통과해 필드에 이미 있던 텍스트로 제안이 뜨던 문제를 막는다. + */ + recentTypingWindowMs: 8000 } as const /** diff --git a/packages/core/src/ipc-channels.ts b/packages/core/src/ipc-channels.ts index 391030f..19b6907 100644 --- a/packages/core/src/ipc-channels.ts +++ b/packages/core/src/ipc-channels.ts @@ -472,6 +472,11 @@ export const IPC_CHANNELS = { // ── Popup Internal Channels (Caption) ── POPUP_CAPTION: { HIDE: 'caption:hide', + /** 손잡이 위에 있는 동안만 마우스를 받는다 (그 외에는 클릭 통과) */ + SET_INTERACTIVE: 'captionPopup:setInteractive', + DRAG_START: 'captionPopup:dragStart', + DRAG_END: 'captionPopup:dragEnd', + RESET_POSITION: 'captionPopup:resetPosition', }, // ── Voice Partial Transcript (RecordingTip) ── diff --git a/packages/core/src/keybinding.ts b/packages/core/src/keybinding.ts index b4fd247..d5c9660 100644 --- a/packages/core/src/keybinding.ts +++ b/packages/core/src/keybinding.ts @@ -681,6 +681,8 @@ export type KeyBindingActionId = | 'suggestion-next' | 'suggestion-prev' | 'suggestion-dismiss' + | 'suggestion-page-next' + | 'suggestion-page-prev' /** 액션 그룹 (설정 화면 섹션) */ export type KeyBindingActionGroup = 'voice' | 'window' | 'input' @@ -700,7 +702,8 @@ export interface KeyBindingActionSpec { defaultBindings: readonly KeyBinding[] } -function kb( +/** 키보드 바인딩을 간결하게 만든다. 기본값(옛 기본값 등)을 구성할 때 이 모듈 밖에서도 쓴다. */ +export function kb( code: number, mods: Partial> = {} ): KeyBinding { @@ -783,9 +786,9 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze descriptionKey: 'keybinding.action.suggestionAccept.desc', holdMode: false, doublePress: false, - // 사용자 요청으로 Ctrl+Alt+화살표 계열로 통일했다: - // 오른쪽 수락 / 아래 다음 후보 / 위 이전 후보 / 왼쪽 닫기. - defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })] + // Ctrl+Alt+화살표 계열: 아래/위로 후보 순환, 좌우로 페이지 이동(12개까지 + // 순차 생성 — 사용자 요청). 화살표를 페이지 이동에 내주기 위해 수락은 Enter로 옮겼다. + defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })] }, { id: 'suggestion-next', @@ -805,6 +808,24 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze doublePress: false, defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })] }, + { + id: 'suggestion-page-next', + group: 'input', + labelKey: 'keybinding.action.suggestionPageNext', + descriptionKey: 'keybinding.action.suggestionPageNext.desc', + holdMode: false, + doublePress: false, + defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })] + }, + { + id: 'suggestion-page-prev', + group: 'input', + labelKey: 'keybinding.action.suggestionPagePrev', + descriptionKey: 'keybinding.action.suggestionPagePrev.desc', + holdMode: false, + doublePress: false, + defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })] + }, { id: 'suggestion-dismiss', group: 'input', @@ -812,7 +833,11 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze descriptionKey: 'keybinding.action.suggestionDismiss.desc', holdMode: false, doublePress: false, - defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })] + // "모든 액션은 기본 바인딩을 하나 이상 갖는다" 가 카탈로그 불변식이라 + // (KEYBINDING_ACTIONS 불변식 테스트) 빈 배열을 기본값으로 두지 않는다. + // 평범한 Esc(전역, 수정자 없음)가 오버레이가 떠 있을 때만 반응하는 + // 별도 경로로 항상 닫아 주므로, 이 바인딩은 보조 수단이다. + defaultBindings: [kb(VK.Backspace, { ctrl: true, alt: true })] } ]) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 43def44..8e6098b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -495,6 +495,8 @@ export interface AppConfig { activeChainId: string | null /** Phase 10.1: Caption audio source (CaptionService, MeetingModeService) */ captionAudioSource: import('@d3ro/core/types').CaptionAudioSource + /** 사용자가 끌어다 놓은 자막 창 위치 (스크린 좌표). null 이면 화면 아래 가운데 */ + captionOverlayPosition: { x: number; y: number } | null /** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */ updateChannel: 'latest' | 'beta' | 'alpha' /** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */ diff --git a/packages/i18n/package.json b/packages/i18n/package.json index ba0587e..66610d0 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/i18n", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "license": "MIT", diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index 4919934..759d078 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Übernehmen", "popup.suggestion.hintNext": "Weiter", "popup.suggestion.hintDismiss": "Schließen", + "popup.suggestion.hintMove": "Bewegen", + "popup.suggestion.hintPage": "Seite", "popup.suggestion.loading": "Wird erzeugt…", "keybinding.ui.sectionInput": "Eingabevorschläge", "keybinding.action.suggestionAccept": "Vorschlag übernehmen", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Modell wird vorbereitet…", "popup.suggestion.hintGenerating": "Wird erzeugt…", + "popup.suggestion.hintGeneratingMore": "Mehr wird erzeugt… (bis zu {{max}})", "keybinding.action.suggestionPrev": "Vorheriger Vorschlag", "keybinding.action.suggestionPrev.desc": "Zum vorherigen Kandidaten wechseln.", + "keybinding.action.suggestionPageNext": "Nächste Seite", + "keybinding.action.suggestionPageNext.desc": "Zeigt die nächste Seite der Vorschläge (bis zu 12 insgesamt).", + "keybinding.action.suggestionPagePrev": "Vorherige Seite", + "keybinding.action.suggestionPagePrev.desc": "Zeigt die vorherige Seite der Vorschläge.", "input.insights.tabs.graph": "Graph", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Satz-Knoten", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "Der lokale Datenbeleg ist derzeit nicht verfügbar. Mengen und Aufbewahrung werden nicht angezeigt.", "input.feedback.clearFailed": "Gesammelte Daten konnten nicht gelöscht werden.", "input.feedback.excludedFailed": "Ausschlüsse konnten nicht gespeichert werden.", - "input.feedback.recommendationFailed": "{{app}} konnte nicht zu Ausschlüssen hinzugefügt werden." + "input.feedback.recommendationFailed": "{{app}} konnte nicht zu Ausschlüssen hinzugefügt werden.", + "popup.caption.loading": "Sprachmodell wird vorbereitet…", + "popup.caption.waiting": "Hört zu… der erste Untertitel kann einige Sekunden dauern", + "popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück" } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index d6cd6fb..b730282 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -1218,7 +1218,7 @@ "popup.time.daysAgo": "{{d}}d ago", "popup.copy": "Copy", "popup.error.default": "An error occurred", - "popup.caption.loading": "⏳ Loading STT model...", + "popup.caption.loading": "Preparing the speech model…", "mobile.paywall.title": "D3RO PRO & Rewards", "mobile.paywall.close": "Close billing screen", "mobile.paywall.currentPlan": "Current plan", @@ -1756,6 +1756,8 @@ "popup.suggestion.hintAccept": "Accept", "popup.suggestion.hintNext": "Next", "popup.suggestion.hintDismiss": "Dismiss", + "popup.suggestion.hintMove": "Move", + "popup.suggestion.hintPage": "Page", "popup.suggestion.loading": "Generating…", "keybinding.ui.sectionInput": "Input suggestions", "keybinding.action.suggestionAccept": "Accept suggestion", @@ -1831,8 +1833,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Preparing model…", "popup.suggestion.hintGenerating": "Generating…", + "popup.suggestion.hintGeneratingMore": "More coming… (up to {{max}})", "keybinding.action.suggestionPrev": "Previous suggestion", "keybinding.action.suggestionPrev.desc": "Move to the previous suggestion candidate.", + "keybinding.action.suggestionPageNext": "Next page", + "keybinding.action.suggestionPageNext.desc": "Show the next page of suggestions (up to 12 total).", + "keybinding.action.suggestionPagePrev": "Previous page", + "keybinding.action.suggestionPagePrev.desc": "Show the previous page of suggestions.", "input.insights.tabs.graph": "Graph", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Sentence nodes", @@ -1894,5 +1901,7 @@ "input.privacy.unavailable": "The local data receipt is unavailable right now. Counts and retention are not shown.", "input.feedback.clearFailed": "Collected data could not be deleted.", "input.feedback.excludedFailed": "Exclusions could not be saved.", - "input.feedback.recommendationFailed": "Could not add {{app}} to exclusions." + "input.feedback.recommendationFailed": "Could not add {{app}} to exclusions.", + "popup.caption.waiting": "Listening… the first caption can take a few seconds", + "popup.caption.dragHint": "Drag to move · double-click to reset" } diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index c0f7dcd..47ccb98 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Aceptar", "popup.suggestion.hintNext": "Siguiente", "popup.suggestion.hintDismiss": "Descartar", + "popup.suggestion.hintMove": "Mover", + "popup.suggestion.hintPage": "Página", "popup.suggestion.loading": "Generando…", "keybinding.ui.sectionInput": "Sugerencias de entrada", "keybinding.action.suggestionAccept": "Aceptar sugerencia", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Preparando el modelo…", "popup.suggestion.hintGenerating": "Generando…", + "popup.suggestion.hintGeneratingMore": "Generando más… (hasta {{max}})", "keybinding.action.suggestionPrev": "Sugerencia anterior", "keybinding.action.suggestionPrev.desc": "Pasa al candidato anterior.", + "keybinding.action.suggestionPageNext": "Página siguiente", + "keybinding.action.suggestionPageNext.desc": "Muestra la siguiente página de sugerencias (hasta 12 en total).", + "keybinding.action.suggestionPagePrev": "Página anterior", + "keybinding.action.suggestionPagePrev.desc": "Muestra la página anterior de sugerencias.", "input.insights.tabs.graph": "Grafo", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nodos de frase", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "El recibo de datos locales no está disponible ahora. No se muestran cantidades ni retención.", "input.feedback.clearFailed": "No se pudieron eliminar los datos recopilados.", "input.feedback.excludedFailed": "No se pudieron guardar las exclusiones.", - "input.feedback.recommendationFailed": "No se pudo añadir {{app}} a las exclusiones." + "input.feedback.recommendationFailed": "No se pudo añadir {{app}} a las exclusiones.", + "popup.caption.loading": "Preparando el modelo de voz…", + "popup.caption.waiting": "Escuchando… el primer subtítulo puede tardar unos segundos", + "popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer" } diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index e661fe4..2e4ca75 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Accepter", "popup.suggestion.hintNext": "Suivant", "popup.suggestion.hintDismiss": "Fermer", + "popup.suggestion.hintMove": "Déplacer", + "popup.suggestion.hintPage": "Page", "popup.suggestion.loading": "Génération…", "keybinding.ui.sectionInput": "Suggestions de saisie", "keybinding.action.suggestionAccept": "Accepter la suggestion", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Préparation du modèle…", "popup.suggestion.hintGenerating": "Génération…", + "popup.suggestion.hintGeneratingMore": "Génération en cours… (jusqu'à {{max}})", "keybinding.action.suggestionPrev": "Suggestion précédente", "keybinding.action.suggestionPrev.desc": "Passe au candidat précédent.", + "keybinding.action.suggestionPageNext": "Page suivante", + "keybinding.action.suggestionPageNext.desc": "Affiche la page suivante de suggestions (jusqu'à 12 au total).", + "keybinding.action.suggestionPagePrev": "Page précédente", + "keybinding.action.suggestionPagePrev.desc": "Affiche la page précédente de suggestions.", "input.insights.tabs.graph": "Graphe", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nœuds de phrase", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "Le reçu de données locales est indisponible. Les quantités et durées de conservation ne sont pas affichées.", "input.feedback.clearFailed": "Les données collectées n’ont pas pu être supprimées.", "input.feedback.excludedFailed": "Les exclusions n’ont pas pu être enregistrées.", - "input.feedback.recommendationFailed": "Impossible d’ajouter {{app}} aux exclusions." + "input.feedback.recommendationFailed": "Impossible d’ajouter {{app}} aux exclusions.", + "popup.caption.loading": "Préparation du modèle vocal…", + "popup.caption.waiting": "Écoute… le premier sous-titre peut prendre quelques secondes", + "popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser" } diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index 2bca975..1dcd8fa 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "承認", "popup.suggestion.hintNext": "次へ", "popup.suggestion.hintDismiss": "閉じる", + "popup.suggestion.hintMove": "移動", + "popup.suggestion.hintPage": "ページ", "popup.suggestion.loading": "生成中…", "keybinding.ui.sectionInput": "入力サジェスト", "keybinding.action.suggestionAccept": "サジェストを承認", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "モデルを準備中…", "popup.suggestion.hintGenerating": "生成中…", + "popup.suggestion.hintGeneratingMore": "さらに生成中…(最大{{max}}件)", "keybinding.action.suggestionPrev": "前の候補", "keybinding.action.suggestionPrev.desc": "前の候補に移動します。", + "keybinding.action.suggestionPageNext": "次のページ", + "keybinding.action.suggestionPageNext.desc": "次の提案ページを表示します(最大12件まで)。", + "keybinding.action.suggestionPagePrev": "前のページ", + "keybinding.action.suggestionPagePrev.desc": "前の提案ページに戻ります。", "input.insights.tabs.graph": "グラフ", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "文ノード", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "ローカルデータの明細は現在利用できません。件数と保持期間は表示されません。", "input.feedback.clearFailed": "収集したデータを削除できませんでした。", "input.feedback.excludedFailed": "除外設定を保存できませんでした。", - "input.feedback.recommendationFailed": "{{app}} を除外に追加できませんでした。" + "input.feedback.recommendationFailed": "{{app}} を除外に追加できませんでした。", + "popup.caption.loading": "音声モデルを準備中…", + "popup.caption.waiting": "聞き取り中… 最初の字幕まで数秒かかることがあります", + "popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置" } diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index e3b9969..fe9a9b2 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -1225,7 +1225,7 @@ "popup.time.daysAgo": "{{d}}일 전", "popup.copy": "복사", "popup.error.default": "오류가 발생했습니다", - "popup.caption.loading": "⏳ STT 모델 로딩 중...", + "popup.caption.loading": "음성 모델 준비 중…", "mobile.paywall.title": "D3RO PRO 및 리워드", "mobile.paywall.close": "결제 화면 닫기", "mobile.paywall.currentPlan": "현재 요금제", @@ -1763,6 +1763,8 @@ "popup.suggestion.hintAccept": "수락", "popup.suggestion.hintNext": "다음", "popup.suggestion.hintDismiss": "닫기", + "popup.suggestion.hintMove": "이동", + "popup.suggestion.hintPage": "페이지", "popup.suggestion.loading": "생성 중…", "keybinding.ui.sectionInput": "입력 제안", "keybinding.action.suggestionAccept": "제안 수락", @@ -1838,8 +1840,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "모델 준비 중…", "popup.suggestion.hintGenerating": "생성 중…", + "popup.suggestion.hintGeneratingMore": "더 생성 중… (최대 {{max}}개)", "keybinding.action.suggestionPrev": "이전 제안", "keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.", + "keybinding.action.suggestionPageNext": "다음 페이지", + "keybinding.action.suggestionPageNext.desc": "다음 제안 페이지를 봅니다 (최대 12개까지).", + "keybinding.action.suggestionPagePrev": "이전 페이지", + "keybinding.action.suggestionPagePrev.desc": "이전 제안 페이지로 이동합니다.", "input.insights.tabs.graph": "그래프", "input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.", "input.graph.nodes": "문장 노드", @@ -1901,5 +1908,7 @@ "input.privacy.unavailable": "지금은 로컬 데이터 영수증을 불러올 수 없습니다. 수량과 보존 기간은 표시하지 않습니다.", "input.feedback.clearFailed": "수집된 데이터를 삭제하지 못했습니다.", "input.feedback.excludedFailed": "제외 목록을 저장하지 못했습니다.", - "input.feedback.recommendationFailed": "{{app}}을(를) 제외 목록에 추가하지 못했습니다." + "input.feedback.recommendationFailed": "{{app}}을(를) 제외 목록에 추가하지 못했습니다.", + "popup.caption.waiting": "듣는 중… 첫 자막까지 몇 초 걸릴 수 있어요", + "popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치" } diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index d04be7c..6331de7 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Aceitar", "popup.suggestion.hintNext": "Próxima", "popup.suggestion.hintDismiss": "Descartar", + "popup.suggestion.hintMove": "Mover", + "popup.suggestion.hintPage": "Página", "popup.suggestion.loading": "Gerando…", "keybinding.ui.sectionInput": "Sugestões de entrada", "keybinding.action.suggestionAccept": "Aceitar sugestão", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Preparando o modelo…", "popup.suggestion.hintGenerating": "Gerando…", + "popup.suggestion.hintGeneratingMore": "Gerando mais… (até {{max}})", "keybinding.action.suggestionPrev": "Sugestão anterior", "keybinding.action.suggestionPrev.desc": "Vai para o candidato anterior.", + "keybinding.action.suggestionPageNext": "Próxima página", + "keybinding.action.suggestionPageNext.desc": "Mostra a próxima página de sugestões (até 12 no total).", + "keybinding.action.suggestionPagePrev": "Página anterior", + "keybinding.action.suggestionPagePrev.desc": "Mostra a página anterior de sugestões.", "input.insights.tabs.graph": "Grafo", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nós de frase", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "O recibo de dados locais está indisponível agora. Quantidades e retenção não são exibidas.", "input.feedback.clearFailed": "Não foi possível excluir os dados coletados.", "input.feedback.excludedFailed": "Não foi possível salvar as exclusões.", - "input.feedback.recommendationFailed": "Não foi possível adicionar {{app}} às exclusões." + "input.feedback.recommendationFailed": "Não foi possível adicionar {{app}} às exclusões.", + "popup.caption.loading": "Preparando o modelo de voz…", + "popup.caption.waiting": "Ouvindo… a primeira legenda pode levar alguns segundos", + "popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar" } diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 368da4b..4407497 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Принять", "popup.suggestion.hintNext": "Далее", "popup.suggestion.hintDismiss": "Закрыть", + "popup.suggestion.hintMove": "Перемещение", + "popup.suggestion.hintPage": "Страница", "popup.suggestion.loading": "Генерация…", "keybinding.ui.sectionInput": "Подсказки ввода", "keybinding.action.suggestionAccept": "Принять подсказку", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Подготовка модели…", "popup.suggestion.hintGenerating": "Генерация…", + "popup.suggestion.hintGeneratingMore": "Создаётся ещё… (до {{max}})", "keybinding.action.suggestionPrev": "Предыдущая подсказка", "keybinding.action.suggestionPrev.desc": "Перейти к предыдущему варианту.", + "keybinding.action.suggestionPageNext": "Следующая страница", + "keybinding.action.suggestionPageNext.desc": "Показывает следующую страницу подсказок (до 12 всего).", + "keybinding.action.suggestionPagePrev": "Предыдущая страница", + "keybinding.action.suggestionPagePrev.desc": "Показывает предыдущую страницу подсказок.", "input.insights.tabs.graph": "Граф", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Узлы-предложения", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "Квитанция локальных данных сейчас недоступна. Количества и сроки хранения не показаны.", "input.feedback.clearFailed": "Не удалось удалить собранные данные.", "input.feedback.excludedFailed": "Не удалось сохранить исключения.", - "input.feedback.recommendationFailed": "Не удалось добавить {{app}} в исключения." + "input.feedback.recommendationFailed": "Не удалось добавить {{app}} в исключения.", + "popup.caption.loading": "Подготовка речевой модели…", + "popup.caption.waiting": "Слушаю… первый субтитр может появиться через несколько секунд", + "popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс" } diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index bd467dc..0ac1e89 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "ยอมรับ", "popup.suggestion.hintNext": "ถัดไป", "popup.suggestion.hintDismiss": "ปิด", + "popup.suggestion.hintMove": "ย้าย", + "popup.suggestion.hintPage": "หน้า", "popup.suggestion.loading": "กำลังสร้าง…", "keybinding.ui.sectionInput": "คำแนะนำการป้อน", "keybinding.action.suggestionAccept": "ยอมรับคำแนะนำ", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "กำลังเตรียมโมเดล…", "popup.suggestion.hintGenerating": "กำลังสร้าง…", + "popup.suggestion.hintGeneratingMore": "กำลังสร้างเพิ่ม… (สูงสุด {{max}})", "keybinding.action.suggestionPrev": "คำแนะนำก่อนหน้า", "keybinding.action.suggestionPrev.desc": "ย้ายไปยังตัวเลือกก่อนหน้า", + "keybinding.action.suggestionPageNext": "หน้าถัดไป", + "keybinding.action.suggestionPageNext.desc": "แสดงคำแนะนำหน้าถัดไป (สูงสุด 12 รายการ)", + "keybinding.action.suggestionPagePrev": "หน้าก่อนหน้า", + "keybinding.action.suggestionPagePrev.desc": "แสดงคำแนะนำหน้าก่อนหน้า", "input.insights.tabs.graph": "กราฟ", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "โหนดประโยค", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "ใบรับข้อมูลในเครื่องไม่พร้อมใช้งานขณะนี้ จึงไม่แสดงจำนวนและระยะเวลาเก็บข้อมูล", "input.feedback.clearFailed": "ไม่สามารถลบข้อมูลที่เก็บรวบรวมได้", "input.feedback.excludedFailed": "ไม่สามารถบันทึกรายการยกเว้นได้", - "input.feedback.recommendationFailed": "ไม่สามารถเพิ่ม {{app}} ในรายการยกเว้นได้" + "input.feedback.recommendationFailed": "ไม่สามารถเพิ่ม {{app}} ในรายการยกเว้นได้", + "popup.caption.loading": "กำลังเตรียมโมเดลเสียง…", + "popup.caption.waiting": "กำลังฟัง… คำบรรยายแรกอาจใช้เวลาสักครู่", + "popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต" } diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index ca2cd96..3cb839f 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "Chấp nhận", "popup.suggestion.hintNext": "Tiếp", "popup.suggestion.hintDismiss": "Đóng", + "popup.suggestion.hintMove": "Di chuyển", + "popup.suggestion.hintPage": "Trang", "popup.suggestion.loading": "Đang tạo…", "keybinding.ui.sectionInput": "Gợi ý nhập liệu", "keybinding.action.suggestionAccept": "Chấp nhận gợi ý", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "Đang chuẩn bị mô hình…", "popup.suggestion.hintGenerating": "Đang tạo…", + "popup.suggestion.hintGeneratingMore": "Đang tạo thêm… (tối đa {{max}})", "keybinding.action.suggestionPrev": "Gợi ý trước", "keybinding.action.suggestionPrev.desc": "Chuyển về ứng viên trước đó.", + "keybinding.action.suggestionPageNext": "Trang tiếp theo", + "keybinding.action.suggestionPageNext.desc": "Hiển thị trang gợi ý tiếp theo (tối đa 12 gợi ý).", + "keybinding.action.suggestionPagePrev": "Trang trước", + "keybinding.action.suggestionPagePrev.desc": "Hiển thị trang gợi ý trước đó.", "input.insights.tabs.graph": "Đồ thị", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nút câu", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "Biên nhận dữ liệu cục bộ hiện không khả dụng. Số lượng và thời hạn lưu giữ không được hiển thị.", "input.feedback.clearFailed": "Không thể xóa dữ liệu đã thu thập.", "input.feedback.excludedFailed": "Không thể lưu danh sách loại trừ.", - "input.feedback.recommendationFailed": "Không thể thêm {{app}} vào danh sách loại trừ." + "input.feedback.recommendationFailed": "Không thể thêm {{app}} vào danh sách loại trừ.", + "popup.caption.loading": "Đang chuẩn bị mô hình giọng nói…", + "popup.caption.waiting": "Đang nghe… phụ đề đầu tiên có thể mất vài giây", + "popup.caption.dragHint": "Kéo để di chuyển · nhấp đúp để đặt lại" } diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index a8e1cf6..8139c3c 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "接受", "popup.suggestion.hintNext": "下一個", "popup.suggestion.hintDismiss": "關閉", + "popup.suggestion.hintMove": "移動", + "popup.suggestion.hintPage": "翻頁", "popup.suggestion.loading": "產生中…", "keybinding.ui.sectionInput": "輸入建議", "keybinding.action.suggestionAccept": "接受建議", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "正在準備模型…", "popup.suggestion.hintGenerating": "產生中…", + "popup.suggestion.hintGeneratingMore": "正在產生更多…(最多 {{max}} 則)", "keybinding.action.suggestionPrev": "上一則建議", "keybinding.action.suggestionPrev.desc": "移動到上一則候選。", + "keybinding.action.suggestionPageNext": "下一頁", + "keybinding.action.suggestionPageNext.desc": "顯示下一頁建議(最多 12 則)。", + "keybinding.action.suggestionPagePrev": "上一頁", + "keybinding.action.suggestionPagePrev.desc": "顯示上一頁建議。", "input.insights.tabs.graph": "圖譜", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "句子節點", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "本機資料憑據目前無法使用,因此不顯示數量和保留期。", "input.feedback.clearFailed": "無法刪除已收集的資料。", "input.feedback.excludedFailed": "無法儲存排除清單。", - "input.feedback.recommendationFailed": "無法將 {{app}} 加入排除清單。" + "input.feedback.recommendationFailed": "無法將 {{app}} 加入排除清單。", + "popup.caption.loading": "正在準備語音模型…", + "popup.caption.waiting": "聆聽中… 第一則字幕可能需要幾秒鐘", + "popup.caption.dragHint": "拖曳以移動 · 按兩下還原" } diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index 909699d..2581963 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -374,6 +374,8 @@ "popup.suggestion.hintAccept": "接受", "popup.suggestion.hintNext": "下一个", "popup.suggestion.hintDismiss": "关闭", + "popup.suggestion.hintMove": "移动", + "popup.suggestion.hintPage": "翻页", "popup.suggestion.loading": "生成中…", "keybinding.ui.sectionInput": "输入建议", "keybinding.action.suggestionAccept": "接受建议", @@ -449,8 +451,13 @@ "input.unit.percent": "%", "popup.suggestion.warming": "正在准备模型…", "popup.suggestion.hintGenerating": "生成中…", + "popup.suggestion.hintGeneratingMore": "正在生成更多…(最多 {{max}} 条)", "keybinding.action.suggestionPrev": "上一条建议", "keybinding.action.suggestionPrev.desc": "移动到上一条候选。", + "keybinding.action.suggestionPageNext": "下一页", + "keybinding.action.suggestionPageNext.desc": "显示下一页建议(最多 12 条)。", + "keybinding.action.suggestionPagePrev": "上一页", + "keybinding.action.suggestionPagePrev.desc": "显示上一页建议。", "input.insights.tabs.graph": "图谱", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "句子节点", @@ -512,5 +519,8 @@ "input.privacy.unavailable": "本地数据凭据暂不可用,因此不显示数量和保留期。", "input.feedback.clearFailed": "无法删除已收集的数据。", "input.feedback.excludedFailed": "无法保存排除列表。", - "input.feedback.recommendationFailed": "无法将 {{app}} 添加到排除列表。" + "input.feedback.recommendationFailed": "无法将 {{app}} 添加到排除列表。", + "popup.caption.loading": "正在准备语音模型…", + "popup.caption.waiting": "正在聆听… 第一条字幕可能需要几秒钟", + "popup.caption.dragHint": "拖动以移动 · 双击复位" } diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json index 443c7ef..e4fc79b 100644 --- a/packages/ui-native/package.json +++ b/packages/ui-native/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui-native", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index fbd962c..f8aa3dd 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui", - "version": "1.5.0", + "version": "1.6.0", "private": true, "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "license": "MIT", diff --git a/release/product-version.json b/release/product-version.json index a1326a7..5e29973 100644 --- a/release/product-version.json +++ b/release/product-version.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "1.5.0", - "androidVersionCode": 1050000, - "iosBuildNumber": 1050000, - "releaseDate": "2026-09-23", + "version": "1.6.0", + "androidVersionCode": 1060000, + "iosBuildNumber": 1060000, + "releaseDate": "2026-09-24", "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" } diff --git a/site/package-lock.json b/site/package-lock.json index 7473a15..c77c387 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index 2b53e08..6154558 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.5.0", + "version": "1.6.0", "type": "module", "scripts": { "dev": "vite --port 5199 --host", diff --git a/site/src/release.ts b/site/src/release.ts index 3ca1ca6..de0396b 100644 --- a/site/src/release.ts +++ b/site/src/release.ts @@ -5,10 +5,10 @@ // NAS 배포는 바이너리를 포함하지 않으므로 `/releases/...` 같은 로컬 경로는 // 실제 배포 환경에서 404가 된다. -export const DESKTOP_VERSION = '1.5.0' +export const DESKTOP_VERSION = '1.6.0' /** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */ -export const DESKTOP_RELEASE_DATE = '2026-09-23' +export const DESKTOP_RELEASE_DATE = '2026-09-24' const FORGEJO_ORIGIN = 'https://git.chanpaca.net' const FORGEJO_OWNER = 'yunchan'