release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s

Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

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

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

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
Yun Chan 2026-09-24 19:56:28 +09:00
parent 856e375f3e
commit 2fe20fa7b5
71 changed files with 2469 additions and 366 deletions

View file

@ -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

View file

@ -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": [
{

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/admin",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": {

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>1.5.0</Version>
<Version>1.6.0</Version>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View file

@ -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",

View file

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

View file

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

View file

@ -2,12 +2,16 @@
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import {
bindingsEqual,
createDefaultBindingMap,
detectBindingConflicts,
findActionSpec,
kb,
normalizeBinding,
parseBindingMap
parseBindingMap,
VK
} from '@d3ro/core/keybinding'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from './LoggerService'
@ -15,7 +19,7 @@ import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
const SUGGESTION_TUNING_REVISION = 4
const SUGGESTION_TUNING_REVISION = 5
const INITIAL_SUGGESTION_TUNING = {
suggestionTriggerDelayMs: 300,
@ -55,12 +59,62 @@ function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
activeStore.set('suggestionMaxRequestsPerMinute', 6)
}
if (current < 5) migrateSuggestionOverlayBindings(activeStore)
activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION)
logger.info(
`Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}`
)
}
/** revision 2 가 심어 둔 옛 기본값 — 사용자가 정확히 이 값 그대로일 때만 옮긴다. */
const OLD_SUGGESTION_ACCEPT_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowRight, { ctrl: true, alt: true })]
const OLD_SUGGESTION_DISMISS_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]): boolean {
return a.length === b.length && a.every((binding, index) => bindingsEqual(binding, b[index]))
}
/**
* 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다.
*
* - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다.
* - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다).
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
* 절대 건드리지 않는다(설계 요구사항).
* - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른
* 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다.
*/
function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void {
const raw = activeStore.store as unknown as Record<string, unknown>
const bindings = parseBindingMap(raw.keyBindings)
const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): void => {
const existing = bindings[actionId] ?? []
if (!bindingListEquals(existing, oldDefault)) return
const spec = findActionSpec(actionId)
if (spec) bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding }))
}
migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT)
migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT)
for (const actionId of ['suggestion-page-next', 'suggestion-page-prev'] as const) {
const candidate = bindings[actionId] ?? []
const hasConflict = candidate.some(
(binding) => detectBindingConflicts(actionId, binding, bindings).length > 0
)
if (hasConflict) {
logger.warn(
`${actionId} 기본 바인딩이 다른 액션과 충돌해 바인딩 없이 둔다 (사용자가 설정에서 직접 지정할 수 있다)`
)
bindings[actionId] = []
}
}
activeStore.set('keyBindings', bindings)
}
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T> {
get<K extends keyof T>(key: K): T[K]
@ -131,6 +185,7 @@ const CONFIG_DEFAULTS: AppConfig = {
activeInstructionId: '',
activeChainId: null,
captionAudioSource: 'mic',
captionOverlayPosition: null,
updateChannel: 'latest',
updateDeviceId: '',
skippedUpdateVersion: null,
@ -148,7 +203,7 @@ const CONFIG_DEFAULTS: AppConfig = {
suggestionDailyBudget: 500,
suggestionOverlayInteractive: true,
suggestionRequestTimeoutMs: 8000,
suggestionTuningRevision: 4,
suggestionTuningRevision: 5,
}
let store: ElectronStore<AppConfig> | null = null

View file

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

View file

@ -208,8 +208,17 @@ class LocalLLMService extends EventEmitter {
logger.info(`Ollama not running, auto-starting from ${binaryPath}`)
try {
const child = spawn(binaryPath, ['serve'], {
detached: 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
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T extends Record<string, unknown>>(persisted: Partial<T>) {
return class TestStore {
store: T
constructor(options: { defaults: T }) {
this.store = { ...options.defaults, ...persisted } as T
}
get<K extends keyof T>(key: K): T[K] {
return this.store[key]
}
set<K extends keyof T>(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()
})
})

View file

@ -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<void> | 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<void>((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')
})
})

View file

@ -262,6 +262,7 @@ function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSugges
fullText: '오늘 회의 결과를',
caretOffset: 9,
anchor: { x: 1, y: 1, width: 1, height: 1 },
anchorKind: 'caret' as const,
isPassword: false,
isEditable: true,
isComposing: false,
@ -271,6 +272,8 @@ function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSugges
windowTitle: '회의록',
idleMs: 300,
capturedAt: now,
editedSinceFocus: true,
typedRecently: true,
...overrides
}
}
@ -466,14 +469,18 @@ describe('입력 플로우 서비스', () => {
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,

View file

@ -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('마우스 이동', () => {

View file

@ -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('이미 제안한 문장')
})
})

View file

@ -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)
})
})

View file

@ -75,7 +75,7 @@ describe('paths (packaged)', () => {
configurable: true,
})
expect(() => getSidecarCommand()).toThrowError(/사이드카를 찾을 수 없습니다/)
expect(() => getSidecarCommand()).toThrowError(/로컬 음성 엔진이 아직 설치되지 않았습니다/)
})
it('uses the packaged sidecar executable when present', () => {

View file

@ -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,

View file

@ -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",

View file

@ -0,0 +1 @@
Maintenance update. This release focuses on sentence suggestions and live captions in the desktop app; there are no mobile feature changes.

View file

@ -0,0 +1 @@
유지보수 업데이트입니다. 이번 변경은 데스크톱 앱의 문장 제안·실시간 자막에 집중되어 있으며, 모바일 앱의 기능 변경은 없습니다.

View file

@ -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": "*"

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/mobile-rn",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"scripts": {
"android": "react-native run-android",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/web",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": {

View file

@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
</PhosphorText>
</Box>
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
v1.5.0
v1.6.0
</Box>
</Box>

View file

@ -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'

View file

@ -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)

View file

@ -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 |

View file

@ -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. |

20
package-lock.json generated
View file

@ -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": "*"

View file

@ -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",

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/core",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",

View file

@ -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
/**

View file

@ -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) ──

View file

@ -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<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
): 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 })]
}
])

View file

@ -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용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */

View file

@ -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",

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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": "ドラッグで移動 · ダブルクリックで元の位置"
}

View file

@ -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": "끌어서 이동 · 더블클릭하면 원위치"
}

View file

@ -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"
}

View file

@ -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": "Перетащите, чтобы переместить · двойной щелчок — сброс"
}

View file

@ -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": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต"
}

View file

@ -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"
}

View file

@ -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": "拖曳以移動 · 按兩下還原"
}

View file

@ -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": "拖动以移动 · 双击复位"
}

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/ui",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT",

View file

@ -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"
}

View file

@ -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"

View file

@ -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",

View file

@ -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'