release: ship v1.5.0 with on-device writing suggestions
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s

Adds next-sentence suggestions while typing, weekly input insights and a
personal phrase memory to the desktop app, and fixes custom instructions so
they process the text instead of inserting the instruction's own wording.
Local model requests are now bounded and individually cancellable.

Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the
landing and web download links, and records the new INPUT feature rows and the
open verification gaps in the infrastructure map.
This commit is contained in:
Yun Chan 2026-09-23 16:04:27 +09:00
parent 99f06c253c
commit 5c11ee2fde
104 changed files with 14410 additions and 174 deletions

View file

@ -13,6 +13,69 @@ 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.5.0] - 2026-09-23
> Published from an annotated tag. Installer and update metadata are served by the
> canonical Forgejo feed; no binaries are committed to this repository.
### Added
- **Next-sentence suggestions while you type.** With the feature turned on, a small
ghost-text panel sits next to the caret in whatever app has focus and offers up to
three ways to continue the sentence. `Ctrl+Alt+Right` accepts, `Down`/`Up` move
between candidates, `Left` or the panel's X dismisses, and all four are ordinary
rebindable shortcuts. The panel never takes focus and is click-through until you
turn interaction on.
- **Typed-text learning from the field you are in,** read through UI Automation and
diffed as a snapshot, so committed Korean and Japanese IME text is counted
correctly (key codes cannot reconstruct it). Password fields are refused before any
read, and IME composition suppresses learning and suggestions until the character
is committed.
- **Weekly input insights** (`Settings > Input` and a dashboard card): keystroke,
click and scroll totals, mouse travel converted to meters, top hours and apps, flow
windows, edit friction (edits per 100 characters) and per-app suggestion quality.
- **A personal phrase memory** built from your own typed sentences and voice history,
ranked by frequency, recency (30-day half-life) and same-app usage, and offered as
prompt hints. Phrases can be deleted individually or all at once.
- **A privacy receipt** that reports the real number of retained rows and the 30-day
retention rule, plus **smart exclusion**: only the app you are in can be suggested
for the exclusion list, and only after repeated unreadable fields, never
automatically.
- **A shortcut safety audit** in Settings that flags invalid or conflicting bindings
across every action.
- **A local personal graph** that links your sentences by "follows" and shared-terms
relationships, so related context can surface even when the current prefix differs.
It is local SQLite only, with no embeddings or network.
### Changed
- **Local model requests are now bounded and individually cancellable.**
`LocalLLMService` gives every request its own abort controller, relays external
cancellation, requires a `done` frame before a stream counts as successful, and
clears incomplete streams. Generation and streaming are capped at 2048 tokens /
120 seconds and chat at 512 / 60 seconds. Text suggestions use `keep_alive: 2m` and
no longer warm the model at startup.
- **Voice conversations are single-flight** and pass their cancellation through to
the active local chat request, so ending a conversation aborts its own inference
instead of only changing what the UI shows.
- Console windows are suppressed for the app's Windows helper processes (text to
speech, voice actions, audio-device and active-window enumeration, and every ffmpeg
path).
### Fixed
- **Custom instructions ran the instruction instead of your text.** Every built-in
instruction (translate, summarize, rephrase, explain code, free prompt) inserted the
instruction's own wording rather than the processed result, and only `{{text}}` was
ever substituted, which none of the presets use. The instruction is now the system
prompt, the transcript is the text, and `{{text}}`, `{{userPrompt}}` and
`{{targetLanguage}}` are resolved in one shared place. This path had never worked in
any shipped release.
- **The suggestion panel could refuse to close, revive itself, or appear on a bare
click.** Holding the modifier that Windows turns into `Ctrl+Alt` no longer fires the
voice shortcut underneath, closing the panel during generation discards that request
instead of re-showing it, a click without typing no longer triggers a suggestion,
and pressing Enter stops the same text from being suggested again.
- **The global input hook is reference-counted**, so the shortcut service and input
telemetry can attach at the same time without one disabling the other.
## [1.4.0] - 2026-09-21
### Added

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.4.0"
"version": "1.5.0"
},
"servers": [
{

View file

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

View file

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

View file

@ -44,6 +44,9 @@ asarUnpack:
- "node_modules/better-sqlite3/**"
- "node_modules/uiohook-napi/**"
- "node_modules/@nut-tree-fork/**"
# koffi — 포그라운드 창 제목/프로세스 조회(User32 FFI). prebuilt 바이너리 포함.
- "node_modules/koffi/**"
- "node_modules/@koromix/**"
# ────────────────────────────────────────────────────────────────────
# Windows

View file

@ -67,6 +67,10 @@ export default defineConfig({
'popups/caption-overlay': resolve(
__dirname,
'src/renderer/popups/caption-overlay/index.html'
),
'popups/suggestion-overlay': resolve(
__dirname,
'src/renderer/popups/suggestion-overlay/index.html'
)
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/desktop",
"version": "1.4.0",
"version": "1.5.0",
"productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js",
@ -58,6 +58,7 @@
"electron-log": "^5.2.0",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"koffi": "^3.3.1",
"lucide-react": "^1.25.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",

View file

@ -83,6 +83,11 @@ const args = [
'tokenizers',
'--collect-all',
'huggingface_hub',
// UIA 브리지(입력 인텔리전스). comtypes 는 실행 시점에 타입 라이브러리를
// 동적으로 생성하므로 데이터 파일까지 통째로 모아야 한다 (Windows 전용).
...(process.platform === 'win32'
? ['--collect-all', 'uiautomation', '--collect-all', 'comtypes', '--hidden-import', 'comtypes.client']
: []),
// uvicorn은 동적 임포트를 사용하므로 명시
'--hidden-import',
'uvicorn.logging',

View file

@ -12,6 +12,7 @@ faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림
POST /download - 모델 다운로드 시작 (백그라운드)
GET /download/status - 다운로드 진행률 조회
POST /download/cancel - 다운로드 취소
GET /uia/focus - 포커스 입력 요소 UIA 스냅샷 (제안/학습용)
POST /shutdown - 서버 종료
주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며,
@ -21,6 +22,7 @@ pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공
from __future__ import annotations
import argparse
import asyncio
import fnmatch
import logging
import os
@ -582,11 +584,34 @@ async def download_cancel() -> JSONResponse:
return JSONResponse(content={"status": "idle"})
@app.get("/uia/focus")
async def uia_focus(timeoutMs: int = 1500) -> JSONResponse:
"""포커스된 입력 요소의 UIA 스냅샷 (텍스트/케어렛/비밀번호 여부).
제안(ghost text)과 이핑 학습이 쓰는 유일한 입력창 읽기 경로다.
비밀번호 필드는 브리지 안에서 fail-closed 로 차단한다.
"""
try:
import uia_bridge
except Exception as exc: # pragma: no cover - 파일 누락 등
return JSONResponse(content={"available": False, "reason": f"bridge-import:{exc}"})
snapshot = await asyncio.to_thread(uia_bridge.snapshot_focus, timeoutMs)
return JSONResponse(content=snapshot)
@app.post("/shutdown")
async def shutdown() -> JSONResponse:
"""서버를 graceful하게 종료한다."""
logger.info("종료 요청 수신")
try:
import uia_bridge
await asyncio.to_thread(uia_bridge.shutdown)
except Exception:
pass
if _server is not None:
_server.should_exit = True

View file

@ -5,3 +5,6 @@ python-multipart>=0.0.6
numpy>=1.24.0
huggingface_hub>=0.23.0
requests>=2.31.0
# UIA 브리지(/uia/focus) — 포커스 입력창 텍스트·케어렛 읽기. Windows 전용.
uiautomation>=2.0.29; sys_platform == "win32"
comtypes>=1.4.0; sys_platform == "win32"

View file

@ -0,0 +1,428 @@
"""UIA(UI Automation) 포커스 스냅샷 브리지.
목적: 사용자가 지금 어느 입력창에 무엇을 치고 있는지, 어렛이 어디 있는지를
읽어 "다음 문장 제안" 과 "타이핑 학습" 에 넘긴다.
설계 근거 (조사 기반, 뇌피셜 아님):
- Win32 `GetGUIThreadInfo` 의 `rcCaret` 은 Chromium/Electron/VS Code 처럼
커서를 직접 그리는 앱에서 아무것도 돌려주지 않는다 (AutoHotkey CaretGetPos 문서,
MS Learn "GetGUIThreadInfo" 의 rcCaret 주의 문구). 그래서 UIA 를 정본으로 쓴다.
- 케어렛 rect 는 `TextPattern.GetSelection()` (축소된 caret range) →
`GetBoundingRectangles()` 가 이식성 있는 경로다. Chromium 은 `ITextProvider` 는
구현하지만 `ITextPattern2::GetCaretRange` 는 구현하지 않는다 (Chromium 소스).
- 비밀번호 필드는 `UIA_IsPasswordPropertyId(30019)` 로 반드시 먼저 검사한다.
(MS Learn "IUIAutomationElement::get_CurrentIsPassword")
- 트리 전체 순회는 금지한다. Chrome/VS Code 에서 UIA 트리 크가 10~30초 걸린다는
실측 보고가 있다 (PowerToys #46385). 포커스 요소 + 패턴 2~3회 호출만 한다.
- IME(한/일) 조합 중에는 스트가 커밋되지 않고 케어렛도 부정확하다. 조합 중이면
호출자는 제안을 억제해야 하므로 `is_composing` 을 함께 돌려준다.
모든 호출은 전용 단일 스레드에서 실행한다 (comtypes COM 아파트먼트 친화성).
"""
from __future__ import annotations
import logging
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError as FutureTimeout
from typing import Any, Optional
logger = logging.getLogger("sidecar.uia")
# 텍스트를 그대로 들고 오는 상한. 입력창 전체 복사는 위험하므로 잘라 쓴다.
MAX_TEXT_CHARS = 4000
# UIA 호출 예산. 넘기면 이번 주기는 실패로 처리하고 다음 기회를 기다린다.
DEFAULT_TIMEOUT_MS = 1500
_uia_available: Optional[bool] = None
_executor: Optional[ThreadPoolExecutor] = None
_executor_lock = threading.Lock()
_inflight = threading.Lock()
def _uia_module() -> Any:
"""uiautomation 모듈을 지연 임포트한다 (미설치 환경에서 임포트 자체를 막지 않기 위해)."""
global _uia_available
try:
import uiautomation # noqa: PLC0415
_uia_available = True
return uiautomation
except Exception as exc: # pragma: no cover - 설치 환경 의존
_uia_available = False
logger.warning("uiautomation 미설치 — UIA 컨텍스트 비활성 (%s)", exc)
raise
def is_available() -> bool:
"""uiautomation 사용 가능 여부 (실제 임포트 시도 결과)."""
global _uia_available
if _uia_available is not None:
return _uia_available
try:
_uia_module()
except Exception:
return False
return bool(_uia_available)
def _worker_init() -> None:
"""UIA 전용 스레드의 COM 초기화."""
try:
import comtypes # noqa: PLC0415
comtypes.CoInitializeEx(comtypes.COINIT_MULTITHREADED)
except Exception:
# 이미 초기화됐거나 comtypes 가 없더라도 uiautomation 자체 초기화에 맡긴다.
try:
import comtypes # noqa: PLC0415
comtypes.CoInitialize()
except Exception:
pass
def _get_executor() -> ThreadPoolExecutor:
global _executor
with _executor_lock:
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="uia")
_executor.submit(_worker_init).result(timeout=5)
return _executor
def _rect_to_dict(rect: Any) -> Optional[dict]:
if rect is None:
return None
try:
left = int(rect.left)
top = int(rect.top)
right = int(rect.right)
bottom = int(rect.bottom)
except Exception:
return None
width = right - left
height = bottom - top
if width < 0 or height < 0:
return None
return {"x": left, "y": top, "width": width, "height": height}
def _first_rect(rects: Any) -> Optional[dict]:
"""GetBoundingRectangles 결과에서 케어 rect 를 는다 (첫 사각형 우선)."""
try:
for rect in rects or []:
converted = _rect_to_dict(rect)
if converted is not None:
return converted
except Exception:
return None
return None
def _read_text(auto: Any, control: Any) -> tuple[str, str, Optional[Any]]:
"""(텍스트, 출처, value_pattern) 을 돌려준다. 지원 패턴이 없으면 ('', 'none', None)."""
value_pattern = None
try:
value_pattern = control.GetValuePattern()
except Exception:
value_pattern = None
if value_pattern is not None:
try:
text = value_pattern.Value or ""
return (str(text)[:MAX_TEXT_CHARS], "value", value_pattern)
except Exception:
pass
try:
text_pattern = control.GetTextPattern()
except Exception:
text_pattern = None
if text_pattern is not None:
try:
text = text_pattern.DocumentRange.GetText(-1) or ""
return (str(text)[:MAX_TEXT_CHARS], "text", value_pattern)
except Exception:
# Chromium/CEF 입력창에서 COMError 가 나는 사례가 보고돼 있다 (issue #135/#138).
pass
try:
legacy = control.GetLegacyIAccessiblePattern()
if legacy is not None:
text = legacy.Value or ""
return (str(text)[:MAX_TEXT_CHARS], "legacy", value_pattern)
except Exception:
pass
return ("", "none", value_pattern)
def _caret_rect_and_offset(auto: Any, control: Any) -> tuple[Optional[dict], Optional[int], bool, bool]:
"""케어렛 rect / 문서 시작 기준 오프셋 / 조합 진행 여부 / 비축소 선택 여부."""
caret_rect: Optional[dict] = None
caret_offset: Optional[int] = None
composing = False
has_selection = False
text_pattern = None
try:
text_pattern = control.GetTextPattern()
except Exception:
text_pattern = None
if text_pattern is not None:
try:
selection = text_pattern.GetSelection()
except Exception:
selection = None
if selection:
for selected_range in selection:
try:
has_selection = (
selected_range.CompareEndpoints(
auto.TextPatternRangeEndpoint.Start,
selected_range,
auto.TextPatternRangeEndpoint.End,
)
!= 0
)
except Exception:
has_selection = False
if has_selection:
break
caret_range = selection[0]
try:
caret_rect = _first_rect(caret_range.GetBoundingRectangles())
except Exception:
caret_rect = None
if caret_rect is None:
# 축소된 caret range 는 빈 사각형 배열을 돌려주는 경우가 많다.
# 문자 단위로 확장한 뒤 다시 시도한다 (Windows Terminal 레시피).
try:
expanded = caret_range.Clone()
expanded.ExpandToEnclosingUnit(auto.TextUnit.Character)
caret_rect = _first_rect(expanded.GetBoundingRectangles())
except Exception:
caret_rect = None
try:
caret_offset = int(
caret_range.CompareEndpoints(
auto.TextPatternRangeEndpoint.Start,
text_pattern.DocumentRange,
auto.TextPatternRangeEndpoint.Start,
)
)
except Exception:
caret_offset = None
# IME 조합 여부 — TextEditPattern 이 지원되는 제공자에서만 확인 가능하다.
try:
pattern_id = getattr(auto.PatternId, "TextEditPattern", None)
if pattern_id is not None:
text_edit = control.GetPattern(pattern_id)
if text_edit is not None:
composition = text_edit.GetActiveComposition()
if composition is not None:
composing_text = composition.GetText(-1) or ""
composing = len(str(composing_text)) > 0
except Exception:
composing = False
return (caret_rect, caret_offset, composing, has_selection)
def _wake_chromium_accessibility() -> bool:
"""Chromium 계열에 "보조기술이 있다" 고 알려 접근성 트리를 열게 한다.
Chromium 문서: 브라우저가 EVENT_SYSTEM_ALERT + custom object id 1 을 알리고,
그 id 에 대해 WM_GETOBJECT 를 받으면 보조기술이 실행 중이라고 판단해 AX 트리를
만든다. NVDA 등이 쓰는 방식이며, 이걸 하지 않으면 Chrome/Edge 의 웹 입력창은
LegacyIAccessible 만 노출돼 텍스트를 읽을 수 없다(실측: edit=false src=legacy len=0).
SendMessage 대신 SendMessageTimeout 을 쓰는 이유: 대상이 바쁘면 무한 대기한다.
"""
try:
import ctypes # noqa: PLC0415
from ctypes import wintypes # noqa: PLC0415
user32 = ctypes.windll.user32
WM_GETOBJECT = 0x003D
OBJID_CLIENT = 0xFFFFFFFC
hwnd = user32.GetForegroundWindow()
if not hwnd:
return False
result = ctypes.c_void_p()
# 1 = 보조기술 감지용 custom object id
user32.SendMessageTimeoutW(
wintypes.HWND(hwnd),
WM_GETOBJECT,
wintypes.WPARAM(0),
wintypes.LPARAM(1),
0x0002, # SMTO_ABORTIFHUNG
250,
ctypes.byref(result),
)
# 클라이언트 영역 객체도 함께 요청해 AX 트리를 확실히 깨운다.
user32.SendMessageTimeoutW(
wintypes.HWND(hwnd),
WM_GETOBJECT,
wintypes.WPARAM(0),
wintypes.LPARAM(OBJID_CLIENT & 0xFFFFFFFF),
0x0002,
250,
ctypes.byref(result),
)
return True
except Exception as exc: # pragma: no cover - 플랫폼 의존
logger.debug("Chromium 접근성 깨우기 실패: %s", exc)
return False
def _snapshot() -> dict:
"""전용 스레드에서 실행되는 실제 UIA 수집."""
auto = _uia_module()
# Chromium/Electron 계열은 접근성 트리를 요청받아야 연다 — 먼저 깨운다.
_wake_chromium_accessibility()
started = time.monotonic()
control = auto.GetFocusedControl()
if control is None:
return {"available": False, "reason": "no-focused-control"}
result: dict[str, Any] = {
"available": True,
"isPassword": False,
"isEditable": False,
"isComposing": False,
"hasSelection": False,
"controlType": None,
"controlName": None,
"className": None,
"textSource": "none",
"text": "",
"caretOffset": None,
"caretRect": None,
"elementRect": None,
"windowTitle": None,
"processId": None,
}
try:
result["controlType"] = str(control.ControlTypeName or "")
result["controlName"] = str(control.Name or "")[:200]
result["className"] = str(control.ClassName or "")[:120]
except Exception:
pass
# 1) 비밀번호 필드는 어떤 읽기도 하지 않는다 (fail-closed).
try:
is_password = bool(control.IsPassword)
except Exception:
# 판별 실패를 "안전" 으로 넘기면 안 된다 — 읽기 불가로 처리한다.
return {"available": False, "reason": "is-password-unavailable"}
result["isPassword"] = is_password
if is_password:
result["reason"] = "password-field"
return result
try:
result["processId"] = int(control.ProcessId)
except Exception:
result["processId"] = None
try:
top = control.GetTopLevelControl()
if top is not None:
result["windowTitle"] = str(top.Name or "")[:300]
result["elementRect"] = _rect_to_dict(top.BoundingRectangle) or result["elementRect"]
except Exception:
pass
try:
result["elementRect"] = _rect_to_dict(control.BoundingRectangle) or result["elementRect"]
except Exception:
pass
text, source, value_pattern = _read_text(auto, control)
result["textSource"] = source
result["text"] = text
is_readonly = False
try:
if value_pattern is not None:
is_readonly = bool(value_pattern.IsReadOnly)
except Exception:
is_readonly = False
control_type = result["controlType"] or ""
# 터미널(Windows Terminal 등)은 ValuePattern 이 없고 TextPattern 만 있다.
# 값 패턴 부재만 보고 "편집 불가" 로 판정하면 파워셸/터미널처럼 실제로 가장
# 많이 타이핑하는 표면에서 제안이 통째로 죽는다(실측: src=text, edit=false).
# 텍스트 패턴이 선택/삽입 지점을 제공하면 입력 가능으로 본다.
#
# TextPattern 이 있으면 입력 가능으로 본다. 처음에는 GetSelection() 이 비어
# 있지 않은지까지 봤는데, Windows Terminal 은 선택이 없으면 0개를 돌려주는
# 탓에 터미널이 계속 "편집 불가" 로 판정됐다(실측 프로브: TextPattern=yes,
# GetSelection=0 ranges). 값 패턴조차 없는 표면이라 다른 단서가 없다.
text_readable = False
try:
text_readable = control.GetTextPattern() is not None
except Exception:
text_readable = False
result["isEditable"] = bool(
(value_pattern is not None and not is_readonly)
or control_type in ("EditControl", "DocumentControl")
or text_readable
)
caret_rect, caret_offset, composing, has_selection = _caret_rect_and_offset(auto, control)
result["caretRect"] = caret_rect
result["caretOffset"] = caret_offset
result["isComposing"] = composing
result["hasSelection"] = has_selection
result["capturedAt"] = int(time.time() * 1000)
result["elapsedMs"] = int((time.monotonic() - started) * 1000)
return result
def snapshot_focus(timeout_ms: int = DEFAULT_TIMEOUT_MS) -> dict:
"""포커스 스냅샷. 실패/타임아웃은 예외 대신 available=False 로 돌려준다."""
if not is_available():
return {"available": False, "reason": "uiautomation-missing"}
# 이전 호출이 아직 춰 있으면 새 작업을 쌓지 않고 즉시 포기한다.
if not _inflight.acquire(blocking=False):
return {"available": False, "reason": "busy"}
try:
executor = _get_executor()
future: Future = executor.submit(_snapshot)
try:
return future.result(timeout=max(0.2, timeout_ms / 1000))
except FutureTimeout:
logger.warning("UIA 스냅샷 타임아웃 (%sms) — 이번 주기 건너뜀", timeout_ms)
return {"available": False, "reason": "timeout"}
except Exception as exc: # pragma: no cover - 환경 의존
logger.warning("UIA 스냅샷 실패: %s", exc)
return {"available": False, "reason": f"error:{type(exc).__name__}"}
finally:
_inflight.release()
def shutdown() -> None:
global _executor
with _executor_lock:
if _executor is not None:
_executor.shutdown(wait=False, cancel_futures=True)
_executor = None

View file

@ -11,6 +11,8 @@ import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
import { getTextInsertService } from './services/TextInsertService'
import { getCustomInstructionService } from './services/CustomInstructionService'
import { getVoiceCommandService } from './services/VoiceCommandService'
import { getInputTelemetryService } from './services/InputTelemetryService'
import { getSuggestionService } from './services/SuggestionService'
import { getSoundEffectService } from './services/SoundEffectService'
import { getAutoLaunchService } from './services/AutoLaunchService'
import { getAudioCaptureService } from './services/AudioCaptureService'
@ -30,6 +32,8 @@ import {
isCommandPopupVisible,
hideRecordingTip,
updateRecordingTipState,
showSuggestionOverlay,
hideSuggestionOverlay,
} from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { registerAllIpcHandlers } from './ipc'
@ -62,6 +66,7 @@ export async function bootstrap(): Promise<void> {
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
{ name: 'key-bindings', critical: false, fn: initKeyBindings },
{ name: 'input-intelligence', critical: false, fn: initInputIntelligence },
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
{ name: 'stt-warmup', critical: false, fn: initSTTWarmup },
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
@ -125,6 +130,91 @@ async function initKeyBindings(): Promise<void> {
keyBindings.start()
}
/**
* 입력 인텔리전스 배선 — 텔레메트리 → 제안 → 오버레이.
*
* 두 서비스를 직접 서로 import 하지 않고 여기서 이벤트로 는다
* (서비스 간 결합을 늘리지 않으면서 교체 가능성을 남긴다).
*/
async function initInputIntelligence(): Promise<void> {
const telemetry = getInputTelemetryService()
const suggestion = getSuggestionService()
telemetry.on('typed-context', (context) => suggestion.handleTypingContext(context))
telemetry.on('state-changed', (state) =>
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.STATE_CHANGED, state)
)
telemetry.on('activity', (payload) =>
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, payload)
)
suggestion.on('updated', (state) => {
// 오버레이/윈도우 호출이 예외를 던지면 서비스의 진행 플래그가 굳을 수 있다.
// 이벤트 경로는 어떤 경우에도 조용히 삼킨다 (UI 문제로 기능 전체가 멈추면 안 된다).
try {
const hasSomethingToShow =
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
const shouldPresent = hasSomethingToShow && !!state.anchor
telemetry.setSuggestionPresentationActive(shouldPresent)
if (!shouldPresent) {
hideSuggestionOverlay()
} else {
showSuggestionOverlay({
candidates: state.candidates,
activeIndex: state.activeIndex,
generating: state.generating,
warmingUp: state.warmingUp,
partialText: state.partialText,
anchor: state.anchor,
appName: state.appName,
provenance: state.provenance
})
}
} catch (error) {
logger.warn(`제안 오버레이 표시 실패: ${error instanceof Error ? error.message : String(error)}`)
}
sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state)
})
suggestion.on('cleared', (payload) => {
telemetry.setSuggestionPresentationActive(false)
try {
hideSuggestionOverlay()
} catch (error) {
logger.warn(`제안 오버레이 숨기기 실패: ${error instanceof Error ? error.message : String(error)}`)
}
sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload)
})
suggestion.on('state-changed', (state) =>
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-dismiss') suggestion.dismiss('dismissed')
})
// Chromium(Electron)은 접근성 지원이 감지될 때만 AX 트리를 만든다. 켜지 않으면
// 우리 앱 자신의 입력창은 UIA 로 읽히지 않아 D3RO 안에서 타이핑할 때 제안이 죽는다.
if (configGet('inputTelemetryEnabled') || configGet('suggestionEnabled')) {
app.setAccessibilitySupportEnabled(true)
logger.info('[bootstrap] accessibility tree enabled for UIA context capture')
}
telemetry.start()
}
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
function sendToMainWindow(channel: IPCChannel, payload: unknown): void {
const win = getMainWindow()
if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send(channel, payload)
}
}
async function initCustomInstructions(): Promise<void> {
getCustomInstructionService().initialize()
}
@ -228,6 +318,7 @@ function unregisterPopupNavKeys(): void {
}
}
async function initVoiceMode(): Promise<void> {
const voiceMode = getVoiceModeService()
voiceMode.connectKeyBindings()

View file

@ -246,6 +246,99 @@ function applySchema(s: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
`)
// 입력 인텔리전스: 텔레메트리 집계 / 학습 문장 / 개인 문구 / 제안 이력
s.exec(`
CREATE TABLE IF NOT EXISTS input_activity (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
hour INTEGER NOT NULL,
app_name TEXT NOT NULL DEFAULT '',
keystrokes INTEGER NOT NULL DEFAULT 0,
shortcuts INTEGER NOT NULL DEFAULT 0,
backspaces INTEGER NOT NULL DEFAULT 0,
clicks INTEGER NOT NULL DEFAULT 0,
double_clicks INTEGER NOT NULL DEFAULT 0,
scroll_ticks INTEGER NOT NULL DEFAULT 0,
mouse_distance_px INTEGER NOT NULL DEFAULT 0,
chars INTEGER NOT NULL DEFAULT 0,
words INTEGER NOT NULL DEFAULT 0,
sentences INTEGER NOT NULL DEFAULT 0,
active_ms INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_input_activity_bucket
ON input_activity(date, hour, app_name);
CREATE INDEX IF NOT EXISTS idx_input_activity_date ON input_activity(date);
CREATE TABLE IF NOT EXISTS typing_samples (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
word_count INTEGER NOT NULL DEFAULT 0,
char_count INTEGER NOT NULL DEFAULT 0,
app_name TEXT,
window_title TEXT,
source TEXT NOT NULL DEFAULT 'typed',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_typing_samples_created_at ON typing_samples(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_typing_samples_source ON typing_samples(source);
CREATE TABLE IF NOT EXISTS personal_phrases (
id TEXT PRIMARY KEY,
phrase TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'typed',
last_used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_personal_phrases_phrase ON personal_phrases(phrase);
CREATE INDEX IF NOT EXISTS idx_personal_phrases_count ON personal_phrases(count DESC);
CREATE TABLE IF NOT EXISTS suggestions (
id TEXT PRIMARY KEY,
app_name TEXT,
prefix_text TEXT NOT NULL DEFAULT '',
suggestion_text TEXT NOT NULL,
candidate_count INTEGER NOT NULL DEFAULT 1,
model TEXT,
latency_ms INTEGER,
accepted INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_suggestions_created_at ON suggestions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_suggestions_accepted ON suggestions(accepted);
CREATE TABLE IF NOT EXISTS phrase_edges (
id TEXT PRIMARY KEY,
from_phrase TEXT NOT NULL,
to_phrase TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'follows',
weight INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_phrase_edges_unique
ON phrase_edges(from_phrase, to_phrase, kind);
CREATE INDEX IF NOT EXISTS idx_phrase_edges_from ON phrase_edges(from_phrase);
CREATE INDEX IF NOT EXISTS idx_phrase_edges_weight ON phrase_edges(weight DESC);
`)
// 개인 그래프: personal_phrases 에 terms/app_name 컬럼 (기존 DB 마이그레이션)
try {
const phraseCols = s.pragma('table_info(personal_phrases)') as Array<{ name: string }>
if (!phraseCols.some((c) => c.name === 'terms')) {
s.exec('ALTER TABLE personal_phrases ADD COLUMN terms TEXT')
logger.info('Migrated: added terms column to personal_phrases')
}
if (!phraseCols.some((c) => c.name === 'app_name')) {
s.exec('ALTER TABLE personal_phrases ADD COLUMN app_name TEXT')
logger.info('Migrated: added app_name column to personal_phrases')
}
} catch (err) {
logger.warn(
`personal_phrases graph migration failed: ${err instanceof Error ? err.message : String(err)}`
)
}
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼
try {
const msCols = s.pragma('table_info(meeting_sessions)') as Array<{ name: string }>

View file

@ -229,6 +229,137 @@ export const meetingDocuments = sqliteTable(
export type MeetingDocumentRow = typeof meetingDocuments.$inferSelect
export type NewMeetingDocumentRow = typeof meetingDocuments.$inferInsert
// ── input_activity (입력 텔레메트리) ─────────────────────
// 시간(hour)·앱 단위 집계 행. 키 내용은 저장하지 않는다 —
// ActivityWatch aw-watcher-input 과 같은 데이터 최소화 정책 (카운터 + 거리만).
export const inputActivity = sqliteTable(
'input_activity',
{
id: text('id').primaryKey(),
/** 로컬 날짜 YYYY-MM-DD */
date: text('date').notNull(),
/** 0~23 */
hour: integer('hour').notNull(),
/** 포그라운드 프로세스명 (없으면 '') */
appName: text('app_name').notNull().default(''),
keystrokes: integer('keystrokes').notNull().default(0),
shortcuts: integer('shortcuts').notNull().default(0),
backspaces: integer('backspaces').notNull().default(0),
clicks: integer('clicks').notNull().default(0),
doubleClicks: integer('double_clicks').notNull().default(0),
scrollTicks: integer('scroll_ticks').notNull().default(0),
mouseDistancePx: integer('mouse_distance_px').notNull().default(0),
chars: integer('chars').notNull().default(0),
words: integer('words').notNull().default(0),
sentences: integer('sentences').notNull().default(0),
activeMs: integer('active_ms').notNull().default(0),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
uniqueIndex('idx_input_activity_bucket').on(table.date, table.hour, table.appName),
index('idx_input_activity_date').on(table.date),
]
)
// ── typing_samples (학습된 이핑/음성 문장) ─────────────
export const typingSamples = sqliteTable(
'typing_samples',
{
id: text('id').primaryKey(),
text: text('text').notNull(),
wordCount: integer('word_count').notNull().default(0),
charCount: integer('char_count').notNull().default(0),
appName: text('app_name'),
windowTitle: text('window_title'),
source: text('source', { enum: ['typed', 'voice', 'suggestion', 'clipboard'] })
.notNull()
.default('typed'),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('idx_typing_samples_created_at').on(table.createdAt),
index('idx_typing_samples_source').on(table.source),
]
)
// ── personal_phrases (개인화 문구) ──────────────────────
export const personalPhrases = sqliteTable(
'personal_phrases',
{
id: text('id').primaryKey(),
phrase: text('phrase').notNull(),
count: integer('count').notNull().default(1),
source: text('source', { enum: ['typed', 'voice', 'suggestion', 'clipboard'] })
.notNull()
.default('typed'),
lastUsedAt: integer('last_used_at'),
createdAt: integer('created_at').notNull(),
/** 개인 그래프: 비교용 용어 (JSON string[]) */
terms: text('terms'),
/** 마지막으로 쓰인 앱 (그래프 문맥 표시용) */
appName: text('app_name'),
},
(table) => [
uniqueIndex('idx_personal_phrases_phrase').on(table.phrase),
index('idx_personal_phrases_count').on(table.count),
]
)
// ── suggestions (제안 이력 · 수락률 측정) ────────────────
export const suggestions = sqliteTable(
'suggestions',
{
id: text('id').primaryKey(),
appName: text('app_name'),
/** 어렛 앞 접두 (프롬프트 스트) */
prefixText: text('prefix_text').notNull().default(''),
suggestionText: text('suggestion_text').notNull(),
candidateCount: integer('candidate_count').notNull().default(1),
model: text('model'),
latencyMs: integer('latency_ms'),
accepted: integer('accepted', { mode: 'boolean' }).notNull().default(false),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('idx_suggestions_created_at').on(table.createdAt),
index('idx_suggestions_accepted').on(table.accepted),
]
)
// ── phrase_edges (개인 그래프 엣지) ──────────────────────
// 문장(노드) 사이의 관계. follows = 한 텍스트 안에서 연달아 나온 문장 쌍,
// shares_terms = 용어를 충분히 공유하는 문장 쌍. 관계형 개인화의 근거다.
export const phraseEdges = sqliteTable(
'phrase_edges',
{
id: text('id').primaryKey(),
fromPhrase: text('from_phrase').notNull(),
toPhrase: text('to_phrase').notNull(),
kind: text('kind', { enum: ['follows', 'shares_terms'] })
.notNull()
.default('follows'),
weight: integer('weight').notNull().default(1),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
uniqueIndex('idx_phrase_edges_unique').on(table.fromPhrase, table.toPhrase, table.kind),
index('idx_phrase_edges_from').on(table.fromPhrase),
index('idx_phrase_edges_weight').on(table.weight),
]
)
export type PhraseEdgeRow = typeof phraseEdges.$inferSelect
export type NewPhraseEdgeRow = typeof phraseEdges.$inferInsert
export type InputActivityRow = typeof inputActivity.$inferSelect
export type NewInputActivityRow = typeof inputActivity.$inferInsert
export type TypingSampleRow = typeof typingSamples.$inferSelect
export type NewTypingSampleRow = typeof typingSamples.$inferInsert
export type PersonalPhraseRow = typeof personalPhrases.$inferSelect
export type NewPersonalPhraseRow = typeof personalPhrases.$inferInsert
export type SuggestionRow = typeof suggestions.$inferSelect
export type NewSuggestionRow = typeof suggestions.$inferInsert
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect
export type NewHistory = typeof history.$inferInsert

View file

@ -29,6 +29,8 @@ import { registerCloudSyncHandlers } from './cloud-sync-handlers'
import { registerAdsHandlers } from './ads-handlers'
import { registerSupportHandlers } from './support-handlers'
import { registerPaymentHandlers } from './payment-handlers'
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
import { registerSuggestionHandlers } from './suggestion-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -63,5 +65,7 @@ export function registerAllIpcHandlers(): void {
registerAdsHandlers()
registerSupportHandlers()
registerPaymentHandlers()
registerInputTelemetryHandlers()
registerSuggestionHandlers()
logger.info('All IPC handlers registered')
}

View file

@ -0,0 +1,164 @@
// src/main/ipc/input-telemetry-handlers.ts
// 입력 텔레메트리 수집 동의 · 리포트 · 개인 문구 관리.
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getInputTelemetryService } from '../services/InputTelemetryService'
import { getPersonalGraphService } from '../services/PersonalGraphService'
import { getSuggestionService } from '../services/SuggestionService'
export interface SetInputTelemetryEnabledParams {
enabled: boolean
}
export interface SetInputTelemetryPausedParams {
paused: boolean
}
export interface InputSummaryParams {
days?: number
}
export interface InputPhrasesParams {
limit?: number
}
export interface DeletePhraseParams {
id: string
}
export function registerInputTelemetryHandlers(): void {
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_STATE, async () => {
try {
return ipcSuccess(getInputTelemetryService().getState())
} catch (error) {
return ipcError(
ErrorCode.InputTelemetryConfigFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED,
async (_event, params: SetInputTelemetryEnabledParams) => {
try {
getInputTelemetryService().setEnabled(params.enabled === true)
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다.
if (params.enabled !== true) getSuggestionService().applyConfig()
return ipcSuccess(getInputTelemetryService().getState())
} catch (error) {
return ipcError(
ErrorCode.InputTelemetryConfigFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED,
async (_event, params: SetInputTelemetryPausedParams) => {
try {
getInputTelemetryService().setPaused(params.paused === true)
return ipcSuccess(getInputTelemetryService().getState())
} catch (error) {
return ipcError(
ErrorCode.InputTelemetryConfigFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.GET_SUMMARY,
async (_event, params?: InputSummaryParams) => {
try {
return ipcSuccess(getInputTelemetryService().getSummary(params?.days ?? 7))
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_PRIVACY_RECEIPT, async () => {
try {
return ipcSuccess(getInputTelemetryService().getPrivacyReceipt())
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.GET_PHRASES,
async (_event, params?: InputPhrasesParams) => {
try {
return ipcSuccess(getInputTelemetryService().listPhrases(params?.limit ?? 100))
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.DELETE_PHRASE,
async (_event, params: DeletePhraseParams) => {
try {
return ipcSuccess(getInputTelemetryService().deletePhrase(params.id))
} catch (error) {
return ipcError(
ErrorCode.DBWriteFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_GRAPH, async () => {
try {
return ipcSuccess(getPersonalGraphService().getStats())
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.INPUT_TELEMETRY.QUERY_GRAPH,
async (_event, params: { text: string; limit?: number }) => {
try {
return ipcSuccess(getPersonalGraphService().query(params.text, params.limit ?? 12))
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.CLEAR_ALL, async () => {
try {
getInputTelemetryService().clearAll()
return ipcSuccess(undefined)
} catch (error) {
return ipcError(
ErrorCode.DBWriteFailed,
error instanceof Error ? error.message : String(error)
)
}
})
}

View file

@ -0,0 +1,190 @@
// src/main/ipc/suggestion-handlers.ts
// 다음 문장 제안(ghost text) 상태/설정/수락 + 오버레이 팝업 내부 채널.
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
import { getSuggestionService } from '../services/SuggestionService'
import { configSet } from '../services/ConfigService'
import { applySuggestionOverlayConfig, hideSuggestionOverlay } from '../windows/WindowManager'
export interface SetSuggestionConfigParams {
enabled?: boolean
modelId?: string | null
triggerDelayMs?: number
minPrefixChars?: number
maxRequestsPerMinute?: number
dailyBudget?: number
overlayInteractive?: boolean
learnTypedText?: boolean
excludedApps?: string[]
requestTimeoutMs?: number
}
export interface SuggestionAcceptParams {
index?: number
}
export interface SuggestionHistoryParams {
limit?: number
}
export function registerSuggestionHandlers(): void {
ipcMain.handle(IPC_CHANNELS.SUGGESTION.GET_STATE, async () => {
try {
return ipcSuccess(getSuggestionService().getState())
} catch (error) {
return ipcError(
ErrorCode.SuggestionGenerationFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.SUGGESTION.SET_CONFIG,
async (_event, params: SetSuggestionConfigParams) => {
try {
const service = getSuggestionService()
if (params.enabled !== undefined) service.setEnabled(params.enabled === true)
if (params.modelId !== undefined) configSet('suggestionModelId', params.modelId || null)
if (params.triggerDelayMs !== undefined) {
configSet('suggestionTriggerDelayMs', clamp(params.triggerDelayMs, 100, 2000))
}
if (params.minPrefixChars !== undefined) {
configSet('suggestionMinPrefixChars', clamp(params.minPrefixChars, 4, 40))
}
if (params.maxRequestsPerMinute !== undefined) {
configSet('suggestionMaxRequestsPerMinute', clamp(params.maxRequestsPerMinute, 1, 12))
}
if (params.dailyBudget !== undefined) {
configSet('suggestionDailyBudget', clamp(params.dailyBudget, 1, 100000))
}
if (params.requestTimeoutMs !== undefined) {
configSet('suggestionRequestTimeoutMs', clamp(params.requestTimeoutMs, 500, 60000))
}
if (params.overlayInteractive !== undefined) {
configSet('suggestionOverlayInteractive', params.overlayInteractive === true)
applySuggestionOverlayConfig()
}
if (params.learnTypedText !== undefined) {
configSet('inputLearnTypedText', params.learnTypedText === true)
}
if (params.excludedApps !== undefined) {
configSet('inputExcludedApps', sanitizeExcludedApps(params.excludedApps))
}
service.applyConfig()
return ipcSuccess(service.getState())
} catch (error) {
return ipcError(
ErrorCode.ConfigWriteFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(IPC_CHANNELS.SUGGESTION.REQUEST_NOW, async () => {
try {
const result = await getSuggestionService().requestNow()
return ipcSuccess(result)
} catch (error) {
return ipcError(
ErrorCode.SuggestionGenerationFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.SUGGESTION.ACCEPT,
async (_event, params?: SuggestionAcceptParams) => {
try {
const result = await getSuggestionService().accept(params?.index)
return ipcSuccess(result)
} catch (error) {
return ipcError(
ErrorCode.SuggestionAcceptFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
ipcMain.handle(IPC_CHANNELS.SUGGESTION.NEXT, async () => {
try {
return ipcSuccess(getSuggestionService().next())
} catch (error) {
return ipcError(
ErrorCode.SuggestionGenerationFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(IPC_CHANNELS.SUGGESTION.PREV, async () => {
try {
return ipcSuccess(getSuggestionService().previous())
} catch (error) {
return ipcError(
ErrorCode.SuggestionGenerationFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(IPC_CHANNELS.SUGGESTION.DISMISS, async () => {
try {
getSuggestionService().dismiss('dismissed')
return ipcSuccess(undefined)
} catch (error) {
return ipcError(
ErrorCode.SuggestionGenerationFailed,
error instanceof Error ? error.message : String(error)
)
}
})
ipcMain.handle(
IPC_CHANNELS.SUGGESTION.GET_HISTORY,
async (_event, params?: SuggestionHistoryParams) => {
try {
return ipcSuccess(getSuggestionService().getHistory(params?.limit ?? 50))
} catch (error) {
return ipcError(
ErrorCode.DBQueryFailed,
error instanceof Error ? error.message : String(error)
)
}
}
)
// ── 오버레이 팝업 → 메인 (클릭 수락/닫기) ──────────────
ipcMain.on(IPC_CHANNELS.POPUP_SUGGESTION.ACCEPT, (_event, index?: number) => {
void getSuggestionService().accept(index)
})
ipcMain.on(IPC_CHANNELS.POPUP_SUGGESTION.DISMISS, () => {
hideSuggestionOverlay()
getSuggestionService().dismiss('dismissed')
})
}
function clamp(value: number, min: number, max: number): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return min
return Math.max(min, Math.min(Math.round(numeric), max))
}
/** 실행 파일명만 남기고 정규화 (최대 100개). */
function sanitizeExcludedApps(apps: string[]): string[] {
const seen = new Set<string>()
for (const raw of apps) {
const value = String(raw).trim().slice(0, 120)
if (!value) continue
seen.add(value)
if (seen.size >= 100) break
}
return [...seen]
}

View file

@ -3,6 +3,7 @@
import { app } from 'electron'
import { getLogger } from './services/LoggerService'
import { getConfigService } from './services/ConfigService'
import { disposeLocalLLMService } from './services/LocalLLMService'
const logger = getLogger('lifecycle')
@ -24,6 +25,7 @@ export function setupLifecycle(): void {
app.on('will-quit', () => {
logger.info('[lifecycle] will-quit — flushing config')
disposeLocalLLMService()
try {
const configService = getConfigService()
if (configService) {

View file

@ -261,7 +261,7 @@ class AudioCaptureService extends EventEmitter {
return new Promise<AudioDevice[]>((resolve) => {
exec(
`powershell -NoProfile -Command "${psCommand}"`,
{ encoding: 'utf8', timeout: 3000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } },
{ encoding: 'utf8', timeout: 3000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, windowsHide: true },
(err, stdout) => {
if (err || !stdout?.trim()) {
return resolve([

View file

@ -3,12 +3,64 @@
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
import { createDefaultBindingMap, normalizeBinding, parseBindingMap } from '@d3ro/core/keybinding'
import {
createDefaultBindingMap,
findActionSpec,
normalizeBinding,
parseBindingMap
} from '@d3ro/core/keybinding'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
const SUGGESTION_TUNING_REVISION = 4
const INITIAL_SUGGESTION_TUNING = {
suggestionTriggerDelayMs: 300,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 12,
suggestionDailyBudget: 500
} as const
/** 저장된 튜닝 값을 해당 개정판에서만 변경된 항목으로 올린다. */
function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
const raw = activeStore.store as unknown as Record<string, unknown>
const current = Number(raw.suggestionTuningRevision ?? 0)
if (Number.isFinite(current) && current >= SUGGESTION_TUNING_REVISION) return
if (current < 1) {
for (const [key, value] of Object.entries(INITIAL_SUGGESTION_TUNING)) {
activeStore.set(key as keyof AppConfig, value as AppConfig[keyof AppConfig])
}
}
if (current < 2) {
const rawBindings = raw.keyBindings as unknown
const bindings = parseBindingMap(rawBindings)
const defaults = createDefaultBindingMap()
const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const
for (const action of suggestionActions) {
const spec = findActionSpec(action)
if (!spec) continue
bindings[action] = defaults[action] ?? spec.defaultBindings.map((binding) => ({ ...binding }))
}
activeStore.set('keyBindings', bindings)
}
if (current < 3) activeStore.set('suggestionRequestTimeoutMs', 8000)
if (current < 4) {
activeStore.set('suggestionTriggerDelayMs', 600)
activeStore.set('suggestionMaxRequestsPerMinute', 6)
}
activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION)
logger.info(
`Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}`
)
}
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T> {
get<K extends keyof T>(key: K): T[K]
@ -82,6 +134,21 @@ const CONFIG_DEFAULTS: AppConfig = {
updateChannel: 'latest',
updateDeviceId: '',
skippedUpdateVersion: null,
// 입력 인텔리전스 — 옵트인. 켜기 전까지 어떤 입력도 수집하지 않는다.
inputTelemetryEnabled: false,
inputTelemetryPaused: false,
inputLearnTypedText: false,
inputExcludedApps: [],
suggestionEnabled: false,
suggestionModelId: null,
// 기본값 정본: packages/core/src/input-intelligence.ts SUGGESTION_DEFAULTS
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 6,
suggestionDailyBudget: 500,
suggestionOverlayInteractive: true,
suggestionRequestTimeoutMs: 8000,
suggestionTuningRevision: 4,
}
let store: ElectronStore<AppConfig> | null = null
@ -195,6 +262,7 @@ export async function initConfigService(): Promise<void> {
defaults: CONFIG_DEFAULTS
})
migrateKeyBindings(store)
migrateSuggestionTuning(store)
logger.info('ConfigService initialized')
}

View file

@ -282,7 +282,7 @@ class FileTranscriptionService extends EventEmitter {
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
@ -319,7 +319,7 @@ class FileTranscriptionService extends EventEmitter {
'-',
]
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
@ -369,7 +369,7 @@ class FileTranscriptionService extends EventEmitter {
'pipe:1',
]
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const chunks: Buffer[] = []
proc.stdout?.on('data', (data: Buffer) => {

View file

@ -7,6 +7,7 @@ import { history, stats } from '../db/schema'
import type { History, NewHistory } from '../db/schema'
import { getLogger } from './LoggerService'
import { getCloudSyncService } from './CloudSyncService'
import { getInputTelemetryService } from './InputTelemetryService'
import type {
HistoryEntry,
HistoryQueryParams,
@ -43,6 +44,18 @@ class HistoryService {
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
this.generateTitle(id).catch(() => { /* ignore */ })
// 입력 인리전스: 음성 전사도 개인 문구 코퍼스의 표본이다.
// 학습 동의(inputLearnTypedText)가 없으면 서비스 내부에서 무시된다.
try {
getInputTelemetryService().recordExternalText(input.originalText, {
appName: input.focusedAppName ?? null,
windowTitle: input.focusedAppWindowTitle ?? null,
source: 'voice'
})
} catch {
// 학습 실패가 이력 저장을 막아서는 안 된다.
}
return this._toEntry(entry as History)
}

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import { globalShortcut } from 'electron'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi'
import { getLogger } from './LoggerService'
import { acquireGlobalInputHook } from './global-input-hook'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import { TIMING } from '@d3ro/core/constants'
@ -33,6 +34,37 @@ import type {
const logger = getLogger('KeyBindingService')
/**
* AltGr(=Ctrl+Alt) 함정 가드.
*
* 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신
* 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는
* 잠깐 보류하고, 그 사이에 다른 키가 눌리면(=조합을 만들려던 것) 취소한다.
*
* 받아쓰기(수정자 단독, Ctrl 없음)와 Alt+Shift 계열은 모양이 달라 영향받지 않는다.
*/
const ALTGR_CHORD_GRACE_MS = 280
/** 수정자 키의 VK 코드 (수정자만으로 끝나는 조합 판별). */
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
0x10, // Shift
0x11, // Ctrl
0x12, // Alt
0x5b, // LWin
0x5c, // RWin
0xa0,
0xa1, // L/R Shift
0xa2,
0xa3, // L/R Ctrl
0xa4,
0xa5 // L/R Alt
])
let pendingAltGrEmit: NodeJS.Timeout | null = null
/** 보류가 취소된 바인딩 — 대응하는 released 를 내보내지 않기 위해 기억한다. */
const cancelledAltGrBindings = new Set<string>()
// ============================================================
// 이벤트 페이로드
// ============================================================
@ -47,6 +79,21 @@ export interface KeyBindingTriggerPayload {
timestamp: number
/** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */
durationMs: number
/**
* 매칭된 바인딩의 주 키 코드와 수정자 상태.
*
* 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다.
* (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다)
*/
binding: {
device: 'keyboard' | 'mouse'
code: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
}
}
interface KeyBindingServiceEvents {
@ -200,6 +247,17 @@ const UIOHOOK_TO_VK: ReadonlyMap<number, number> = new Map(
Array.from(VK_TO_UIOHOOK, ([vk, uiohookCode]) => [uiohookCode, vk] as const)
)
/**
* uiohook 이트 키코드 → Windows VK (정본 좌표계).
*
* 같은 변환표를 두 곳에 두지 않기 위해 내보낸다. 입력 텔레메트리가 키 **종류**
* (문자/숫자/백스페이스/단축키 …)를 분류할 때 쓴다 — 키 내용은 저장하지 않는다.
* 매핑이 없는 키는 null.
*/
export function uiohookCodeToVk(code: number): number | null {
return UIOHOOK_TO_VK.get(code) ?? null
}
function isCtrlCode(code: number): boolean {
return code === UiohookKey.Ctrl || code === UiohookKey.CtrlRight
}
@ -322,12 +380,21 @@ interface ActiveTrigger {
actionId: KeyBindingActionId
isDoublePress: boolean
holdMode: boolean
/** 눌림을 만든 바인딩 (AltGr 형태 판정에 필요) */
entry: RegisteredBinding
}
// ============================================================
// KeyBindingService
// ============================================================
/** AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. */
function isAltGrShaped(binding: KeyBinding): boolean {
if (binding.device !== 'keyboard') return false
if (!binding.ctrl || !binding.alt) return false
return MODIFIER_VK_CODES.has(binding.code)
}
class KeyBindingService extends EventEmitter {
private _isRunning = false
@ -354,6 +421,14 @@ class KeyBindingService extends EventEmitter {
private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null
private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null
/**
* 글로벌 후 해제 함수.
*
* 후킹은 프로세스 전역 글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
* 직접 stop 하면 상대방 수신을 죽이므로 참조 카운트 해제자를 보관한다.
*/
private _releaseHook: (() => void) | null = null
get isRunning(): boolean {
return this._isRunning
}
@ -386,11 +461,11 @@ class KeyBindingService extends EventEmitter {
uIOhook.on('keyup', this._onKeyUp)
uIOhook.on('mousedown', this._onMouseDown)
uIOhook.on('mouseup', this._onMouseUp)
uIOhook.start()
this._releaseHook = acquireGlobalInputHook()
this._isRunning = true
this._syncAccelerators()
logger.info('uiohook started, global keyboard/mouse hook active')
logger.info('Key binding listeners attached to global input hook')
} catch (error) {
const d3roError = new D3ROError(
ErrorCode.HotkeyHookInitFailed,
@ -406,7 +481,8 @@ class KeyBindingService extends EventEmitter {
if (!this._isRunning) return
try {
uIOhook.stop()
this._releaseHook?.()
this._releaseHook = null
if (this._onKeyDown) {
uIOhook.removeListener('keydown', this._onKeyDown)
@ -684,24 +760,74 @@ class KeyBindingService extends EventEmitter {
triggers.push({
actionId: entry.actionId,
isDoublePress,
holdMode: entry.spec.holdMode
holdMode: entry.spec.holdMode,
entry
})
}
this._activeTriggers.set(key, triggers)
for (const trigger of triggers) {
logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`)
this.emit('triggered', {
actionId: trigger.actionId,
type: 'pressed',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs: 0
})
this._emitPressed(trigger.entry, trigger, now, key)
}
}
/**
* 눌림 이벤트를 내보낸다.
*
* AltGr 형태(수정자 전용 + Ctrl+Alt)는 곧 다른 키가 이어질 수 있으므로 잠깐
* 보류한다. 그 사이 다른 바인딩의 눌림이 오면 취소한다 — 오른쪽 Alt 는 Windows 가
* Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 Alt 순간에 Ctrl+AltRight(음성 명령)
* 가 먼저 발동하던 문제를 이 지점에서 막는다.
*/
private _emitPressed(
entry: RegisteredBinding,
trigger: ActiveTrigger,
now: number,
bindingKeyValue: string
): void {
const payload: KeyBindingTriggerPayload = {
actionId: trigger.actionId,
type: 'pressed',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs: 0,
binding: {
device: entry.binding.device,
code: entry.binding.code,
ctrl: entry.binding.ctrl,
alt: entry.binding.alt,
shift: entry.binding.shift,
meta: entry.binding.meta
}
}
if (isAltGrShaped(entry.binding)) {
if (pendingAltGrEmit) clearTimeout(pendingAltGrEmit)
const actionId = trigger.actionId
// 취소 표시는 _fireRelease 가 쓰는 것과 같은 키여야 한다 (device:code 조합이 아니다).
cancelledAltGrBindings.add(actionId + ':' + bindingKeyValue)
pendingAltGrEmit = setTimeout(() => {
pendingAltGrEmit = null
logger.debug(`AltGr 형태 트리거 유예 후 발동: "${actionId}"`)
this.emit('triggered', payload)
}, ALTGR_CHORD_GRACE_MS)
pendingAltGrEmit.unref?.()
return
}
// 다른 키가 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소.
if (pendingAltGrEmit) {
clearTimeout(pendingAltGrEmit)
pendingAltGrEmit = null
logger.debug('AltGr 형태 보류 트리거 취소 (다른 키가 이어짐)')
}
this.emit('triggered', payload)
}
private _fireRelease(key: string): void {
this._isKeyDown.set(key, false)
@ -717,13 +843,26 @@ class KeyBindingService extends EventEmitter {
logger.debug(
`Key binding released: "${trigger.actionId}" (duration=${durationMs}ms)`
)
if (cancelledAltGrBindings.delete(trigger.actionId + ':' + key)) {
logger.debug(`AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.actionId}"`)
continue
}
this.emit('triggered', {
actionId: trigger.actionId,
type: 'released',
isDoublePress: trigger.isDoublePress,
holdMode: trigger.holdMode,
timestamp: now,
durationMs
durationMs,
binding: {
device: 'keyboard',
code: 0,
ctrl: false,
alt: false,
shift: false,
meta: false
}
})
}
}

View file

@ -38,6 +38,22 @@ interface GenerateOptions {
maxTokens?: number
systemPrompt?: string
stream?: boolean
/**
* 호출자 소유의 취소 시그널.
*
* 호출자별 취소를 위해 사용한다. `cancelGeneration()`은 모든 활성 요청을
* 취소하므로 인라인 제안처럼 자기 요청만 중단해야 하는 쪽은 이 시그널을 쓴다.
*/
signal?: AbortSignal
/** 요청별 생성 제한 시간(ms). */
timeoutMs?: number
/**
* Ollama keep_alive 값 (예: '30m').
*
* 제안처럼 반복 호출되는 경로에서 모델이 메모리에서 내려가면 요청마다
* 콜드 로딩(실측 20초+)을 다시 문다.
*/
keepAlive?: string
}
interface GenerateResult {
@ -57,6 +73,23 @@ interface OllamaGenerateResponse {
eval_count?: number
}
interface ChatStreamOptions {
model?: string
temperature?: number
maxTokens?: number
signal?: AbortSignal
timeoutMs?: number
keepAlive?: string
}
type AbortCause = 'timeout' | 'cancelled'
interface ActiveRequest {
controller: AbortController
abortCause: AbortCause | null
close: () => void
}
interface OllamaTagsResponse {
models: Array<{
name: string
@ -110,7 +143,8 @@ class LocalLLMService extends EventEmitter {
private _pollInterval: ReturnType<typeof setInterval> | null = null
private _available = false
private _serverVersion: string | null = null
private _abortController: AbortController | null = null
private _activeRequests = new Set<ActiveRequest>()
private _ensureRunningPromise: Promise<'running' | 'starting' | 'not-installed' | 'failed'> | null = null
private _disposed = false
get state(): LLMState {
@ -144,6 +178,22 @@ class LocalLLMService extends EventEmitter {
* - 'failed': 스폰 시도 실패
*/
async ensureRunning(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> {
if (this._ensureRunningPromise) {
return this._ensureRunningPromise
}
const task = this._ensureRunning()
this._ensureRunningPromise = task
try {
return await task
} finally {
if (this._ensureRunningPromise === task) {
this._ensureRunningPromise = null
}
}
}
private async _ensureRunning(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> {
if (await this._ping(1500)) {
logger.info('Ollama server already running')
return 'running'
@ -307,6 +357,7 @@ class LocalLLMService extends EventEmitter {
* Ollama 가용성 폴링을 시작한다 (5초 간격).
*/
startPolling(): void {
if (this._pollInterval) return
this._checkAvailability()
this._pollInterval = setInterval(() => {
if (this._state !== LLMState.Generating) {
@ -333,8 +384,8 @@ class LocalLLMService extends EventEmitter {
const serverUrl = getOllamaServerUrl()
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
this._state = LLMState.Generating
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 2048)
const request = this._beginRequest(options?.signal, options?.timeoutMs, 120000)
try {
const response = await fetch(`${serverUrl}/api/generate`, {
@ -345,15 +396,16 @@ class LocalLLMService extends EventEmitter {
prompt,
system: options?.systemPrompt,
stream: false,
keep_alive: options?.keepAlive,
// Ollama v0.20+ think 파라미터: reasoning 모델에서 thinking 토큰 생성 중단.
// gemma4/llama3.2 등 non-reasoning 모델에서는 무시됨.
think: false,
options: {
temperature: options?.temperature ?? 0.3,
num_predict: options?.maxTokens ?? 2048
num_predict: maxTokens
}
}),
signal: AbortSignal.timeout(120000)
signal: request.controller.signal
})
if (!response.ok) {
@ -373,22 +425,18 @@ class LocalLLMService extends EventEmitter {
totalDuration: data.total_duration ? data.total_duration / 1e6 : 0
}
this._state = LLMState.Available
this.emit('complete', { result })
return result
} catch (error) {
this._state = LLMState.Available
if (error instanceof D3ROError) throw error
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`LLM generation failed: ${error instanceof Error ? error.message : String(error)}`
)
throw this._toRequestError(error, request, 'LLM generation failed')
} finally {
request.close()
}
}
/**
* 스트리밍 텍스트 생성. NDJSON 파싱.
* 반환된 AbortController로 취소 가능.
* 호출자별 AbortSignal 또는 전역 cancelGeneration()으로 취소 가능.
*/
async *streamGenerate(
prompt: string,
@ -400,9 +448,10 @@ class LocalLLMService extends EventEmitter {
const serverUrl = getOllamaServerUrl()
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
this._state = LLMState.Generating
this._abortController = new AbortController()
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 2048)
const request = this._beginRequest(options?.signal, options?.timeoutMs, 120000)
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
let doneFrame = false
try {
const response = await fetch(`${serverUrl}/api/generate`, {
@ -413,13 +462,14 @@ class LocalLLMService extends EventEmitter {
prompt,
system: options?.systemPrompt,
stream: true,
keep_alive: options?.keepAlive,
think: false,
options: {
temperature: options?.temperature ?? 0.3,
num_predict: options?.maxTokens ?? 2048
num_predict: maxTokens
}
}),
signal: this._abortController.signal
signal: request.controller.signal
})
if (!response.ok || !response.body) {
@ -429,11 +479,22 @@ class LocalLLMService extends EventEmitter {
)
}
const reader = response.body.getReader()
reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let fullText = ''
let lastChunk: OllamaGenerateResponse | null = null
const complete = (): GenerateResult => {
const result: GenerateResult = {
text: fullText,
model: lastChunk?.model ?? model,
promptTokens: lastChunk?.prompt_eval_count ?? 0,
completionTokens: lastChunk?.eval_count ?? 0,
totalDuration: lastChunk?.total_duration ? lastChunk.total_duration / 1e6 : 0
}
this.emit('complete', { result })
return result
}
while (true) {
const { done, value } = await reader.read()
@ -448,45 +509,140 @@ class LocalLLMService extends EventEmitter {
try {
const chunk = JSON.parse(line) as OllamaGenerateResponse
fullText += chunk.response
this.emit('token', { token: chunk.response, done: chunk.done })
yield chunk.response
if (chunk.done) {
lastChunk = chunk
doneFrame = true
this.emit('token', { token: chunk.response, done: true })
if (chunk.response) yield chunk.response
return complete()
}
this.emit('token', { token: chunk.response, done: chunk.done })
yield chunk.response
} catch {
logger.warn(`Failed to parse NDJSON line: ${line.substring(0, 100)}`)
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
}
}
}
this._state = LLMState.Available
this._abortController = null
const result: GenerateResult = {
text: fullText,
model: lastChunk?.model ?? model,
promptTokens: lastChunk?.prompt_eval_count ?? 0,
completionTokens: lastChunk?.eval_count ?? 0,
totalDuration: lastChunk?.total_duration ? lastChunk.total_duration / 1e6 : 0
buffer += decoder.decode()
const trailing = buffer.trim()
if (trailing) {
try {
const chunk = JSON.parse(trailing) as OllamaGenerateResponse
fullText += chunk.response
if (chunk.done) {
lastChunk = chunk
doneFrame = true
this.emit('token', { token: chunk.response, done: true })
if (chunk.response) yield chunk.response
return complete()
}
this.emit('token', { token: chunk.response, done: chunk.done })
yield chunk.response
} catch {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
}
}
this.emit('complete', { result })
return result
if (!doneFrame) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama stream ended before completion')
}
return complete()
} catch (error) {
this._state = LLMState.Available
this._abortController = null
if (error instanceof D3ROError) throw error
if (error instanceof DOMException && error.name === 'AbortError') {
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM generation cancelled')
throw this._toRequestError(error, request, 'LLM streaming failed')
} finally {
if (reader) {
try {
await reader.cancel()
} catch (error) {
logger.warn(`LLM stream reader cancellation failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new D3ROError(
ErrorCode.LLMProcessingFailed,
`LLM streaming failed: ${error instanceof Error ? error.message : String(error)}`
)
if (!doneFrame) {
request.abortCause ??= 'cancelled'
request.controller.abort()
}
request.close()
}
}
/** 요청별 취소·기한을 활성 요청 집합에 등록한다. */
private _beginRequest(
externalSignal: AbortSignal | undefined,
timeoutMs: number | undefined,
defaultTimeoutMs: number
): ActiveRequest {
const controller = new AbortController()
const resolvedTimeoutMs = this._resolveTimeoutMs(timeoutMs, defaultTimeoutMs)
const abort = (cause: AbortCause): void => {
if (request.abortCause) return
request.abortCause = cause
controller.abort()
}
const onExternalAbort = (): void => abort('cancelled')
const timeout = setTimeout(() => abort('timeout'), resolvedTimeoutMs)
const request: ActiveRequest = {
controller,
abortCause: null,
close: () => {
clearTimeout(timeout)
externalSignal?.removeEventListener('abort', onExternalAbort)
this._activeRequests.delete(request)
this._refreshState()
}
}
this._activeRequests.add(request)
this._refreshState()
if (externalSignal?.aborted) {
onExternalAbort()
} else {
externalSignal?.addEventListener('abort', onExternalAbort, { once: true })
}
return request
}
private _resolveMaxTokens(maxTokens: number | undefined, fallback: number): number {
if (maxTokens === undefined) return fallback
if (!Number.isSafeInteger(maxTokens) || maxTokens <= 0) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'maxTokens must be a positive safe integer')
}
return Math.min(maxTokens, 4096)
}
private _resolveTimeoutMs(timeoutMs: number | undefined, fallback: number): number {
if (timeoutMs === undefined) return fallback
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'timeoutMs must be a positive safe integer')
}
return timeoutMs
}
private _toRequestError(error: unknown, request: ActiveRequest, prefix: string): D3ROError {
if (request.abortCause === 'timeout') {
return new D3ROError(ErrorCode.LLMProcessingTimeout, 'LLM generation timed out')
}
if (request.abortCause === 'cancelled' || request.controller.signal.aborted) {
return new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM generation cancelled')
}
if (error instanceof D3ROError) return error
return new D3ROError(
ErrorCode.LLMProcessingFailed,
`${prefix}: ${error instanceof Error ? error.message : String(error)}`
)
}
private _refreshState(): void {
this._state = this._disposed
? LLMState.Unavailable
: this._activeRequests.size > 0
? LLMState.Generating
: this._available
? LLMState.Available
: LLMState.Unavailable
}
/**
* 텍스트를 LLM 액션에 따라 처리한다.
*/
@ -531,9 +687,11 @@ class LocalLLMService extends EventEmitter {
}
cancelGeneration(): void {
if (this._abortController) {
this._abortController.abort()
this._abortController = null
if (this._activeRequests.size > 0) {
for (const request of this._activeRequests) {
if (!request.abortCause) request.abortCause = 'cancelled'
request.controller.abort()
}
logger.info('LLM generation cancelled')
}
}
@ -679,7 +837,7 @@ class LocalLLMService extends EventEmitter {
*/
async *chatStream(
messages: Array<{ role: string; content: string }>,
options?: { model?: string; temperature?: number },
options?: ChatStreamOptions,
): AsyncGenerator<string, string> {
if (!this._available) {
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama server not available')
@ -687,9 +845,10 @@ class LocalLLMService extends EventEmitter {
const serverUrl = getOllamaServerUrl()
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
this._abortController = new AbortController()
this._state = LLMState.Generating
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 512)
const request = this._beginRequest(options?.signal, options?.timeoutMs, 60000)
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
let doneFrame = false
try {
const response = await fetch(`${serverUrl}/api/chat`, {
@ -699,19 +858,21 @@ class LocalLLMService extends EventEmitter {
model,
messages,
stream: true,
keep_alive: options?.keepAlive,
think: false,
options: {
temperature: options?.temperature ?? 0.7,
num_predict: maxTokens,
},
}),
signal: this._abortController.signal,
signal: request.controller.signal,
})
if (!response.ok || !response.body) {
throw new D3ROError(ErrorCode.LLMProcessingFailed, `Chat API error: ${response.status}`)
}
const reader = response.body.getReader()
reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let accumulated = ''
@ -728,6 +889,9 @@ class LocalLLMService extends EventEmitter {
if (!line.trim()) continue
try {
const chunk = JSON.parse(line) as { message?: { content: string }; done: boolean }
if (chunk.done) {
doneFrame = true
}
if (chunk.message?.content) {
accumulated += chunk.message.content
yield chunk.message.content
@ -736,15 +900,47 @@ class LocalLLMService extends EventEmitter {
return accumulated
}
} catch {
// 불완전 JSON 무시
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
}
}
}
return accumulated
buffer += decoder.decode()
const trailing = buffer.trim()
if (trailing) {
try {
const chunk = JSON.parse(trailing) as { message?: { content: string }; done: boolean }
if (chunk.done) {
doneFrame = true
}
if (chunk.message?.content) {
accumulated += chunk.message.content
yield chunk.message.content
}
if (chunk.done) {
return accumulated
}
} catch {
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
}
}
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama chat stream ended before completion')
} catch (error) {
throw this._toRequestError(error, request, 'LLM chat streaming failed')
} finally {
this._state = this._available ? LLMState.Available : LLMState.Unavailable
this._abortController = null
if (reader) {
try {
await reader.cancel()
} catch (error) {
logger.warn(`LLM chat reader cancellation failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
if (!doneFrame) {
request.abortCause ??= 'cancelled'
request.controller.abort()
}
request.close()
}
}
@ -752,6 +948,7 @@ class LocalLLMService extends EventEmitter {
this._disposed = true
this.stopPolling()
this.cancelGeneration()
this._refreshState()
this.removeAllListeners()
logger.info('LocalLLMService disposed')
}
@ -800,11 +997,11 @@ class LocalLLMService extends EventEmitter {
if (detectedVersion) this._serverVersion = detectedVersion
if (!wasAvailable && this._available) {
this._state = LLMState.Available
this._refreshState()
this.emit('availability-changed', { available: true })
logger.info(`Ollama server connected (version: ${this._serverVersion ?? 'active'})`)
} else if (wasAvailable && !this._available) {
this._state = LLMState.Unavailable
this._refreshState()
this._serverVersion = null
this.emit('availability-changed', { available: false })
logger.warn('Ollama server disconnected')
@ -848,6 +1045,10 @@ export async function startLocalLLMAvailability(): Promise<void> {
}
export function resetLocalLLMServiceForTests(): void {
if (instance) instance.removeAllListeners()
instance?.dispose()
instance = null
}
export function disposeLocalLLMService(): void {
instance?.dispose()
}

View file

@ -354,6 +354,17 @@ class LocalSTTService extends EventEmitter {
}
}
/**
* 사이드카 프로세스만 보장하고 base URL 을 돌려준다 (모델 로딩 없음).
*
* 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도
* 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다.
*/
async ensureSidecar(): Promise<string> {
await this._ensureSidecarRunning()
return this._baseUrl
}
/**
* 녹음 중 실시간 미리보기 전사.
* 최종 결과와 분리되어 삽입되지 않으며, 실패해도 빈 문자열을 반환한다.

View file

@ -0,0 +1,392 @@
// src/main/services/PersonalGraphService.ts
//
// 개인 그래프 — 사용자의 문장을 노드로, 관계를 엣지로 저장하고 제안 문맥을 끌어온다.
//
// 왜: n-gram 문자열 조회는 "같은 꼬리 뒤" 만 찾는다. 실제 글은 관계로 이어진다 —
// 어떤 문장은 늘 다른 문장 뒤에 오고(follows), 어떤 문장들은 용어를 공유한다
// (shares_terms). 그 관계를 저장하면 접두가 조금 어긋나도 관련 문맥을 쓸 수 있다.
//
// 전부 로컬 SQLite 다 (임베딩/네트워크 없음). 학습 동의(inputLearnTypedText)가 없으면
// 표본이 저장되지 않으므로 그래프도 비어 있다.
import {
SHARES_TERMS_MIN_SIMILARITY,
buildSequenceEdges,
continuationsFrom,
extractTerms,
jaccard,
rankRelated,
splitSentences,
type GraphContext,
type PersonalGraphQuery,
type PersonalGraphStats,
type PhraseSource,
type RelatedCandidate
} from '@d3ro/core/personal-graph'
import { 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'
import { getLogger } from './LoggerService'
const logger = getLogger('PersonalGraphService')
/** 계약 타입은 core 정본을 따른다 (렌더러/프리로드와 공유). */
export type { PersonalGraphQuery, PersonalGraphStats }
/** 용어 공유 엣지 백필 시 한 번에 볼 노드 수. */
const MAINTENANCE_NODE_LIMIT = 300
/** 관계 조회에서 최근 노드를 훑는 수. */
const TERM_SCAN_LIMIT = 400
class PersonalGraphService {
/**
* 텍스트를 그래프에 반영한다.
*
* 문장마다 노드를 upsert 하고, 연속한 문장 쌍을 follows 엣지로, 용어를 충분히
* 공유하는 쌍을 shares_terms 엣지로 남긴다.
*/
indexText(
text: string,
meta: { source: PhraseSource; appName?: string | null; at?: number }
): void {
const sentences = splitSentences(text)
if (sentences.length === 0) return
const at = meta.at ?? Date.now()
try {
const db = getDatabase()
for (const sentence of sentences) {
const terms = extractTerms(sentence)
db.insert(personalPhrases)
.values({
id: crypto.randomUUID(),
phrase: sentence,
count: 1,
source: meta.source,
lastUsedAt: at,
createdAt: at,
terms: JSON.stringify(terms),
appName: meta.appName ?? null
})
.onConflictDoUpdate({
target: personalPhrases.phrase,
set: {
count: sql`${personalPhrases.count} + 1`,
lastUsedAt: at,
terms: JSON.stringify(terms),
appName: meta.appName ?? null
}
})
.run()
}
// follows 엣지
for (const edge of buildSequenceEdges(sentences)) {
this._upsertEdge(edge.from, edge.to, 'follows')
}
// 같은 텍스트 안에서 용어를 많이 공유하는 쌍
for (let i = 0; i < sentences.length; i += 1) {
const termsA = extractTerms(sentences[i])
for (let j = i + 1; j < sentences.length; j += 1) {
const termsB = extractTerms(sentences[j])
if (jaccard(termsA, termsB) < SHARES_TERMS_MIN_SIMILARITY) continue
this._upsertEdge(sentences[i], sentences[j], 'shares_terms')
}
}
} catch (error) {
logger.warn(`그래프 반영 실패: ${error instanceof Error ? error.message : String(error)}`)
}
}
private _upsertEdge(from: string, to: string, kind: 'follows' | 'shares_terms'): void {
const db = getDatabase()
const now = Date.now()
db.insert(phraseEdges)
.values({
id: crypto.randomUUID(),
fromPhrase: from,
toPhrase: to,
kind,
weight: 1,
updatedAt: now
})
.onConflictDoUpdate({
target: [phraseEdges.fromPhrase, phraseEdges.toPhrase, phraseEdges.kind],
set: {
weight: sql`${phraseEdges.weight} + 1`,
updatedAt: now
}
})
.run()
}
/**
* 제안 문맥을 그래프에서 끌어온다.
*
* 1) 접두 꼬리를 포함하는 노드를 찾고 그 뒤에 실제로 이어 쓴 텍스트를 뽑는다.
* 2) 그 노드들의 follows 이웃과 용어를 공유하는 노드를 순위화해 관련 문장으로 준다.
*/
retrieveContext(prefix: string, limit = 4): GraphContext {
const tail = prefixTail(prefix)
if (tail.length < 4) return { continuations: [], related: [] }
try {
const db = getDatabase()
const pattern = `%${tail.replace(/[%_\\]/gu, '')}%`
const anchorRows = db
.select()
.from(personalPhrases)
.where(sql`${personalPhrases.phrase} LIKE ${pattern}`)
.orderBy(desc(personalPhrases.lastUsedAt))
.limit(20)
.all()
if (anchorRows.length === 0) return { continuations: [], related: [] }
const anchorTexts = anchorRows.map((row) => row.phrase)
const continuations = continuationsFrom(anchorTexts, tail, 3)
const anchorTerms = new Set<string>()
for (const row of anchorRows) {
for (const term of parseTerms(row.terms)) anchorTerms.add(term)
}
const candidates: RelatedCandidate[] = []
const seen = new Set<string>(anchorTexts)
// (a) follows 이웃 — 가장 강한 관계
const edgeRows = db
.select()
.from(phraseEdges)
.where(
and(inArray(phraseEdges.fromPhrase, anchorTexts), eq(phraseEdges.kind, 'follows'))
)
.orderBy(desc(phraseEdges.weight))
.limit(40)
.all()
for (const edge of edgeRows) {
if (seen.has(edge.toPhrase)) continue
seen.add(edge.toPhrase)
candidates.push({
text: edge.toPhrase,
terms: [],
weight: edge.weight * 2,
lastUsedAt: null
})
}
// (b) 용어 공유 — 최근 노드를 훑어 유사도가 높은 것
const recentNodes = db
.select()
.from(personalPhrases)
.orderBy(desc(personalPhrases.lastUsedAt))
.limit(TERM_SCAN_LIMIT)
.all()
for (const node of recentNodes) {
if (seen.has(node.phrase)) continue
const terms = parseTerms(node.terms)
if (terms.length === 0) continue
const similarity = jaccard([...anchorTerms], terms)
if (similarity < SHARES_TERMS_MIN_SIMILARITY) continue
seen.add(node.phrase)
candidates.push({
text: node.phrase,
terms,
weight: 0,
lastUsedAt: node.lastUsedAt
})
}
const related = rankRelated([...anchorTerms], candidates, limit)
return { continuations, related }
} catch (error) {
logger.warn(
`그래프 문맥 조회 실패: ${error instanceof Error ? error.message : String(error)}`
)
return { continuations: [], related: [] }
}
}
/** 용어 공유 엣지를 백필한다 (기존 노드들 사이 관계 보강). */
runMaintenance(limit = MAINTENANCE_NODE_LIMIT): number {
try {
const db = getDatabase()
const nodes = db
.select()
.from(personalPhrases)
.orderBy(desc(personalPhrases.lastUsedAt))
.limit(limit)
.all()
let created = 0
for (let i = 0; i < nodes.length; i += 1) {
const termsA = parseTerms(nodes[i].terms)
if (termsA.length === 0) continue
for (let j = i + 1; j < nodes.length; j += 1) {
const termsB = parseTerms(nodes[j].terms)
if (termsB.length === 0) continue
if (jaccard(termsA, termsB) < SHARES_TERMS_MIN_SIMILARITY) continue
this._upsertEdge(nodes[i].phrase, nodes[j].phrase, 'shares_terms')
created += 1
if (created >= 200) return created
}
}
if (created > 0) logger.info(`그래프 유지보수: shares_terms 엣지 ${created}개 생성`)
return created
} catch (error) {
logger.warn(`그래프 유지보수 실패: ${error instanceof Error ? error.message : String(error)}`)
return 0
}
}
getStats(days = 30): PersonalGraphStats {
const empty: PersonalGraphStats = {
nodes: 0,
followsEdges: 0,
sharesTermsEdges: 0,
topEdges: [],
recentNodes: []
}
try {
const db = getDatabase()
const since = Date.now() - days * 86400000
const nodeCount = db
.select({ value: sql<number>`count(*)` })
.from(personalPhrases)
.get()
const follows = db
.select({ value: sql<number>`count(*)` })
.from(phraseEdges)
.where(eq(phraseEdges.kind, 'follows'))
.get()
const shares = db
.select({ value: sql<number>`count(*)` })
.from(phraseEdges)
.where(eq(phraseEdges.kind, 'shares_terms'))
.get()
const topEdges = db
.select({
from: phraseEdges.fromPhrase,
to: phraseEdges.toPhrase,
kind: phraseEdges.kind,
weight: phraseEdges.weight
})
.from(phraseEdges)
.orderBy(desc(phraseEdges.weight))
.limit(12)
.all()
const recentNodes = db
.select()
.from(personalPhrases)
.where(gte(personalPhrases.createdAt, since))
.orderBy(desc(personalPhrases.lastUsedAt))
.limit(20)
.all()
return {
nodes: Number(nodeCount?.value ?? 0),
followsEdges: Number(follows?.value ?? 0),
sharesTermsEdges: Number(shares?.value ?? 0),
topEdges,
recentNodes: recentNodes.map((row) => ({
text: row.phrase,
terms: parseTerms(row.terms),
count: row.count,
appName: row.appName
}))
}
} catch (error) {
logger.warn(`그래프 통계 실패: ${error instanceof Error ? error.message : String(error)}`)
return empty
}
}
/** 특정 텍스트 주변의 그래프를 조회한다 (설정/지식베이스 UI). */
query(text: string, limit = 12): PersonalGraphQuery {
try {
const db = getDatabase()
const needle = text.trim()
if (needle.length < 2) return { anchors: [], neighbors: [] }
const pattern = `%${needle.replace(/[%_\\]/gu, '')}%`
const anchors = db
.select()
.from(personalPhrases)
.where(sql`${personalPhrases.phrase} LIKE ${pattern}`)
.orderBy(desc(personalPhrases.lastUsedAt))
.limit(6)
.all()
if (anchors.length === 0) return { anchors: [], neighbors: [] }
const anchorTexts = anchors.map((row) => row.phrase)
const neighbors = db
.select({
text: phraseEdges.toPhrase,
kind: phraseEdges.kind,
weight: phraseEdges.weight
})
.from(phraseEdges)
.where(inArray(phraseEdges.fromPhrase, anchorTexts))
.orderBy(desc(phraseEdges.weight))
.limit(limit)
.all()
return {
anchors: anchors.map((row) => ({
text: row.phrase,
terms: parseTerms(row.terms),
count: row.count
})),
neighbors
}
} catch (error) {
logger.warn(`그래프 조회 실패: ${error instanceof Error ? error.message : String(error)}`)
return { anchors: [], neighbors: [] }
}
}
clearAll(): void {
try {
const db = getDatabase()
db.delete(phraseEdges).run()
logger.info('개인 그래프 엣지 전체 삭제')
} catch (error) {
logger.warn(`그래프 삭제 실패: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
/** DB 의 terms 컬럼(JSON)을 안전하게 파싱한다. */
function parseTerms(raw: string | null): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed.filter((value): value is string => typeof value === 'string')
} catch {
return []
}
}
let instance: PersonalGraphService | null = null
export function getPersonalGraphService(): PersonalGraphService {
if (!instance) instance = new PersonalGraphService()
return instance
}
export function resetPersonalGraphServiceForTests(): void {
instance = null
}

View file

@ -241,7 +241,7 @@ $title = $sb.ToString()
'-NonInteractive',
'-ExecutionPolicy', 'Bypass',
'-Command', psScript,
], { timeout: 3000 })
], { timeout: 3000, windowsHide: true })
const lines = stdout.trim().split('\n')
const appName = lines[0]?.trim() || null

File diff suppressed because it is too large Load diff

View file

@ -146,7 +146,7 @@ class TTSPlaybackService extends EventEmitter {
'-NonInteractive',
'-Command',
script,
], { stdio: 'pipe' })
], { stdio: 'pipe', windowsHide: true })
this._bindProcessHandlers(resolve, reject)
})

View file

@ -0,0 +1,214 @@
// src/main/services/UiaContextService.ts
//
// 사이드카의 UIA 브리지(`GET /uia/focus`)를 감싸는 유일한 창구.
//
// 사이드카인가:
// Win32 케어렛 API(GetGUIThreadInfo)는 Chromium/Electron 에서 동작하지 않고,
// Node UIA 바인딩(selection-hook / xa11y)은 케어 rect 나 IsPassword 를 노출하지
// 않는다. 이미 배포 중인 Python 사이드카에 UIA 브리지를 두는 것이 검증된 유일한 경로다.
//
// 안전 규칙:
// - 비밀번호 필드는 브리지가 fail-closed 로 차단하지만, 여기서도 한 번 더 막는다.
// - 읽기 실패/타임아웃은 텍스트를 쓰지 않는다 (available=false).
// - 실패 후에는 백오프를 걸어 매 키입력마다 사이드카를 두드리지 않는다.
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import {
emptyFocusSnapshot,
type FocusSnapshot,
type FocusTextSource,
type UiRect
} from '@d3ro/core/input-intelligence'
import { getLogger } from './LoggerService'
import { getLocalSTTService } from './LocalSTTService'
const logger = getLogger('UiaContextService')
const REQUEST_TIMEOUT_MS = 1500
/**
* 실패 후 재시도 대기 (ms).
*
* 최초 실패는 대부분 "사이드카가 아직 포트를 열지 않음" 이다 — 실측: 부팅 0.87초 후 첫 실패,
* 사이드카 준비는 1.1초 후였다. 20초를 통째로 백오프하면 앱을 켠 직후의 입력이 전부 죽으므로
* 일시 실패는 짧게 시작해 배수로 늘리고, 브리지 자체가 없는 경우(미지원 플랫폼)만 길게 잡는다.
*/
const BACKOFF_STEPS_MS = [1500, 3000, 6000, 12000, 30000] as const
/** 영구 실패(브리지 없음) 백오프 */
const BACKOFF_PERMANENT_MS = 5 * 60 * 1000
interface UiaBridgePayload {
available?: boolean
reason?: string
isPassword?: boolean
isEditable?: boolean
isComposing?: boolean
hasSelection?: boolean
controlType?: string | null
controlName?: string | null
className?: string | null
textSource?: string
text?: string
caretOffset?: number | null
caretRect?: UiRect | null
elementRect?: UiRect | null
windowTitle?: string | null
processId?: number | null
capturedAt?: number
}
class UiaContextService {
private _backoffUntil = 0
/** 연속 실패 수 — 백오프 단계 계산용 */
private _failureStreak = 0
private _lastReason = ''
private _inFlight: Promise<FocusSnapshot> | null = null
private _last: FocusSnapshot | null = null
private _lastSuccessAt = 0
get lastSnapshot(): FocusSnapshot | null {
return this._last
}
get lastSuccessAt(): number {
return this._lastSuccessAt
}
/** 브리지 사용 가능 여부 (백오프 상태면 false). */
isAvailable(now = Date.now()): boolean {
return now >= this._backoffUntil
}
get lastReason(): string {
return this._lastReason
}
/**
* 현재 포커스 입력 요소 스냅샷.
*
* 동시 호출은 하나의 요청으로 합친다 — 키 입력마다 부르므로 중복 방지가 중요하다.
*/
async getSnapshot(now = Date.now()): Promise<FocusSnapshot> {
if (!this.isAvailable(now)) {
return emptyFocusSnapshot(this._lastReason || 'backoff', now)
}
if (this._inFlight) return this._inFlight
this._inFlight = this._fetch(now)
try {
return await this._inFlight
} finally {
this._inFlight = null
}
}
/** 테스트/설정 변경 시 상태 초기화. */
reset(): void {
this._backoffUntil = 0
this._failureStreak = 0
this._lastReason = ''
}
private async _fetch(now: number): Promise<FocusSnapshot> {
let baseUrl: string
try {
baseUrl = await getLocalSTTService().ensureSidecar()
} catch (error) {
return this._fail(
`sidecar-unavailable:${error instanceof Error ? error.message : String(error)}`,
now
)
}
let payload: UiaBridgePayload
try {
const response = await fetch(`${baseUrl}/uia/focus`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})
if (!response.ok) {
return this._fail(`http-${response.status}`, now)
}
payload = (await response.json()) as UiaBridgePayload
} catch (error) {
return this._fail(
`request-failed:${error instanceof Error ? error.message : String(error)}`,
now
)
}
if (!payload.available) {
// 브리지가 없는 환경(미지원 플랫폼/미설치)은 길게 백오프한다.
return this._fail(payload.reason ?? 'unavailable', now)
}
const snapshot: FocusSnapshot = {
available: true,
isPassword: payload.isPassword === true,
isEditable: payload.isEditable === true,
isComposing: payload.isComposing === true,
hasSelection: payload.hasSelection === true,
controlType: payload.controlType ?? undefined,
controlName: payload.controlName ?? undefined,
className: payload.className ?? undefined,
textSource: normalizeTextSource(payload.textSource),
text: typeof payload.text === 'string' ? payload.text : '',
caretOffset: typeof payload.caretOffset === 'number' ? payload.caretOffset : null,
caretRect: payload.caretRect ?? null,
elementRect: payload.elementRect ?? null,
windowTitle: payload.windowTitle ?? null,
appName: null,
processId: typeof payload.processId === 'number' ? payload.processId : null,
capturedAt: typeof payload.capturedAt === 'number' ? payload.capturedAt : now
}
// 2차 방어: 비밀번호로 판정되면 텍스트를 즉시 버린다 (fail-closed).
if (snapshot.isPassword) {
snapshot.text = ''
snapshot.textSource = 'none'
snapshot.caretOffset = null
}
this._last = snapshot
this._lastSuccessAt = now
this._backoffUntil = 0
this._lastReason = ''
return snapshot
}
private _fail(reason: string, now: number): FocusSnapshot {
this._lastReason = reason
// 영구적 사용 불가(미지원 플랫폼/미설치)는 길게, 일시 오류는 단계적으로.
const permanent = reason === 'uiautomation-missing' || reason.startsWith('bridge-import')
const waitMs = permanent
? BACKOFF_PERMANENT_MS
: BACKOFF_STEPS_MS[Math.min(this._failureStreak, BACKOFF_STEPS_MS.length - 1)]
this._failureStreak += 1
this._backoffUntil = now + waitMs
logger.warn(
`UIA 스냅샷 사용 불가 (${reason}) — ${waitMs}ms 백오프 (연속 ${this._failureStreak}회)`
)
return emptyFocusSnapshot(reason, now)
}
}
function normalizeTextSource(value: string | undefined): FocusTextSource {
if (value === 'value' || value === 'text' || value === 'legacy') return value
return 'none'
}
let instance: UiaContextService | null = null
export function getUiaContextService(): UiaContextService {
if (!instance) instance = new UiaContextService()
return instance
}
export function resetUiaContextServiceForTests(): void {
instance = null
}
/** 진단용 — D3ROError 로 감싼 브리지 오류 생성. */
export function createUiaBridgeError(reason: string): D3ROError {
return new D3ROError(ErrorCode.UiaBridgeUnavailable, `UIA bridge unavailable: ${reason}`)
}

View file

@ -258,7 +258,7 @@ class VoiceActionService extends EventEmitter {
private _openApp(appName: string): Promise<void> {
return new Promise((resolve, reject) => {
const cmd = `start "" "${appName}"`
exec(cmd, { shell: 'cmd.exe' }, (err) => {
exec(cmd, { shell: 'cmd.exe', windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})
@ -277,7 +277,7 @@ class VoiceActionService extends EventEmitter {
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys([char]175)
`
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})
@ -288,7 +288,7 @@ class VoiceActionService extends EventEmitter {
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys([char]174)
`
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})
@ -299,7 +299,7 @@ class VoiceActionService extends EventEmitter {
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys([char]173)
`
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})
@ -318,7 +318,7 @@ class VoiceActionService extends EventEmitter {
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys('${sendKeysStr}')
`
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})
@ -336,7 +336,7 @@ class VoiceActionService extends EventEmitter {
private _runCommand(command: string): Promise<void> {
return new Promise((resolve, reject) => {
exec(command, { timeout: 10000 }, (err) => {
exec(command, { timeout: 10000, windowsHide: true }, (err) => {
if (err) reject(err)
else resolve()
})

View file

@ -37,6 +37,7 @@ class VoiceConversationService extends EventEmitter {
private _audioBuffers: Buffer[] = []
private _audioListenerBound = false
private _audioLevelListenerBound = false
private _responseAbortController: AbortController | null = null
get state(): ConversationState {
return this._state
@ -105,6 +106,7 @@ class VoiceConversationService extends EventEmitter {
if (!this._isActive) return
this._stopListening()
this._responseAbortController?.abort()
getTTSPlaybackService().stop()
getPremiumLLMService().cancelGeneration()
@ -128,9 +130,10 @@ class VoiceConversationService extends EventEmitter {
* 현재 LLM 응답 또는 TTS 재생을 취소.
*/
cancelResponse(): void {
this._responseAbortController?.abort()
getPremiumLLMService().cancelGeneration()
getTTSPlaybackService().stop()
if (this._isActive) {
if (this._isActive && this._state !== 'listening') {
this._setState('listening')
this._startListening()
}
@ -238,6 +241,8 @@ class VoiceConversationService extends EventEmitter {
const language = (configGet('sttLanguage') as string | undefined) ?? 'auto'
const result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
if (!this._isActive) return
if (!result.text || result.text.trim().length === 0) {
// Bug 13: VAD가 전체 오디오를 무음 판정한 경우에도 사용자 피드백.
this._emitError('stt', 'No speech detected. Check microphone and try again.')
@ -248,6 +253,7 @@ class VoiceConversationService extends EventEmitter {
await this._processUserMessage(result.text.trim())
} catch (err) {
if (!this._isActive) return
logger.error('STT failed in conversation:', err)
this._emitError('stt', err instanceof Error ? err.message : 'STT failed')
this._setState('listening')
@ -256,6 +262,13 @@ class VoiceConversationService extends EventEmitter {
}
private async _processUserMessage(text: string): Promise<void> {
if (this._responseAbortController) {
throw new D3ROError(ErrorCode.ConversationLLMFailed, 'Conversation response is already in progress')
}
const responseAbortController = new AbortController()
this._responseAbortController = responseAbortController
// 사용자 메시지 추가
const userMsg: ConversationMessage = {
id: crypto.randomUUID(),
@ -271,7 +284,7 @@ class VoiceConversationService extends EventEmitter {
try {
// LLM 백엔드 선택: premium 설정 + 사용 가능 → PremiumLLM, 아니면 LocalLLM
const { generator: chatGenerator, backend } = await this._createChatStream()
const { generator: chatGenerator, backend } = await this._createChatStream(responseAbortController.signal)
logger.info(`Conversation LLM backend: ${backend}`)
const assistantMsgId = crypto.randomUUID()
@ -280,6 +293,8 @@ class VoiceConversationService extends EventEmitter {
let sentenceBuffer = ''
for await (const token of chatGenerator) {
if (!this._isCurrentResponse(responseAbortController)) return
accumulated += token
// 렌더러에 델타 전송
@ -308,6 +323,8 @@ class VoiceConversationService extends EventEmitter {
}
}
if (!this._isCurrentResponse(responseAbortController)) return
// 남은 텍스트도 TTS 큐에 추가
if (sentenceBuffer.trim()) {
ttsSentences.push(sentenceBuffer.trim())
@ -331,12 +348,16 @@ class VoiceConversationService extends EventEmitter {
// TTS 재생
if (ttsSentences.length > 0) {
if (!this._isCurrentResponse(responseAbortController)) return
this._setState('speaking')
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED, {})
const ttsService = getTTSPlaybackService()
await ttsService.speakSentences(ttsSentences)
if (!this._isCurrentResponse(responseAbortController)) return
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED, {})
// 응답 완료 chime (자동 listening 재진입 직전)
@ -349,12 +370,18 @@ class VoiceConversationService extends EventEmitter {
this._startListening()
}
} catch (err) {
if (responseAbortController.signal.aborted) return
logger.error('LLM chat failed in conversation:', err)
this._emitError('llm', err instanceof Error ? err.message : 'LLM failed')
if (this._isActive) {
this._setState('listening')
this._startListening()
}
} finally {
if (this._responseAbortController === responseAbortController) {
this._responseAbortController = null
}
}
}
@ -362,7 +389,7 @@ class VoiceConversationService extends EventEmitter {
* LLM 백엔드 선택 + chatStream 생성.
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback.
*/
private async _createChatStream(): Promise<{
private async _createChatStream(signal: AbortSignal): Promise<{
generator: AsyncGenerator<string, string>
backend: 'local' | 'premium'
}> {
@ -389,7 +416,21 @@ class VoiceConversationService extends EventEmitter {
'Local LLM (Ollama) is not available',
)
}
return { generator: local.chatStream(chatMessages), backend: 'local' }
return {
generator: local.chatStream(chatMessages, {
signal,
maxTokens: 512,
timeoutMs: 60_000,
keepAlive: '2m',
}),
backend: 'local',
}
}
private _isCurrentResponse(responseAbortController: AbortController): boolean {
return this._isActive
&& this._responseAbortController === responseAbortController
&& !responseAbortController.signal.aborted
}
private _buildChatMessages(): Array<{ role: string; content: string }> {

View file

@ -0,0 +1,69 @@
// src/main/services/global-input-hook.ts
// uiohook 글로벌 후킹의 소유권을 참조 카운트로 관리한다.
//
// uiohook 은 프로세스 전역 싱글턴이다. KeyBindingService InputTelemetryService 가
// 각자 start()/stop() 을 부르면 두 번째 start() 는 무시되거나, 먼저 끝난 쪽의 stop() 이
// 상대방 수신까지 죽인다. 그래서 "후킹이 켜져 있어야 하는 소비자 수"를 세고
// 0 이 될 때만 실제로 춘다.
import { uIOhook } from 'uiohook-napi'
import { getLogger } from './LoggerService'
const logger = getLogger('GlobalInputHook')
let refCount = 0
let running = false
/**
* 글로벌 후킹을 (필요하면) 시작하고 해제 함수를 돌려준다.
*
* 반드시 반환된 함수를 호출해야 한다. 시작에 실패하면 예외를 던지고 카운트를 되돌린다.
*/
export function acquireGlobalInputHook(): () => void {
refCount += 1
if (!running) {
try {
uIOhook.start()
running = true
logger.info('uiohook started (global input hook active)')
} catch (error) {
refCount = Math.max(0, refCount - 1)
throw error
}
}
let released = false
return () => {
if (released) return
released = true
refCount = Math.max(0, refCount - 1)
if (refCount === 0 && running) {
try {
uIOhook.stop()
logger.info('uiohook stopped (no consumer left)')
} catch (error) {
logger.error(
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
running = false
}
}
}
}
export function isGlobalInputHookRunning(): boolean {
return running
}
export function getGlobalInputHookRefCount(): number {
return refCount
}
/** 테스트 전용 — 모듈 상태 초기화. */
export function resetGlobalInputHookForTests(): void {
refCount = 0
running = false
}

View file

@ -143,3 +143,98 @@ export function resolveSystemPrompt(
}
export { BASE_SYSTEM_PROMPTS }
// ============================================================
// 다음 문장 제안 (ghost text) — 프롬프트 정본
// ============================================================
/**
* 추론 모델이 `thinking` 블록을 지 않게 하는 prefix.
*
* `LocalLLMService.processText` 가 같은 목적으로 쓰는 값과 같다. 제안 경로는
* `streamGenerate` 를 직접 부르므로 여기서 직접 붙인다.
*/
export const SUGGESTION_NO_THINK_PREFIX = '/no_think'
/**
* 제안 시스템 프롬프트.
*
* 지시문은 **시스템 프롬프트에만** 둔다. 지시문이 처리 대상 텍스트 자리에 들어가
* 지시문 자체를 다듬어 돌려주던 회귀(밋 9c2b4d4)와 같은 부류의 사고를 막는다.
*/
const SUGGESTION_SYSTEM_PROMPT = `사용자가 지금 입력창에 글을 쓰는 중입니다. 사용자가 마지막으로 쓴 글 뒤에 이어질 다음 문장을 제안하세요.
규칙:
- 사용자가 언어와 같은 언어로 쓰세요.
- 사용자의 말투와 문체를 유지하세요. 존댓말/반말, 격식/비격식을 바꾸지 마세요.
- 이미 나온 단어를 되풀이하지 말고 이어지는 내용만 쓰세요.
- 후보를 한 줄에 하나씩, 번호나 따옴표, 설명 없이 출력하세요.
- 각 후보는 한 문장, 최대 {{maxChars}}자입니다.
- 확신이 없으면 짧게 쓰세요. 리말, 요약, 빈 줄을 출력하지 마세요.`
export interface SuggestionPromptInput {
/** 어렛 앞까지의 텍스트 */
prefix: string
/** 활성 앱/창 (맥락 트) */
appName?: string | null
windowTitle?: string | null
/** 사용자가 자주 쓰는 표현 (개인화 힌트) */
phraseHints?: readonly string[]
/**
* 과거에 같은 꼬리 뒤에 실제로 이어 쓴 문장 (개인 기억).
*
* 자주 쓰는 표현보다 훨씬 강한 문맥 신호다 — 사용자 자신의 실제 이어쓰기다.
*/
continuationHints?: readonly string[]
/** 후보 개수 */
candidates?: number
/** 후보당 최대 길이 */
maxChars?: number
}
/**
* 제안 요청용 (systemPrompt, text) 를 만든다.
*
* 시스템 프롬프트에 지시문, 사용자 메시지에는 **지금까지 쓴 텍스트와 맥락**만 는다.
*/
export function buildSuggestionPrompt(input: SuggestionPromptInput): {
systemPrompt: string
text: string
} {
const candidates = Math.max(1, Math.min(input.candidates ?? 3, 5))
const maxChars = Math.max(20, Math.min(input.maxChars ?? 160, 400))
const sections: string[] = ['[지금까지 텍스트]', input.prefix]
if (input.phraseHints && input.phraseHints.length > 0) {
sections.push('', '[자주 쓰는 표현]')
for (const hint of input.phraseHints.slice(0, 5)) {
sections.push(`- ${hint}`)
}
}
if (input.appName || input.windowTitle) {
sections.push('', '[현재 앱]')
const app = [input.appName, input.windowTitle].filter(Boolean).join(' — ')
sections.push(app)
}
if (input.continuationHints && input.continuationHints.length > 0) {
sections.push('', '[과거에 사용자가 비슷한 문장 뒤에 실제로 이어 쓴 내용]')
for (const hint of input.continuationHints.slice(0, 3)) {
sections.push(`- ${hint}`)
}
sections.push('위 내용은 문체와 맥락 참고용입니다. 그대로 복사하지 말고 이어질 문장을 새로 쓰세요.')
}
sections.push('', `이어질 다음 문장 ${candidates}개를 한 줄씩 출력하세요.`)
const systemPrompt = `${SUGGESTION_NO_THINK_PREFIX}\n${SUGGESTION_SYSTEM_PROMPT.replace(
'{{maxChars}}',
String(maxChars)
)}`
return { systemPrompt, text: sections.join('\n') }
}
export { SUGGESTION_SYSTEM_PROMPT }

View file

@ -0,0 +1,180 @@
// src/main/utils/win32-foreground.ts
//
// koffi(FFI)로 포그라운드 창 정보를 읽는다 — 창 제목 · 프로세스 · 실행 파일 · 창 rect.
//
// 왜 koffi 인가 (조사 결과):
// - `uiohook-napi` 는 키/마우스만 주고 포그라운드 창 정보는 없다.
// - `get-windows`(active-win 후속)는 ESM 전용 + 설치 시 node-pre-gyp 다운로드가
// 필요한데 이 저장소는 install script 를 허용하지 않는다 (빌드가 깨진다).
// - `koffi` 3.3.1 은 N-API 8 prebuild 를 optional dep 로 배포해 install script 없이
// 동작한다. User32/Kernel32 호출만 쓰므로 COM vtable 을 다 필요도 없다.
//
// 실패(비 Windows, 모듈 로드 실패)는 예외 대신 null 로 돌려준다 — 호출자는
// 앱 이름 없이도 텔레메트리를 계속 수집할 수 있어야 한다.
import { getLogger } from '../services/LoggerService'
import type { UiRect } from '@d3ro/core/input-intelligence'
const logger = getLogger('win32-foreground')
export interface ForegroundWindowInfo {
/** HWND (숫자로 정규화) */
hwnd: number
title: string
processId: number
/** 실행 파일명 (예: chrome.exe). 알 수 없으면 '' */
appName: string
/** 실행 파일 전체 경로 */
appPath: string | null
/** 창 rect (스크린 좌표) */
bounds: UiRect | null
}
interface KoffiRect {
left: number
top: number
right: number
bottom: number
}
type AnyFunc = (...args: unknown[]) => unknown
interface Win32Api {
koffi: { decode: (buffer: Buffer, type: string, length: number) => string }
GetForegroundWindow: AnyFunc
GetWindowTextW: AnyFunc
GetWindowThreadProcessId: AnyFunc
GetWindowRect: AnyFunc
OpenProcess: AnyFunc
QueryFullProcessImageNameW: AnyFunc
CloseHandle: AnyFunc
}
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
const TITLE_BUFFER_CHARS = 512
const PATH_BUFFER_CHARS = 1024
let api: Win32Api | null = null
let loadFailed = false
function loadApi(): Win32Api | null {
if (api) return api
if (loadFailed) return null
if (process.platform !== 'win32') {
loadFailed = true
return null
}
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const koffi = require('koffi') as {
load: (lib: string) => { func: (signature: string) => AnyFunc }
struct: (name: string, fields: Record<string, string>) => unknown
decode: (buffer: Buffer, type: string, length: number) => string
}
const user32 = koffi.load('user32.dll')
const kernel32 = koffi.load('kernel32.dll')
koffi.struct('RECT', { left: 'long', top: 'long', right: 'long', bottom: 'long' })
api = {
koffi: { decode: koffi.decode },
GetForegroundWindow: user32.func('void *GetForegroundWindow()'),
GetWindowTextW: user32.func(
'int GetWindowTextW(void *hWnd, _Out_ uint16_t *lpString, int nMaxCount)'
),
GetWindowThreadProcessId: user32.func(
'uint32_t GetWindowThreadProcessId(void *hWnd, _Out_ uint32_t *lpdwProcessId)'
),
GetWindowRect: user32.func('bool GetWindowRect(void *hWnd, _Out_ RECT *rect)'),
OpenProcess: kernel32.func('void *OpenProcess(uint32_t access, bool inherit, uint32_t pid)'),
QueryFullProcessImageNameW: kernel32.func(
'bool QueryFullProcessImageNameW(void *hProcess, uint32_t flags, _Out_ uint16_t *lpExeName, _Inout_ uint32_t *size)'
),
CloseHandle: kernel32.func('bool CloseHandle(void *handle)')
}
return api
} catch (error) {
loadFailed = true
logger.warn(
`koffi/Win32 FFI 사용 불가 — 포그라운드 정보 없이 계속 (${
error instanceof Error ? error.message : String(error)
})`
)
return null
}
}
/** 포그라운드 창 정보. Windows 가 아니거나 FFI 로드 실패면 null. */
export function getForegroundWindowInfo(): ForegroundWindowInfo | null {
const a = loadApi()
if (!a) return null
try {
const hwndRaw = a.GetForegroundWindow() as bigint | number | null
if (hwndRaw === null) return null
const hwnd = Number(hwndRaw)
if (!Number.isFinite(hwnd) || hwnd === 0) return null
const titleBuffer = Buffer.alloc(TITLE_BUFFER_CHARS * 2)
const titleLength = Number(a.GetWindowTextW(hwnd, titleBuffer, TITLE_BUFFER_CHARS) as number)
const title =
titleLength > 0
? a.koffi.decode(titleBuffer, 'char16_t', titleLength).replace(/\0.*$/su, '')
: ''
const pidBuffer = Buffer.alloc(4)
a.GetWindowThreadProcessId(hwnd, pidBuffer)
const processId = pidBuffer.readUInt32LE(0)
const rect: KoffiRect = { left: 0, top: 0, right: 0, bottom: 0 }
const rectOk = a.GetWindowRect(hwnd, rect) as boolean
const bounds: UiRect | null = rectOk
? {
x: Number(rect.left),
y: Number(rect.top),
width: Number(rect.right) - Number(rect.left),
height: Number(rect.bottom) - Number(rect.top)
}
: null
let appPath: string | null = null
const processHandle = a.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, processId) as
| bigint
| number
| null
if (processHandle) {
try {
const sizeBuffer = Buffer.alloc(4)
sizeBuffer.writeUInt32LE(PATH_BUFFER_CHARS, 0)
const pathBuffer = Buffer.alloc(PATH_BUFFER_CHARS * 2)
const pathOk = a.QueryFullProcessImageNameW(
processHandle,
0,
pathBuffer,
sizeBuffer
) as boolean
if (pathOk) {
const length = sizeBuffer.readUInt32LE(0)
appPath = a.koffi.decode(pathBuffer, 'char16_t', length).replace(/\0.*$/su, '')
}
} finally {
a.CloseHandle(processHandle)
}
}
const appName = appPath ? (appPath.split(/[\\/]/u).pop() ?? '') : ''
return { hwnd, title, processId, appName, appPath, bounds }
} catch (error) {
logger.warn(
`포그라운드 창 조회 실패: ${error instanceof Error ? error.message : String(error)}`
)
return null
}
}
/** 테스트 전용 — 지연 로드 상태 초기화. */
export function resetWin32ForegroundForTests(): void {
api = null
loadFailed = false
}

View file

@ -5,6 +5,7 @@ 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 { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { getLogger } from '../services/LoggerService'
import { getIsQuitting } from '../lifecycle'
@ -59,6 +60,19 @@ function getPopupI18nStrings(): Record<string, string> {
errorDefault: t('popup.error.default'),
// caption-overlay
captionLoading: t('popup.caption.loading'),
// suggestion-overlay
suggestionHintAccept: t('popup.suggestion.hintAccept'),
suggestionHintNext: t('popup.suggestion.hintNext'),
suggestionHintDismiss: t('popup.suggestion.hintDismiss'),
suggestionWarming: t('popup.suggestion.warming'),
suggestionHintGenerating: t('popup.suggestion.hintGenerating'),
suggestionLoading: t('popup.suggestion.loading'),
suggestionSourceModel: t('popup.suggestion.sourceModel'),
suggestionSourceMemory: t('popup.suggestion.sourceMemory'),
suggestionContinuations: t('popup.suggestion.continuations'),
suggestionRelated: t('popup.suggestion.related'),
suggestionPhrases: t('popup.suggestion.phrases'),
suggestionAppPhrases: t('popup.suggestion.appPhrases'),
}
}
@ -163,6 +177,7 @@ let resultPopupWindow: BrowserWindow | null = null
let historyPopupWindow: BrowserWindow | null = null
let commandPopupWindow: BrowserWindow | null = null
let captionOverlayWindow: BrowserWindow | null = null
let suggestionOverlayWindow: BrowserWindow | null = null
// ── 메인 윈도우 ───────────────────────────────────────
@ -706,7 +721,177 @@ export function sendToCaptionOverlay(channel: string, data: unknown): void {
}
}
// ── 프리로딩 ──────────────────────────────────────────
// ── SuggestionOverlay 업 (입력 인텔리전스) ──────────
/**
* 다음 문장 제안 ghost text 오버레이.
*
* IME 후보창과 같은 관례를 따른다: 케어/포커스 요소 바로 아래에 붙고,
* 포커스를 막지 않으며(focusable:false) 기본은 클릭 통과다.
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략)
*/
const SUGGESTION_OVERLAY_WIDTH = 460
/** 후보 5개 + 스크롤을 담을 높이 (목록은 내부 스크롤) */
const SUGGESTION_OVERLAY_HEIGHT = 258
function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
const interactive = configGet('suggestionOverlayInteractive') !== false
try {
if (interactive) {
// 클릭을 받아 수락할 수 있게 한다. focusable:false 라 대상 앱 포커스는 유지된다.
win.setIgnoreMouseEvents(false)
} else {
win.setIgnoreMouseEvents(true, { forward: true })
}
} catch (err) {
logger.warn(
`SuggestionOverlay 마우스 모드 적용 실패: ${err instanceof Error ? err.message : String(err)}`
)
}
}
function createSuggestionOverlayWindow(): BrowserWindow {
const win = new BrowserWindow({
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT,
show: false,
frame: false,
transparent: true,
resizable: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false,
webPreferences: {
preload: join(__dirname, '../preload/popup.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false
}
})
applySuggestionOverlayMouseMode(win)
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/suggestion-overlay/index.html`)
} else {
win.loadFile(join(__dirname, '../renderer/popups/suggestion-overlay/index.html'))
}
attachPopupLifecycle(win, 'suggestion-overlay')
win.on('closed', () => {
suggestionOverlayWindow = null
})
return win
}
export function getSuggestionOverlayWindow(): BrowserWindow {
if (!suggestionOverlayWindow || suggestionOverlayWindow.isDestroyed()) {
suggestionOverlayWindow = createSuggestionOverlayWindow()
logger.info('SuggestionOverlay window created')
}
return suggestionOverlayWindow
}
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
export function showSuggestionOverlay(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
anchor: { x: number; y: number; width: number; height: number } | null
appName: string | null
}): void {
const win = getSuggestionOverlayWindow()
const cursor = screen.getCursorScreenPoint()
const anchorPoint = payload.anchor
? { x: payload.anchor.x, y: payload.anchor.y + payload.anchor.height }
: cursor
const display = screen.getDisplayNearestPoint(anchorPoint)
const position = anchorFloatingPanel(
payload.anchor,
cursor,
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
display.workArea
)
win.setBounds({
x: position.x,
y: position.y,
width: SUGGESTION_OVERLAY_WIDTH,
height: SUGGESTION_OVERLAY_HEIGHT
})
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
candidates: payload.candidates,
activeIndex: payload.activeIndex,
generating: payload.generating === true,
warmingUp: payload.warmingUp === true,
partialText: payload.partialText ?? null,
appName: payload.appName,
provenance: payload.provenance ?? null,
_i18n: getPopupI18nStrings()
})
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 {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, payload)
}
}
export function hideSuggestionOverlay(): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {})
suggestionOverlayWindow.hide()
}
}
export function isSuggestionOverlayVisible(): boolean {
return !!(
suggestionOverlayWindow &&
!suggestionOverlayWindow.isDestroyed() &&
suggestionOverlayWindow.isVisible()
)
}
/** 설정(클릭 허용) 변경 시 즉시 반영. */
export function applySuggestionOverlayConfig(): void {
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
applySuggestionOverlayMouseMode(suggestionOverlayWindow)
}
}
// ─ 프리로딩 ──────────────────────────────────────────
export function preloadPopupWindows(): void {
getRecordingTipWindow()
@ -716,7 +901,7 @@ export function preloadPopupWindows(): void {
logger.info('Popup windows preloaded')
}
// ── 테마 재주입 (설정에서 테마 변경 시 호출) ──────────
// ── 테마 재주입 (설정에서 테마 변경 시 호출) ───────────
/**
* 현재 살아있는 팝업 윈도우에 테마 CSS를 재주입.
@ -729,6 +914,7 @@ export function reapplyThemeToAllPopups(): void {
historyPopupWindow,
commandPopupWindow,
captionOverlayWindow,
suggestionOverlayWindow,
]
for (const win of popupWindows) {
if (win && !win.isDestroyed()) {

View file

@ -882,6 +882,114 @@ const electronAPI = {
getSubscriptionStatus: () =>
invoke<import('@d3ro/core/types').SubscriptionStatusResult>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
},
// ── Input telemetry (입력 수집 동의 · 리포트) ───────────
inputTelemetry: {
getState: () =>
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
IPC_CHANNELS.INPUT_TELEMETRY.GET_STATE
),
setEnabled: (params: { enabled: boolean }) =>
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED,
params
),
setPaused: (params: { paused: boolean }) =>
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED,
params
),
getSummary: (params?: { days?: number }) =>
invoke<import('@d3ro/core/input-intelligence').InputInsightsSummary>(
IPC_CHANNELS.INPUT_TELEMETRY.GET_SUMMARY,
params
),
getPrivacyReceipt: () =>
invoke<import('@d3ro/core/input-intelligence').InputPrivacyReceipt>(
IPC_CHANNELS.INPUT_TELEMETRY.GET_PRIVACY_RECEIPT
),
getPhrases: (params?: { limit?: number }) =>
invoke<import('@d3ro/core/input-intelligence').PersonalPhrase[]>(
IPC_CHANNELS.INPUT_TELEMETRY.GET_PHRASES,
params
),
deletePhrase: (params: { id: string }) =>
invoke<boolean>(IPC_CHANNELS.INPUT_TELEMETRY.DELETE_PHRASE, params),
clearAll: () => invoke<void>(IPC_CHANNELS.INPUT_TELEMETRY.CLEAR_ALL),
getGraph: () =>
invoke<import('@d3ro/core/personal-graph').PersonalGraphStats>(
IPC_CHANNELS.INPUT_TELEMETRY.GET_GRAPH
),
queryGraph: (params: { text: string; limit?: number }) =>
invoke<import('@d3ro/core/personal-graph').PersonalGraphQuery>(
IPC_CHANNELS.INPUT_TELEMETRY.QUERY_GRAPH,
params
),
onStateChanged: (
cb: (state: import('@d3ro/core/input-intelligence').InputTelemetryState) => void
): Unsubscribe => on(IPC_CHANNELS.INPUT_TELEMETRY.STATE_CHANGED, cb),
onActivity: (
cb: (payload: {
totals: import('@d3ro/core/input-intelligence').InputActivityBucket
appName: string | null
}) => void
): Unsubscribe => on(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, cb),
},
// ── 다음 문장 제안 (ghost text) ────────────────────────
suggestion: {
getState: () =>
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
IPC_CHANNELS.SUGGESTION.GET_STATE
),
setConfig: (params: {
enabled?: boolean
modelId?: string | null
triggerDelayMs?: number
minPrefixChars?: number
maxRequestsPerMinute?: number
dailyBudget?: number
overlayInteractive?: boolean
learnTypedText?: boolean
excludedApps?: string[]
requestTimeoutMs?: number
}) =>
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
IPC_CHANNELS.SUGGESTION.SET_CONFIG,
params
),
requestNow: () => invoke<{ ok: boolean; reason?: string }>(IPC_CHANNELS.SUGGESTION.REQUEST_NOW),
accept: (params?: { index?: number }) =>
invoke<{ ok: boolean; reason?: string }>(IPC_CHANNELS.SUGGESTION.ACCEPT, params),
next: () =>
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
IPC_CHANNELS.SUGGESTION.NEXT
),
prev: () =>
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
IPC_CHANNELS.SUGGESTION.PREV
),
dismiss: () => invoke<void>(IPC_CHANNELS.SUGGESTION.DISMISS),
getHistory: (params?: { limit?: number }) =>
invoke<
Array<{
id: string
appName: string | null
prefixText: string
suggestionText: string
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
}>
>(IPC_CHANNELS.SUGGESTION.GET_HISTORY, params),
onUpdated: (cb: (state: import('@d3ro/core/input-intelligence').SuggestionState) => void): Unsubscribe =>
on(IPC_CHANNELS.SUGGESTION.UPDATED, cb),
onCleared: (cb: (payload: { reason: string }) => void): Unsubscribe =>
on(IPC_CHANNELS.SUGGESTION.CLEARED, cb),
onStateChanged: (cb: (state: import('@d3ro/core/input-intelligence').SuggestionState) => void): Unsubscribe =>
on(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, cb),
},
} as const
contextBridge.exposeInMainWorld('electronAPI', electronAPI)

View file

@ -1,7 +1,7 @@
// src/renderer/components/SettingsModal.tsx
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Dialog,
DialogTitle,
@ -22,19 +22,21 @@ import {
Button,
Paper,
} from '@mui/material'
import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, TextCursorInput } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { LicenseTab } from './LicenseTab'
import { CloudSyncSection } from './CloudSyncSection'
import { STTTab } from './STTTab'
import { InputConsentPanel } from './input-insights/InputConsentPanel'
import { KeyBindingField } from './keybinding/KeyBindingField'
import { asTranslationKey } from './keybinding/translation-key'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
import { KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
interface SettingsModalProps {
open: boolean
@ -57,9 +59,10 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> = {
voice: 'keybinding.ui.sectionVoice',
window: 'keybinding.ui.sectionWindow',
input: 'keybinding.ui.sectionInput',
}
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window']
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input']
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n()
@ -67,6 +70,11 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(null)
const keybindingMap = useKeyBindingMap()
const keybindingIssues = useMemo(
() => (keybindingMap === null ? [] : auditKeyBindingMap(keybindingMap)),
[keybindingMap]
)
useEffect(() => {
if (open && initialTab !== undefined) {
@ -229,6 +237,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.stt')} />
<Tab label={t('settings.tabs.llm')} />
<Tab label={t('input.tab')} icon={<TextCursorInput size={14} />} iconPosition="start" />
<Tab label={t('license.nav')} icon={<Lock size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.cloud') ?? 'Cloud'} icon={<Cloud size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.about')} />
@ -259,6 +268,45 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
/>
</Box>
<Box
sx={{
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${keybindingIssues.length === 0 ? d3roPalette.border.subtle : d3roPalette.status.warning}`,
bgcolor: d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.5
}}
aria-live="polite"
>
<Typography sx={{ fontSize: d3roTypo.label.size, color: d3roPalette.text.secondary }}>
{keybindingIssues.length === 0
? t('keybinding.ui.auditClear')
: t('keybinding.ui.auditIssues', { count: keybindingIssues.length })}
</Typography>
{keybindingIssues.slice(0, 3).map((issue) => {
const action = KEYBINDING_ACTIONS.find((entry) => entry.id === issue.actionId)
const actionLabel = action ? t(asTranslationKey(action.labelKey)) : issue.actionId
const conflictLabels = issue.conflictActionIds
.map((actionId) => {
const conflict = KEYBINDING_ACTIONS.find((entry) => entry.id === actionId)
return conflict ? t(asTranslationKey(conflict.labelKey)) : actionId
})
.join(', ')
return (
<Typography key={`${issue.actionId}-${issue.bindingIndex}-${issue.kind}`} sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.text.secondary }}>
{issue.kind === 'invalid'
? t('keybinding.ui.auditInvalid', {
action: actionLabel,
reason: issue.reasonKey ? t(asTranslationKey(issue.reasonKey)) : t('keybinding.ui.auditUnknown')
})
: t('keybinding.ui.auditConflict', { action: actionLabel, conflicts: conflictLabels })}
</Typography>
)
})}
</Box>
{ACTION_GROUP_ORDER.map((group) => {
const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group)
if (actions.length === 0) return null
@ -718,16 +766,20 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
{/* ── 라이선스 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={4}>
<InputConsentPanel />
</TabPanel>
<TabPanel value={activeTab} index={5}>
<LicenseTab />
</TabPanel>
{/* ── Cloud Sync 탭 ──────────────────────────── */}
<TabPanel value={activeTab} index={5}>
<TabPanel value={activeTab} index={6}>
<CloudSyncSection />
</TabPanel>
{/* ── 정보 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={6}>
<TabPanel value={activeTab} index={7}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
D3RO-VOICE

View file

@ -0,0 +1,163 @@
// src/renderer/components/input-insights/BarChart.tsx
// 입력 통계용 막대 그래프.
//
// 이전 구현은 flex 비율 + % 높이만 써서, 데이터가 1~2일치일 때 막대 하나가 화면을
// 가득 채우는 사각형이 됐고 축/라벨도 없어 "그래프"로 읽을 수 없었다. 그래서
// - 막대 폭을 고정 상한(24px)으로 두어 데이터가 적어도 거대해지지 않게 하고
// - 값 축(최대값)과 라벨 행을 항상 함께 그리며
// - 값이 0 뿐이면 그래프 대신 빈 상태 문구를 보여준다.
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
export interface BarDatum {
/** x축 라벨 */
label: string
value: number
/** 툴팁/접근성 텍스트 */
hint?: string
}
interface BarChartProps {
data: readonly BarDatum[]
/** 그래프 높이 (px) */
height?: number
/** 값 포맷 (기본: 정수) */
formatValue?: (value: number) => string
/** 라벨을 몇 개마다 표시할지 */
labelEvery?: number
accent?: string
}
export function BarChart({
data,
height = 110,
formatValue = (value) => value.toLocaleString(),
labelEvery = 1,
accent = d3roPalette.accent.main
}: BarChartProps): React.ReactElement {
const { t } = useI18n()
const max = Math.max(1, ...data.map((item) => item.value))
const hasData = data.some((item) => item.value > 0)
if (data.length === 0 || !hasData) {
return (
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.inactive, py: 2 }}>
{t('input.chart.noData')}
</Typography>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{t('input.chart.max', { value: formatValue(max) })}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'flex-end',
gap: '2px',
height,
px: 0.25,
borderBottom: `1px solid ${d3roPalette.border.subtle}`
}}
>
{data.map((item, index) => (
<Box
key={`${item.label}-${index}`}
title={item.hint ?? `${item.label}: ${formatValue(item.value)}`}
sx={{
flex: '0 1 24px',
minWidth: item.value > 0 ? '5px' : '2px',
height: `${Math.max(2, (item.value / max) * 100)}%`,
borderRadius: `${d3roRadius.xs} ${d3roRadius.xs} 0 0`,
bgcolor: item.value > 0 ? accent : d3roPalette.border.subtle,
opacity: item.value > 0 ? 0.85 : 1,
transition: 'opacity 120ms ease-out',
'&:hover': { opacity: 1 }
}}
/>
))}
</Box>
<Box sx={{ display: 'flex', gap: '2px', px: 0.25 }}>
{data.map((item, index) => (
<Box
key={`${item.label}-label-${index}`}
sx={{ flex: '0 1 24px', minWidth: '5px', textAlign: 'center', overflow: 'hidden' }}
>
{index % labelEvery === 0 ? (
<Typography
sx={{
...typoSx('nano'),
color: d3roPalette.text.inactive,
whiteSpace: 'nowrap',
overflow: 'hidden'
}}
>
{item.label}
</Typography>
) : null}
</Box>
))}
</Box>
</Box>
)
}
/** 값 비중을 가로 막대로 보여주는 목록 행 (앱 비중 등). */
export function ShareBar({
label,
value,
total,
right
}: {
label: string
value: number
total: number
right?: string
}): React.ReactElement {
const percent = total > 0 ? value / total : 0
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography
sx={{
...typoSx('small'),
color: d3roPalette.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{label}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, whiteSpace: 'nowrap' }}>
{right ?? `${Math.round(percent * 100)}%`}
</Typography>
</Box>
<Box
sx={{
height: 6,
borderRadius: 3,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden'
}}
>
<Box
sx={{
width: `${Math.max(2, Math.round(percent * 100))}%`,
height: '100%',
bgcolor: d3roPalette.accent.main,
opacity: 0.8
}}
/>
</Box>
</Box>
)
}

View file

@ -0,0 +1,555 @@
// src/renderer/components/input-insights/InputConsentPanel.tsx
// 입력 인텔리전스 — 동의 · 정책 · 실시간 진단.
//
// 상세 통계(요약/키보드/마우스/앱/문구)는 지식 베이스 화면의 "입력 인사이트" 탭에 있다.
// 이 패널은 "무엇을 수집하고, 언제 멈추고, 어떻게 지우는가" 만 다룬다.
import { useCallback, useEffect, useState } from 'react'
import {
Box,
Button,
Divider,
FormControlLabel,
MenuItem,
Select,
Switch,
TextField,
Typography
} from '@mui/material'
import { Trash2 } from 'lucide-react'
import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { LLMModel } from '@d3ro/core/types'
import { useInputInsights } from '../../hooks/useInputInsights'
const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const
const HINT_SX = {
color: d3roPalette.text.secondary,
fontSize: d3roTypo.small.size,
lineHeight: d3roTypo.small.line
} as const
export function InputConsentPanel(): React.ReactElement {
const { t } = useI18n()
const { telemetry, suggestion, receipt, receiptUnavailable, refresh } = useInputInsights(7)
const [excludedApps, setExcludedApps] = useState('')
const [triggerDelayMs, setTriggerDelayMs] = useState('')
const [minPrefixChars, setMinPrefixChars] = useState('')
const [requestTimeoutMs, setRequestTimeoutMs] = useState('')
const [models, setModels] = useState<LLMModel[]>([])
const [feedback, setFeedback] = useState<{ message: string; tone: 'success' | 'error' } | null>(null)
const enabled = telemetry?.enabled ?? false
const paused = telemetry?.paused ?? false
const suggestionEnabled = suggestion?.enabled ?? false
useEffect(() => {
if (!telemetry) return
setExcludedApps(telemetry.excludedApps.join(', '))
}, [telemetry])
// 설정 파일에 굳은 실제 값을 그대로 보여준다 (하드코딩 기본값을 표시하면
// 사용자가 보는 값과 동작이 어긋난다 — 실측: 트리거가 3008ms 로 저장돼 있었다).
useEffect(() => {
if (!suggestion) return
setTriggerDelayMs(String(suggestion.triggerDelayMs))
setMinPrefixChars(String(suggestion.minPrefixChars))
setRequestTimeoutMs(String(suggestion.requestTimeoutMs))
}, [suggestion])
useEffect(() => {
let cancelled = false
void window.electronAPI.llm.getModels().then((result) => {
if (!cancelled && result.success) setModels(result.data)
})
return () => {
cancelled = true
}
}, [])
const flash = useCallback((message: string, tone: 'success' | 'error' = 'success'): void => {
setFeedback({ message, tone })
setTimeout(() => setFeedback(null), 2500)
}, [])
const handleConsent = useCallback(
async (next: boolean): Promise<void> => {
await window.electronAPI.inputTelemetry.setEnabled({ enabled: next })
await refresh()
},
[refresh]
)
const handlePause = useCallback(
async (next: boolean): Promise<void> => {
await window.electronAPI.inputTelemetry.setPaused({ paused: next })
await refresh()
},
[refresh]
)
const handleLearn = useCallback(
async (next: boolean): Promise<void> => {
await window.electronAPI.suggestion.setConfig({ learnTypedText: next })
await refresh()
},
[refresh]
)
const handleSuggestionToggle = useCallback(
async (next: boolean): Promise<void> => {
await window.electronAPI.suggestion.setConfig({ enabled: next })
await refresh()
},
[refresh]
)
const handleOverlayInteractive = useCallback(
async (next: boolean): Promise<void> => {
await window.electronAPI.suggestion.setConfig({ overlayInteractive: next })
await refresh()
},
[refresh]
)
const handleModel = useCallback(
async (modelId: string): Promise<void> => {
await window.electronAPI.suggestion.setConfig({ modelId: modelId || null })
await refresh()
},
[refresh]
)
const handleExcludedAppsBlur = useCallback(async (): Promise<void> => {
const apps = excludedApps
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: apps })
if (result.success) {
await refresh()
flash(t('input.feedback.excludedSaved'))
} else {
flash(t('input.feedback.excludedFailed'), 'error')
}
}, [excludedApps, refresh, t, flash])
const handleAddRecommendedApp = useCallback(
async (appName: string): Promise<void> => {
const candidates = excludedApps
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.concat(appName)
const nextApps = candidates.filter(
(entry, index) => candidates.findIndex((candidate) => candidate.toLowerCase() === entry.toLowerCase()) === index
)
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: nextApps })
if (result.success) {
setExcludedApps(nextApps.join(', '))
await refresh()
flash(t('input.feedback.recommendationSaved', { app: appName }))
} else {
flash(t('input.feedback.recommendationFailed', { app: appName }), 'error')
}
},
[excludedApps, flash, refresh, t]
)
const handleClearAll = useCallback(async (): Promise<void> => {
const result = await window.electronAPI.inputTelemetry.clearAll()
if (result.success) {
await refresh()
flash(t('input.feedback.cleared'))
} else {
flash(t('input.feedback.clearFailed'), 'error')
}
}, [refresh, t, flash])
const snapshot = telemetry?.lastSnapshot ?? null
const exclusionRecommendation = telemetry?.exclusionRecommendation ?? null
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 동의 ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography variant="overline" sx={OVERLINE_SX}>
{t('input.consent.title')}
</Typography>
<Typography sx={HINT_SX}>{t('input.consent.description')}</Typography>
<Box
sx={{
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.5
}}
>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
{t('input.privacy.title')}
</Typography>
<Typography sx={HINT_SX}>{t('input.privacy.localOnly')}</Typography>
<Typography sx={HINT_SX}>{t('input.privacy.rawKeys')}</Typography>
{receipt ? (
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 1, rowGap: 0.25 }}>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityRetention')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.activityDays })}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamplesRetention')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.typingSamplesDays })}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestionRetention')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.suggestionDays })}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.learnedRetention')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.untilDeleted')}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityBuckets')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.activityBuckets.toLocaleString()}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamples')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.typingSamples.toLocaleString()}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.personalPhrases')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.personalPhrases.toLocaleString()}</Typography>
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestions')}</Typography>
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.suggestions.toLocaleString()}</Typography>
</Box>
) : receiptUnavailable ? (
<Typography sx={HINT_SX} role="status" aria-live="polite">{t('input.privacy.unavailable')}</Typography>
) : null}
</Box>
<FormControlLabel
control={
<Switch size="small" checked={enabled} onChange={(e) => void handleConsent(e.target.checked)} />
}
label={
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
{t('input.consent.collect')}
</Typography>
}
/>
<Typography sx={{ ...HINT_SX, pl: 6 }}>
{telemetry?.running ? t('input.consent.running') : t('input.consent.stopped')}
</Typography>
{/* 실시간 진단 — "왜 제안이 안 뜨는지" 를 사용자가 직접 볼 수 있게 한다. */}
<Box
sx={{
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.25
}}
>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
{t('input.diagnostics.title')}
</Typography>
<Typography sx={HINT_SX}>
{snapshot
? t('input.diagnostics.app', { app: snapshot.appName ?? t('input.diagnostics.unknownApp') })
: t('input.diagnostics.noSnapshot')}
</Typography>
<Typography
sx={{
...HINT_SX,
color:
snapshot?.editable && !snapshot.isPassword
? d3roPalette.status.success
: d3roPalette.status.warning
}}
>
{snapshot?.isPassword
? t('input.diagnostics.password')
: snapshot?.editable
? t('input.diagnostics.readable', {
source: snapshot.textSource,
length: snapshot.textLength
})
: t('input.diagnostics.notReadable')}
</Typography>
{snapshot?.composing ? (
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.diagnostics.composing')}
</Typography>
) : null}
{snapshot?.caretFallback && snapshot.editable ? (
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.diagnostics.caretFallback')}
</Typography>
) : null}
</Box>
{exclusionRecommendation ? (
<Box
sx={{
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.status.warning}`,
bgcolor: d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 0.75
}}
>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.secondary }} aria-live="polite">
{t('input.exclusion.recommendation', {
app: exclusionRecommendation.appName,
samples: exclusionRecommendation.samples,
reason:
exclusionRecommendation.reason === 'repeated-empty'
? t('input.exclusion.reason.repeated-empty')
: t('input.exclusion.reason.repeated-unreadable')
})}
</Typography>
<Button
size="small"
variant="outlined"
onClick={() => void handleAddRecommendedApp(exclusionRecommendation.appName)}
aria-label={t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
sx={{ fontSize: d3roTypo.label.size }}
>
{t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
</Button>
</Box>
) : null}
<FormControlLabel
disabled={!enabled}
control={<Switch size="small" checked={paused} onChange={(e) => void handlePause(e.target.checked)} />}
label={
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
{t('input.consent.pause')}
</Typography>
}
/>
<FormControlLabel
disabled={!enabled}
control={
<Switch
size="small"
checked={telemetry?.learnTypedText ?? false}
onChange={(e) => void handleLearn(e.target.checked)}
/>
}
label={
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
{t('input.consent.learnText')}
</Typography>
}
/>
<Typography sx={{ ...HINT_SX, pl: 6 }}>{t('input.consent.learnTextHint')}</Typography>
<TextField
size="small"
fullWidth
disabled={!enabled}
label={t('input.consent.excludedApps')}
placeholder={t('input.consent.excludedAppsPlaceholder')}
value={excludedApps}
onChange={(e) => setExcludedApps(e.target.value)}
onBlur={() => void handleExcludedAppsBlur()}
helperText={t('input.consent.excludedAppsHint')}
InputProps={{ sx: { fontSize: d3roTypo.compact.size } }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<Trash2 size={14} />}
onClick={() => void handleClearAll()}
sx={{ fontSize: d3roTypo.label.size }}
>
{t('input.consent.clearAll')}
</Button>
{feedback ? (
<Typography
sx={{ ...HINT_SX, color: feedback.tone === 'success' ? d3roPalette.status.success : d3roPalette.status.danger }}
role="status"
aria-live="polite"
>
{feedback.message}
</Typography>
) : null}
</Box>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.statsMovedHint')}
</Typography>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* ── 제안 정책 ─────────────────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography variant="overline" sx={OVERLINE_SX}>
{t('input.suggestion.title')}
</Typography>
<Typography sx={HINT_SX}>{t('input.suggestion.description')}</Typography>
<FormControlLabel
control={
<Switch
size="small"
checked={suggestionEnabled}
onChange={(e) => void handleSuggestionToggle(e.target.checked)}
/>
}
label={
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
{t('input.suggestion.enabled')}
</Typography>
}
/>
<Typography sx={HINT_SX}>
{suggestion?.modelAvailable ? t('input.suggestion.modelReady') : t('input.suggestion.modelMissing')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center' }}>
<TextField
size="small"
type="number"
disabled={!suggestionEnabled}
label={t('input.suggestion.delay')}
value={triggerDelayMs}
onChange={(e) => setTriggerDelayMs(e.target.value)}
onBlur={() =>
void window.electronAPI.suggestion
.setConfig({ triggerDelayMs: Number(triggerDelayMs) })
.then(() => refresh())
}
sx={{ width: 150 }}
/>
<TextField
size="small"
type="number"
disabled={!suggestionEnabled}
label={t('input.suggestion.minPrefix')}
value={minPrefixChars}
onChange={(e) => setMinPrefixChars(e.target.value)}
onBlur={() =>
void window.electronAPI.suggestion
.setConfig({ minPrefixChars: Number(minPrefixChars) })
.then(() => refresh())
}
sx={{ width: 150 }}
/>
<TextField
size="small"
type="number"
disabled={!suggestionEnabled}
label={t('input.suggestion.timeout')}
value={requestTimeoutMs}
onChange={(e) => setRequestTimeoutMs(e.target.value)}
onBlur={() =>
void window.electronAPI.suggestion
.setConfig({ requestTimeoutMs: Number(requestTimeoutMs) })
.then(() => refresh())
}
sx={{ width: 150 }}
/>
<Select
size="small"
disabled={!suggestionEnabled}
value={suggestion?.modelId ?? ''}
onChange={(e) => void handleModel(String(e.target.value))}
displayEmpty
sx={{ minWidth: 200, fontSize: d3roTypo.compact.size }}
>
<MenuItem value="">{t('input.suggestion.modelDefault')}</MenuItem>
{models.map((model) => (
<MenuItem key={model.name} value={model.name}>
{model.name}
</MenuItem>
))}
</Select>
</Box>
<FormControlLabel
disabled={!suggestionEnabled}
control={
<Switch
size="small"
checked={suggestion?.overlayInteractive ?? true}
onChange={(e) => void handleOverlayInteractive(e.target.checked)}
/>
}
label={
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
{t('input.suggestion.overlayInteractive')}
</Typography>
}
/>
{/* 상태 표시 — 스위치로 두면 설정처럼 보여서 "왜 못 켜지?" 가 된다. */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
{t('input.suggestion.onScreenLabel')}
</Typography>
<Box
sx={{
px: 0.75,
py: 0.25,
borderRadius: d3roRadius.xs,
bgcolor: suggestion?.visible ? d3roPalette.accent.dim : d3roPalette.bg.inset,
border: `1px solid ${suggestion?.visible ? d3roPalette.accent.main : d3roPalette.border.subtle}`
}}
>
<Typography
sx={{
...typoSx('micro'),
color: suggestion?.visible ? d3roPalette.accent.main : d3roPalette.text.inactive
}}
>
{suggestion?.visible ? t('input.suggestion.onScreen') : t('input.suggestion.offScreen')}
</Typography>
</Box>
{suggestion?.generating ? (
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.status.warning }}>
{t('input.suggestion.generating')}
</Typography>
) : null}
</Box>
<Typography sx={HINT_SX}>{t('input.suggestion.keyHint')}</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
size="small"
variant="outlined"
disabled={!suggestionEnabled}
onClick={() => void window.electronAPI.suggestion.requestNow()}
sx={{ fontSize: d3roTypo.label.size }}
>
{t('input.suggestion.requestNow')}
</Button>
<Typography sx={HINT_SX}>
{t('input.suggestion.usage', {
requests: suggestion?.requestsToday ?? 0,
budget: suggestion?.dailyBudget ?? 0
})}
</Typography>
{suggestion?.lastLatencyMs !== null && suggestion?.lastLatencyMs !== undefined ? (
<Typography sx={HINT_SX}>
{t('input.suggestion.latency', { ms: suggestion.lastLatencyMs })}
</Typography>
) : null}
</Box>
{suggestion?.lastSkipReason ? (
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.suggestion.lastSkip', { reason: suggestion.lastSkipReason })}
</Typography>
) : null}
</Box>
</Box>
)
}

View file

@ -0,0 +1,701 @@
// src/renderer/components/input-insights/InputInsightsView.tsx
// 입력 인사이트 — 지식 베이스 안에 들어가는 상세 통계 화면.
//
// 종류별 inner tab 으로 나눈다: 요약 / 키보드 / 마우스 / 앱 / 문구·제안.
// 모든 수치는 수집된 로컬 집계(input_activity)와 제안 이력(suggestions)에서 나온다.
import { useCallback, useEffect, useState } from 'react'
import {
Box,
Button,
Divider,
IconButton,
Tab,
Tabs,
TextField,
Typography
} from '@mui/material'
import { Trash2 } from 'lucide-react'
import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { BarChart, ShareBar, type BarDatum } from './BarChart'
import { useInputInsights } from '../../hooks/useInputInsights'
import type {
PersonalGraphQuery,
PersonalGraphStats
} from '@d3ro/core/personal-graph'
const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const
const HINT_SX = {
color: d3roPalette.text.secondary,
fontSize: d3roTypo.small.size,
lineHeight: d3roTypo.small.line
} as const
interface TileProps {
label: string
value: string
unit?: string
emphasis?: boolean
}
function StatTile({ label, value, unit, emphasis = false }: TileProps): React.ReactElement {
return (
<Box
sx={{
flex: '1 1 132px',
minWidth: 132,
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${emphasis ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
bgcolor: emphasis ? d3roPalette.accent.dim : d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.25
}}
>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>{label}</Typography>
<Typography sx={{ ...typoSx('value'), color: d3roPalette.text.primary }}>
{value}
{unit ? (
<Box component="span" sx={{ ml: 0.5, ...typoSx('small'), color: d3roPalette.text.inactive }}>
{unit}
</Box>
) : null}
</Typography>
</Box>
)
}
function TileRow({ children }: { children: React.ReactNode }): React.ReactElement {
return <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{children}</Box>
}
function Section({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography variant="overline" sx={OVERLINE_SX}>
{title}
</Typography>
{children}
</Box>
)
}
interface SuggestionHistoryRow {
id: string
appName: string | null
prefixText: string
suggestionText: string
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
}
export function InputInsightsView(): React.ReactElement {
const { t, formatRelativeDate } = useI18n()
const [days, setDays] = useState(7)
const [tab, setTab] = useState(0)
const [history, setHistory] = useState<SuggestionHistoryRow[]>([])
const [graph, setGraph] = useState<PersonalGraphStats | null>(null)
const [graphQuery, setGraphQuery] = useState('')
const [graphResult, setGraphResult] = useState<PersonalGraphQuery | null>(null)
const { telemetry, summary, phrases, refresh } = useInputInsights(days)
const loadHistory = useCallback(async (): Promise<void> => {
const result = await window.electronAPI.suggestion.getHistory({ limit: 20 })
if (result.success) setHistory(result.data)
}, [])
useEffect(() => {
void loadHistory()
}, [loadHistory])
useEffect(() => {
let cancelled = false
void window.electronAPI.inputTelemetry.getGraph().then((result) => {
if (!cancelled && result.success) setGraph(result.data)
})
return () => {
cancelled = true
}
}, [tab, phrases.length])
const runGraphQuery = useCallback(async (): Promise<void> => {
const result = await window.electronAPI.inputTelemetry.queryGraph({ text: graphQuery })
if (result.success) setGraphResult(result.data)
}, [graphQuery])
const handleDeletePhrase = useCallback(
async (id: string): Promise<void> => {
await window.electronAPI.inputTelemetry.deletePhrase({ id })
await refresh()
},
[refresh]
)
const dailyKeys: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: day.keystrokes,
hint: `${day.date} · ${day.keystrokes.toLocaleString()}`
}))
const dailyWords: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: day.words,
hint: `${day.date} · ${day.words.toLocaleString()}`
}))
const dailyDistance: BarDatum[] = (summary?.daily ?? []).map((day) => ({
label: day.date.slice(5),
value: Math.round(day.mouseDistancePx / 96 / 0.0254),
hint: `${day.date} · ${Math.round(day.mouseDistancePx)}px`
}))
const hourlyKeys: BarDatum[] = (summary?.hourly ?? []).map((hour) => ({
label: String(hour.hour),
value: hour.keystrokes,
hint: `${hour.hour}:00 · ${hour.keystrokes.toLocaleString()}`
}))
const labelEvery = days > 14 ? 3 : days > 7 ? 2 : 1
const totals = summary?.totals
const averages = summary?.averages
const suggestions = summary?.suggestions
const friction = summary?.friction
const flowWindows = summary?.flowWindows ?? []
const suggestionApps = summary?.suggestionApps ?? []
const frictionBandLabel =
friction?.band === 'high'
? t('input.friction.band.high')
: friction?.band === 'watch'
? t('input.friction.band.watch')
: t('input.friction.band.steady')
const topAppTotal = (summary?.topApps ?? []).reduce((sum, app) => sum + app.keystrokes, 0)
if (!telemetry?.enabled) {
return (
<Box sx={{ py: 4, display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={HINT_SX}>{t('input.insights.disabledHint')}</Typography>
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
{t('input.insights.rangeLabel')}
</Typography>
{[7, 14, 30].map((value) => (
<Box
key={value}
component="button"
type="button"
onClick={() => setDays(value)}
sx={{
cursor: 'pointer',
px: 1,
py: 0.25,
borderRadius: d3roRadius.xs,
border: `1px solid ${value === days ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
bgcolor: value === days ? d3roPalette.accent.dim : 'transparent',
color: value === days ? d3roPalette.accent.main : d3roPalette.text.inactive,
font: 'inherit',
...typoSx('micro')
}}
>
{t('input.insights.rangeDays', { days: value })}
</Box>
))}
<Typography sx={{ ...HINT_SX, ml: 'auto' }}>
{t('input.insights.headerSummary', {
days: summary?.days ?? days,
keys: totals?.keystrokes ?? 0,
apps: summary?.topApps.length ?? 0
})}
</Typography>
</Box>
<Tabs
value={tab}
onChange={(_event, next: number) => setTab(next)}
variant="scrollable"
scrollButtons={false}
sx={{
minHeight: 36,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
'& .MuiTab-root': {
minHeight: 36,
py: 0.5,
textTransform: 'none',
color: d3roPalette.text.inactive,
fontSize: d3roTypo.label.size,
'&.Mui-selected': { color: d3roPalette.accent.main } as const
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }
}}
>
<Tab label={t('input.insights.tabs.overview')} />
<Tab label={t('input.insights.tabs.keyboard')} />
<Tab label={t('input.insights.tabs.mouse')} />
<Tab label={t('input.insights.tabs.apps')} />
<Tab label={t('input.insights.tabs.phrases')} />
<Tab label={t('input.insights.tabs.graph')} />
</Tabs>
{/* ── 요약 ─────────────────────────────────────── */}
{tab === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.chars')} value={(totals?.chars ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.activeMinutes')} value={String(averages?.activeMinutes ?? 0)} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.mouseDistance')} value={String(averages?.mouseDistanceMeters ?? 0)} unit={t('input.unit.perDayMeters')} />
<StatTile label={t('input.insights.activeDays')} value={String(summary?.activeDays ?? 0)} />
<StatTile label={t('input.insights.streak')} value={String(summary?.longestStreakDays ?? 0)} />
<StatTile
label={t('input.insights.peakDay')}
value={summary?.peakDay ? summary.peakDay.date.slice(5) : '—'}
unit={summary?.peakDay ? summary.peakDay.keystrokes.toLocaleString() : undefined}
/>
<StatTile label={t('input.insights.phrases')} value={String(summary?.phraseCount ?? 0)} />
<StatTile
label={t('input.insights.acceptRate')}
value={suggestions ? `${Math.round(suggestions.acceptRate * 100)}` : '0'}
unit={t('input.unit.percent')}
/>
</TileRow>
<Section title={t('input.insights.dailyTitle')}>
<BarChart data={dailyKeys} labelEvery={labelEvery} />
</Section>
<Section title={t('input.insights.perDayAverage')}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(averages?.keystrokes ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.words')} value={(averages?.words ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.clicks')} value={(averages?.clicks ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
<StatTile label={t('input.insights.backspaces')} value={(averages?.backspaces ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
</TileRow>
</Section>
</Box>
) : null}
{/* ── 키보드 ───────────────────────────────────── */}
{tab === 1 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.wordChars')} value={(totals?.chars ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.backspaces')} value={(totals?.backspaces ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.shortcuts')} value={(totals?.shortcuts ?? 0).toLocaleString()} />
</TileRow>
<Section title={t('input.insights.hourlyTitle')}>
<BarChart data={hourlyKeys} labelEvery={2} />
</Section>
<Section title={t('input.flow.title')}>
<TileRow>
<StatTile
label={t('input.friction.title')}
value={t('input.friction.value', { count: friction?.editsPer100Chars ?? 0 })}
unit={frictionBandLabel}
/>
</TileRow>
{flowWindows.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.flow.empty')}</Typography>
) : (
flowWindows.map((window) => (
<Box
key={window.hour}
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
alignItems: 'center',
px: 1,
py: 0.75,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset
}}
>
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.text.primary }}>
{t('input.flow.hour', { hour: window.hour })}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
{t('input.flow.window', {
score: window.score,
minutes: Math.round(window.activeMinutes),
friction: Math.round(window.frictionRate * 100)
})}
</Typography>
</Box>
))
)}
</Section>
<Section title={t('input.insights.wordsDailyTitle')}>
<BarChart data={dailyWords} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.topHoursHint', {
hours: (summary?.topHours ?? [])
.slice(0, 3)
.map((hour) => `${hour.hour}시`)
.join(', ')
})}
</Typography>
</Box>
) : null}
{/* ── 마우스 ───────────────────────────────────── */}
{tab === 2 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TileRow>
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} emphasis />
<StatTile label={t('input.insights.doubleClicks')} value={(totals?.doubleClicks ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.scrollTicks')} value={(totals?.scrollTicks ?? 0).toLocaleString()} />
<StatTile
label={t('input.insights.mouseDistanceTotal')}
value={((averages?.mouseDistanceMeters ?? 0) * days).toFixed(1)}
unit={t('input.unit.meters')}
/>
</TileRow>
<Section title={t('input.insights.distanceDailyTitle')}>
<BarChart data={dailyDistance} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.mouseNote')}
</Typography>
</Box>
) : null}
{/* ── 앱 ──────────────────────────────────────── */}
{tab === 3 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography sx={HINT_SX}>{t('input.insights.appsHint')}</Typography>
{(summary?.topApps ?? []).length === 0 ? (
<Typography sx={HINT_SX}>{t('input.insights.empty')}</Typography>
) : (
(summary?.topApps ?? []).map((app) => (
<ShareBar
key={app.appName}
label={app.appName}
value={app.keystrokes}
total={topAppTotal}
right={t('input.insights.appRow', {
keystrokes: app.keystrokes,
clicks: app.clicks
})}
/>
))
)}
<Section title={t('input.appQuality.title')}>
{suggestionApps.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.appQuality.empty')}</Typography>
) : (
suggestionApps.map((app) => (
<Box
key={app.appName}
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
alignItems: 'center',
px: 1,
py: 0.75,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.inset
}}
>
<Typography
sx={{
flex: '1 1 120px',
minWidth: 0,
...typoSx('compact'),
color: d3roPalette.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{app.appName}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
{t('input.appQuality.row', {
accepted: app.accepted,
total: app.total,
rate: Math.round(app.acceptRate * 100),
latency: app.avgLatencyMs ?? '—'
})}
</Typography>
</Box>
))
)}
</Section>
</Box>
) : null}
{/* ── 개인 그래프 ──────────────────────────────── */}
{tab === 5 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography sx={HINT_SX}>{t('input.graph.description')}</Typography>
<TileRow>
<StatTile label={t('input.graph.nodes')} value={String(graph?.nodes ?? 0)} emphasis />
<StatTile label={t('input.graph.followsEdges')} value={String(graph?.followsEdges ?? 0)} />
<StatTile label={t('input.graph.sharesEdges')} value={String(graph?.sharesTermsEdges ?? 0)} />
</TileRow>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
size="small"
fullWidth
value={graphQuery}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGraphQuery(event.target.value)}
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') void runGraphQuery()
}}
placeholder={t('input.graph.searchPlaceholder')}
label={t('input.graph.searchLabel')}
/>
<Button size="small" variant="outlined" onClick={() => void runGraphQuery()}>
{t('input.graph.searchAction')}
</Button>
</Box>
{graphResult && graphResult.anchors.length > 0 ? (
<Section title={t('input.graph.neighbors')}>
{graphResult.anchors.map((anchor) => (
<Box key={anchor.text} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.accent.main }}>
{anchor.text}
</Typography>
{graphResult.neighbors
.slice(0, 4)
.map((neighbor) => (
<Typography
key={`${anchor.text}-${neighbor.text}`}
sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, pl: 1.5 }}
>
→ {neighbor.text} ({neighbor.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')} · {neighbor.weight})
</Typography>
))}
</Box>
))}
</Section>
) : null}
<Section title={t('input.graph.topEdges')}>
{(graph?.topEdges ?? []).length === 0 ? (
<Typography sx={HINT_SX}>{t('input.graph.empty')}</Typography>
) : (
(graph?.topEdges ?? []).map((edge) => (
<Box
key={`${edge.from}-${edge.to}-${edge.kind}`}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.5,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{edge.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{edge.from} → {edge.to}
</Typography>
<Typography
sx={{
minWidth: 0,
maxWidth: '45%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...typoSx('micro'),
color: d3roPalette.text.inactive
}}
>
{edge.weight}×
</Typography>
</Box>
))
)}
</Section>
<Section title={t('input.graph.recentNodes')}>
{(graph?.recentNodes ?? []).slice(0, 8).map((node) => (
<Box key={node.text} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.text}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive }}>
{node.count}× · {node.appName ?? '—'}
</Typography>
</Box>
))}
</Section>
</Box>
) : null}
{/* ── 문구 · 제안 ─────────────────────────────── */}
{tab === 4 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Section title={t('input.insights.suggestions')}>
<TileRow>
<StatTile label={t('input.insights.suggestionsTotal')} value={(suggestions?.total ?? 0).toLocaleString()} />
<StatTile label={t('input.insights.suggestionsAccepted')} value={(suggestions?.accepted ?? 0).toLocaleString()} />
<StatTile
label={t('input.insights.acceptRate')}
value={String(Math.round((suggestions?.acceptRate ?? 0) * 100))}
unit={t('input.unit.percent')}
/>
<StatTile
label={t('input.insights.avgLatency')}
value={suggestions?.avgLatencyMs === null || suggestions?.avgLatencyMs === undefined ? '—' : String(suggestions.avgLatencyMs)}
unit={t('input.unit.ms')}
/>
</TileRow>
</Section>
<Section title={t('input.insights.suggestionHistory')}>
{history.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.insights.noSuggestions')}</Typography>
) : (
history.map((entry) => (
<Box
key={entry.id}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
px: 1.25,
py: 1,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
...typoSx('micro'),
color: entry.accepted ? d3roPalette.status.success : d3roPalette.text.inactive
}}
>
{entry.accepted ? t('input.insights.accepted') : t('input.insights.notAccepted')}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
{entry.appName ?? '—'}
</Typography>
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, ml: 'auto' }}>
{entry.latencyMs === null ? '' : `${entry.latencyMs}ms`}
</Typography>
</Box>
<Typography
sx={{
...typoSx('compact'),
color: d3roPalette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{entry.prefixText.slice(-40)} → {entry.suggestionText}
</Typography>
</Box>
))
)}
</Section>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Section title={t('input.phrases.title')}>
<Typography sx={HINT_SX}>{t('input.phrases.description')}</Typography>
{phrases.length === 0 ? (
<Typography sx={HINT_SX}>{t('input.phrases.empty')}</Typography>
) : (
phrases.slice(0, 40).map((phrase) => (
<Box
key={phrase.id}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1,
py: 0.5,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.border.subtle}`
}}
>
<Typography
sx={{
flex: 1,
minWidth: 0,
...typoSx('compact'),
color: d3roPalette.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{phrase.phrase}
</Typography>
<Typography
sx={{
minWidth: 0,
maxWidth: '45%',
flexShrink: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...typoSx('micro'),
color: d3roPalette.text.inactive
}}
>
{t('input.phrases.metadata', {
source: phrase.source,
count: phrase.count,
app: phrase.appName ?? t('input.phrases.appUnknown')
})}
</Typography>
<IconButton
size="small"
aria-label={t('input.phrases.delete')}
onClick={() => void handleDeletePhrase(phrase.id)}
sx={{ flexShrink: 0 }}
>
<Trash2 size={13} />
</IconButton>
</Box>
))
)}
</Section>
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
{t('input.insights.samplesNote', { count: summary?.sampleCount ?? 0 })}{' '}
{t('input.insights.collectedAt', { at: formatRelativeDate(telemetry.lastSnapshotAt || Date.now()) })}
</Typography>
</Box>
) : null}
</Box>
)
}

View file

@ -0,0 +1,83 @@
// src/renderer/hooks/useInputInsights.ts
// 입력 텔레메트리/제안 상태 + 주간 인사이트 로딩 훅.
//
// 동의(수집 여부)와는 무관하게 **설정값과 집계만** 읽는다. 수집이 꺼져 있으면
// 집계는 비어 있고 UI 는 "꺼" 상태를 보여준다.
import { useCallback, useEffect, useState } from 'react'
import type {
InputInsightsSummary,
InputPrivacyReceipt,
InputTelemetryState,
PersonalPhrase,
SuggestionState
} from '@d3ro/core/input-intelligence'
export interface UseInputInsightsResult {
telemetry: InputTelemetryState | null
suggestion: SuggestionState | null
summary: InputInsightsSummary | null
receipt: InputPrivacyReceipt | null
receiptUnavailable: boolean
phrases: PersonalPhrase[]
loading: boolean
refresh: (days?: number) => Promise<void>
}
export function useInputInsights(days = 7): UseInputInsightsResult {
const [telemetry, setTelemetry] = useState<InputTelemetryState | null>(null)
const [suggestion, setSuggestion] = useState<SuggestionState | null>(null)
const [summary, setSummary] = useState<InputInsightsSummary | null>(null)
const [receipt, setReceipt] = useState<InputPrivacyReceipt | null>(null)
const [receiptUnavailable, setReceiptUnavailable] = useState(false)
const [phrases, setPhrases] = useState<PersonalPhrase[]>([])
const [loading, setLoading] = useState(true)
const refresh = useCallback(
async (rangeDays = days): Promise<void> => {
setLoading(true)
try {
const [stateResult, suggestionResult, summaryResult, phrasesResult, receiptResult] = await Promise.allSettled([
window.electronAPI.inputTelemetry.getState(),
window.electronAPI.suggestion.getState(),
window.electronAPI.inputTelemetry.getSummary({ days: rangeDays }),
window.electronAPI.inputTelemetry.getPhrases({ limit: 50 }),
window.electronAPI.inputTelemetry.getPrivacyReceipt()
])
if (stateResult.status === 'fulfilled' && stateResult.value.success) setTelemetry(stateResult.value.data)
if (suggestionResult.status === 'fulfilled' && suggestionResult.value.success) setSuggestion(suggestionResult.value.data)
if (summaryResult.status === 'fulfilled' && summaryResult.value.success) setSummary(summaryResult.value.data)
if (phrasesResult.status === 'fulfilled' && phrasesResult.value.success) setPhrases(phrasesResult.value.data)
if (receiptResult.status === 'fulfilled' && receiptResult.value.success) {
setReceipt(receiptResult.value.data)
setReceiptUnavailable(false)
} else {
setReceipt(null)
setReceiptUnavailable(true)
}
} finally {
setLoading(false)
}
},
[days]
)
useEffect(() => {
void refresh(days)
}, [refresh, days])
useEffect(() => {
const unsubTelemetry = window.electronAPI.inputTelemetry.onStateChanged((state) => {
setTelemetry(state)
})
const unsubSuggestion = window.electronAPI.suggestion.onStateChanged((state) => {
setSuggestion(state)
})
return () => {
unsubTelemetry()
unsubSuggestion()
}
}, [])
return { telemetry, suggestion, summary, receipt, receiptUnavailable, phrases, loading, refresh }
}

View file

@ -39,6 +39,7 @@ import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey
import { BindingKeycaps } from '../components/keybinding/Keycap'
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
import { FileDropZone } from '../components/FileDropZone'
import { useInputInsights } from '../hooks/useInputInsights'
import type { StatsSummary, HistoryEntry, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
@ -103,6 +104,7 @@ export function DashboardPage(): React.ReactElement {
const [llmModel, setLlmModel] = useState<string | null>(null)
const bindingMap = useKeyBindingMap()
const dictationBinding = bindingMap?.dictation[0] ?? null
const inputInsights = useInputInsights(7)
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
const [audioLevel, setAudioLevel] = useState(0)
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
@ -642,6 +644,57 @@ export function DashboardPage(): React.ReactElement {
</Box>
</MetalCard>
{/* ── 5b. 입력 인사이트 (주간) ──────────────────── */}
{inputInsights.telemetry?.enabled ? (
<MetalCard sx={{ p: 2.75, mt: 3 }}>
<CardHeader
title={t('input.insights.title', { days: inputInsights.summary?.days ?? 7 })}
icon={<BarChart3 size={16} />}
right={
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
{t('input.insights.keystrokes')} {formatNumber(inputInsights.summary?.totals.keystrokes ?? 0)}
</PhosphorText>
}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{[
{
label: t('input.insights.clicks'),
value: formatNumber(inputInsights.summary?.totals.clicks ?? 0),
},
{
label: t('input.insights.words'),
value: formatNumber(inputInsights.summary?.totals.words ?? 0),
},
{
label: t('input.insights.sentences'),
value: formatNumber(inputInsights.summary?.totals.sentences ?? 0),
},
{
label: t('input.insights.mouseDistance'),
value: `${inputInsights.summary?.averages.mouseDistanceMeters ?? 0}${t('input.insights.perDayMeters')}`,
},
{
label: t('input.insights.phrases'),
value: formatNumber(inputInsights.summary?.phraseCount ?? 0),
},
].map((item) => (
<Box
key={item.label}
sx={{ flex: '1 1 140px', minWidth: 140, px: 1.5, py: 1, borderRadius: d3roRadius.xs }}
>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{item.label}
</PhosphorText>
<PhosphorText variant="value" sx={{ color: d3roPalette.text.primary }}>
{item.value}
</PhosphorText>
</Box>
))}
</Box>
</MetalCard>
) : null}
{/* ── 6. 파일 전사 드롭존 ──────────────────────────── */}
<Box sx={{ mt: 3 }}>
<FileDropZone />

View file

@ -2,16 +2,21 @@
// Local RAG Knowledge Base & Semantic Memory Intelligence
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import { Box, TextField, IconButton, Tooltip, LinearProgress, Tab, Tabs } from '@mui/material'
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { InputInsightsView } from '../components/input-insights/InputInsightsView'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'
type KnowledgeView = 'kb' | 'insights'
export function KnowledgeBasePage(): React.ReactElement {
// 이 화면은 두 축을 가진다: 수집된 지식 문서(RAG)와 자동 수집된 입력 데이터 통계.
const [view, setView] = useState<KnowledgeView>('kb')
const { t } = useI18n()
const [documents, setDocuments] = useState<RAGDocument[]>([])
const [loading, setLoading] = useState(true)
@ -128,6 +133,32 @@ export function KnowledgeBasePage(): React.ReactElement {
}
/>
<Tabs
value={view}
onChange={(_event, next: KnowledgeView) => setView(next)}
sx={{
mb: 2.5,
minHeight: 38,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
'& .MuiTab-root': {
minHeight: 38,
py: 0.5,
textTransform: 'none',
color: d3roPalette.text.inactive,
fontSize: d3roTypo.label.size,
'&.Mui-selected': { color: d3roPalette.accent.main }
},
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }
}}
>
<Tab value="kb" label={t('input.kbView.knowledge')} />
<Tab value="insights" label={t('input.kbView.insights')} />
</Tabs>
{view === 'insights' ? (
<InputInsightsView />
) : (
<>
{/* Indexing Progress Card */}
{indexProgress && (
<MetalCard sx={{ mb: 2.5, p: 2.5 }}>
@ -411,6 +442,8 @@ export function KnowledgeBasePage(): React.ReactElement {
</Box>
)}
</Box>
</>
)}
</Box>
)
}

View file

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="./style.css">
<title>Suggestion</title>
</head>
<body>
<div id="root">
<div id="panel" class="suggestion-panel">
<div class="header">
<div id="status" class="status" hidden>
<span id="spinner" class="spinner" aria-hidden="true"></span>
<span id="statusText" class="status-text"></span>
</div>
<button id="close" class="close" type="button" title="Close">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div id="candidates" class="candidates"></div>
<div id="provenance" class="provenance" aria-live="polite"></div>
<div id="hints" class="hints"></div>
</div>
</div>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
<script type="module" src="./script.js"></script>
</body>
</html>

View file

@ -0,0 +1,200 @@
// Suggestion Overlay 팝업 스크립트
// 입력 인텔리전스: 다음 문장 제안(ghost text)을 커서 옆에 보여준다.
//
// 상태 세 가지를 화면에 드러낸다:
// warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중")
// generating - 토큰이 만들어지는 중 (스피너 + 도착한 부분 텍스트)
// 후보 도착 - 최대 5개, 목록은 스크롤 가능
//
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/닫기)은 전역 키바인딩이
// 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다.
;(function () {
'use strict'
var candidatesContainer = document.getElementById('candidates')
var provenanceContainer = document.getElementById('provenance')
var hintsContainer = document.getElementById('hints')
var statusRow = document.getElementById('status')
var statusText = document.getElementById('statusText')
var panel = document.getElementById('panel')
var closeButton = document.getElementById('close')
/** @type {string[]} */
var candidates = []
var activeIndex = 0
var generating = false
var warmingUp = false
var partialText = null
var provenance = null
var i18nStrings = {}
var generatingSince = 0
var tickTimer = null
function render() {
candidatesContainer.textContent = ''
var busy = warmingUp || generating
if (statusRow) statusRow.hidden = !busy
if (statusText) {
if (warmingUp) {
statusText.textContent = i18nStrings.suggestionWarming || '...'
} else if (generating) {
// 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다.
var seconds = generatingSince
? Math.max(1, Math.round((Date.now() - generatingSince) / 1000))
: 1
var template = i18nStrings.suggestionGenerating || i18nStrings.suggestionLoading || '...'
statusText.textContent = template.replace('{{seconds}}', String(seconds))
} else {
statusText.textContent = ''
}
}
if (candidates.length === 0) {
// 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌).
if (partialText) {
var streaming = document.createElement('div')
streaming.className = 'suggestion-item streaming'
streaming.textContent = partialText
candidatesContainer.appendChild(streaming)
}
renderHints()
renderProvenance()
return
}
for (var i = 0; i < candidates.length; i++) {
var item = document.createElement('button')
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()
renderProvenance()
}
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)
}
function onItemClick(event) {
var target = event.currentTarget
var index = Number(target.getAttribute('data-index'))
if (!window.popupAPI) return
window.popupAPI.send('suggestionPopup:accept', index)
}
function applyPayload(payload) {
if (!payload) return
if (payload._i18n) i18nStrings = payload._i18n
candidates = (payload.candidates || []).map(function (candidate) {
return candidate && candidate.text ? candidate.text : String(candidate)
})
activeIndex = payload.activeIndex || 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
if (payload.provenance !== undefined) provenance = payload.provenance || null
}
function startTick() {
if (tickTimer) return
tickTimer = setInterval(function () {
if (!generating && !warmingUp) {
clearInterval(tickTimer)
tickTimer = null
generatingSince = 0
return
}
render()
}, 500)
}
function handleShow(payload) {
applyPayload(payload)
if (payload && payload.generating) generatingSince = Date.now()
startTick()
if (panel) panel.classList.add('visible')
render()
}
function handleUpdate(payload) {
var wasGenerating = generating
applyPayload(payload)
if (generating && !wasGenerating) generatingSince = Date.now()
startTick()
render()
}
function handleHide() {
if (panel) panel.classList.remove('visible')
if (tickTimer) {
clearInterval(tickTimer)
tickTimer = null
}
generatingSince = 0
generating = false
warmingUp = false
partialText = null
provenance = null
candidates = []
activeIndex = 0
candidatesContainer.textContent = ''
if (provenanceContainer) provenanceContainer.textContent = ''
hintsContainer.textContent = ''
}
if (closeButton) {
// 마우스로도 닫을 수 있어야 한다 (키보드만 있으면 불편하다는 피드백).
closeButton.addEventListener('click', function () {
handleHide()
if (window.popupAPI) window.popupAPI.send('suggestionPopup:dismiss')
})
}
if (window.popupAPI) {
window.popupAPI.on('suggestionPopup:show', handleShow)
window.popupAPI.on('suggestionPopup:update', handleUpdate)
window.popupAPI.on('suggestionPopup:hide', handleHide)
}
})()

View file

@ -0,0 +1,208 @@
/* Suggestion Overlay — 입력 인텔리전스 ghost text
*
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*/
:root {
--d3-bg-result: #111a30;
--d3-border-result: rgba(148, 180, 255, 0.12);
--d3-text-result: #eef2fb;
--d3-text-secondary: #93a4c8;
--d3-accent-main: #3b82f6;
--d3-accent-dim: rgba(59, 130, 246, 0.14);
--d3-shadow-popup: 0 8px 32px rgba(3, 7, 18, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
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;
}
#root {
width: 100%;
height: 100%;
padding: 4px;
}
.suggestion-panel {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 10px;
background: var(--d3-bg-result);
border: 1px solid var(--d3-border-result);
box-shadow: var(--d3-shadow-popup);
opacity: 0;
transform: translateY(-2px);
transition: opacity 90ms ease-out, transform 90ms ease-out;
}
.suggestion-panel.visible {
opacity: 1;
transform: translateY(0);
}
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
.header {
display: flex;
align-items: center;
gap: 8px;
min-height: 18px;
}
.close {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d3-text-secondary);
cursor: pointer;
transition: background 90ms ease-out, color 90ms ease-out;
}
.close:hover {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
}
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
}
.spinner {
width: 11px;
height: 11px;
flex: 0 0 auto;
border-radius: 50%;
border: 2px solid var(--d3-accent-dim);
border-top-color: var(--d3-accent-main);
animation: d3-spin 720ms linear infinite;
}
@keyframes d3-spin {
to {
transform: rotate(360deg);
}
}
.status-text {
color: var(--d3-text-secondary);
font-size: 12px;
font-style: italic;
}
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
.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;
}
.suggestion-item {
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
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;
}
.suggestion-item.active {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
}
.suggestion-item.loading {
color: var(--d3-text-secondary);
font-style: italic;
}
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.streaming {
color: var(--d3-text-result);
opacity: 0.85;
white-space: normal;
}
.suggestion-item.streaming::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 {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 2px;
color: var(--d3-text-secondary);
font-size: 11px;
}
.hint {
white-space: nowrap;
}

View file

@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(
() => new Map<string, (event: unknown, params: unknown) => Promise<unknown>>()
)
const eventHandlers = vi.hoisted(() => new Map<string, (...args: unknown[]) => void>())
const configSet = vi.hoisted(() => vi.fn())
const suggestion = vi.hoisted(() => ({
setEnabled: vi.fn(),
applyConfig: vi.fn(),
dismiss: vi.fn(),
getState: vi.fn(() => ({ enabled: true }))
}))
const hideSuggestionOverlay = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params: unknown) => Promise<unknown>) => {
handlers.set(channel, handler)
}),
on: vi.fn((channel: string, handler: (...args: unknown[]) => void) => {
eventHandlers.set(channel, handler)
})
}
}))
vi.mock('../../../src/main/services/ConfigService', () => ({ configSet }))
vi.mock('../../../src/main/services/SuggestionService', () => ({
getSuggestionService: () => suggestion
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
applySuggestionOverlayConfig: vi.fn(),
hideSuggestionOverlay
}))
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
handlers.clear()
eventHandlers.clear()
const mod = await import('../../../src/main/ipc/suggestion-handlers')
mod.registerSuggestionHandlers()
})
describe('SUGGESTION.SET_CONFIG', () => {
it('명시적으로 활성화할 때 서비스의 warm-up 경로를 사용한다', async () => {
const handler = handlers.get(IPC_CHANNELS.SUGGESTION.SET_CONFIG)
if (!handler) throw new Error('suggestion config handler was not registered')
await handler({}, { enabled: true })
expect(suggestion.setEnabled).toHaveBeenCalledWith(true)
expect(configSet).not.toHaveBeenCalledWith('suggestionEnabled', true)
})
it('분당 요청 상한을 12로 강제한다', async () => {
const handler = handlers.get(IPC_CHANNELS.SUGGESTION.SET_CONFIG)
if (!handler) throw new Error('suggestion config handler was not registered')
await handler({}, { maxRequestsPerMinute: 99 })
expect(configSet).toHaveBeenCalledWith('suggestionMaxRequestsPerMinute', 12)
})
})
describe('POPUP_SUGGESTION.DISMISS', () => {
it('서비스 이벤트를 기다리지 않고 창을 숨긴 뒤 제안을 취소한다', () => {
const handler = eventHandlers.get(IPC_CHANNELS.POPUP_SUGGESTION.DISMISS)
if (!handler) throw new Error('suggestion dismiss handler was not registered')
handler({})
expect(hideSuggestionOverlay).toHaveBeenCalledTimes(1)
expect(suggestion.dismiss).toHaveBeenCalledWith('dismissed')
expect(hideSuggestionOverlay.mock.invocationCallOrder[0]).toBeLessThan(
suggestion.dismiss.mock.invocationCallOrder[0]
)
})
})

View file

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
describe('ConfigService suggestion tuning migration', () => {
afterEach(() => {
vi.doUnmock('electron-store')
vi.resetModules()
})
it('revision 3은 새 delay와 분당 상한만 바꾸고 기존 개인 설정을 보존한다', async () => {
const savedBindings = {
'suggestion-accept': [
{ device: 'keyboard' as const, code: 65, ctrl: true, alt: false, shift: false, meta: false }
]
}
const persisted = {
suggestionTuningRevision: 3,
suggestionTriggerDelayMs: 300,
suggestionMaxRequestsPerMinute: 12,
suggestionMinPrefixChars: 17,
suggestionDailyBudget: 777,
suggestionRequestTimeoutMs: 23000,
keyBindings: savedBindings
}
class TestStore<T extends Record<string, unknown>> {
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]
}
}
vi.doMock('electron-store', () => ({ default: TestStore }))
const { configGet, initConfigService, resetInMemoryConfig } = await import(
'../../../src/main/services/ConfigService'
)
await initConfigService()
expect(configGet('suggestionTuningRevision')).toBe(4)
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)
expect(configGet('keyBindings')['suggestion-accept']).toEqual(savedBindings['suggestion-accept'])
resetInMemoryConfig()
})
})

View file

@ -0,0 +1,274 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const config = vi.hoisted(() => ({
suggestionEnabled: true,
suggestionModelId: 'gemma4:e4b',
llmModelId: 'gemma4:e4b',
inputExcludedApps: [],
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 6,
suggestionDailyBudget: 500,
suggestionRequestTimeoutMs: 8000,
inputLearnTypedText: false,
inputTelemetryEnabled: false,
suggestionOverlayInteractive: true
}))
const localLlm = vi.hoisted(() => ({
isAvailable: vi.fn(() => true),
streamGenerate: vi.fn()
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: keyof typeof config) => config[key]),
configSet: vi.fn()
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => localLlm
}))
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
getInputTelemetryService: () => ({ listPhrases: vi.fn(() => []), recordExternalText: vi.fn() })
}))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({ retrieveContext: vi.fn(() => ({ continuations: [], related: [] })) })
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({ insertText: vi.fn() })
}))
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
const context = {
prefix: PREFIX,
fullText: PREFIX,
caretOffset: PREFIX.length,
anchor: null,
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'notepad.exe',
windowTitle: 'notes',
idleMs: 1000,
capturedAt: Date.now()
}
interface InternalSuggestionService {
_abort: AbortController | null
_consecutiveFailures: number
_cooldownUntil: number
_inFlight: boolean
_lastRequestAt: number
_generate(prefix: string, currentContext: typeof context, maxCandidates: number, maxChars: number): Promise<void>
_abortStaleGeneration(currentPrefix: string): void
_watchdog(): void
_generationToken: number
}
function waitForAbort(signal: AbortSignal | undefined): AsyncGenerator<string> {
return (async function* () {
await new Promise<void>((_resolve, reject) => {
if (!signal) {
reject(new Error('missing abort signal'))
return
}
signal.addEventListener('abort', () => reject(new Error('intentional abort')), { once: true })
})
yield 'unreachable'
})()
}
beforeEach(() => {
Object.assign(config, {
suggestionEnabled: true,
suggestionModelId: 'gemma4:e4b',
llmModelId: 'gemma4:e4b',
inputExcludedApps: [],
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 6,
suggestionDailyBudget: 500,
suggestionRequestTimeoutMs: 8000,
inputLearnTypedText: false,
inputTelemetryEnabled: false,
suggestionOverlayInteractive: true
})
localLlm.isAvailable.mockReset()
localLlm.isAvailable.mockReturnValue(true)
localLlm.streamGenerate.mockReset()
})
afterEach(async () => {
const { resetSuggestionServiceForTests } = await import('../../../src/main/services/SuggestionService')
resetSuggestionServiceForTests()
config.suggestionEnabled = true
vi.useRealTimers()
vi.clearAllMocks()
})
describe('SuggestionService warm-up', () => {
it('동시 warm-up 호출을 하나의 1토큰 요청으로 합치고 2분만 유지한다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield 'ok'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const warmingStates: boolean[] = []
service.on('state-changed', (state) => warmingStates.push(state.warmingUp))
await Promise.all([service.warmUp(), service.warmUp()])
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
expect(localLlm.streamGenerate).toHaveBeenCalledWith(
'hi',
expect.objectContaining({ maxTokens: 1, keepAlive: '2m' })
)
expect(warmingStates).toEqual([true, false])
})
it('비활성화하면 가용성 재시도 타이머와 warm-up을 취소한다', async () => {
vi.useFakeTimers()
localLlm.isAvailable.mockReturnValue(false)
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const warmUp = service.warmUp()
config.suggestionEnabled = false
service.applyConfig()
await warmUp
expect(vi.getTimerCount()).toBe(0)
expect(localLlm.streamGenerate).not.toHaveBeenCalled()
})
it('dismiss 취소는 실패 쿨다운을 올리거나 재귀 생성하지 않는다', 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
const cleared: string[] = []
service.on('cleared', ({ reason }) => cleared.push(reason))
const generation = internal._generate(PREFIX, context, 3, 240)
await Promise.resolve()
service.dismiss('dismissed')
await generation
expect(internal._consecutiveFailures).toBe(0)
expect(internal._cooldownUntil).toBe(0)
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
expect(cleared).toEqual(['dismissed'])
})
it('생성 중 동일 접두의 주기 스냅샷은 요청을 취소하지 않는다', async () => {
let signal: AbortSignal | undefined
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
signal = options.signal
return waitForAbort(options.signal)
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const internal = service as unknown as InternalSuggestionService
const generation = internal._generate(PREFIX, context, 3, 240)
await Promise.resolve()
service.handleTypingContext(context)
expect(signal?.aborted).toBe(false)
expect(internal._inFlight).toBe(true)
service.dismiss('dismissed')
await generation
})
it.each([
['선택', { hasSelection: true }, 'selection-active'],
['포커스 이탈', { isEditable: false }, 'not-editable']
])('생성 중 %s은 후보가 없어도 취소하고 정확한 cleared 사유를 낸다', async (_case, update, reason) => {
let signal: AbortSignal | undefined
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
signal = options.signal
return waitForAbort(options.signal)
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const cleared: string[] = []
service.on('cleared', ({ reason: clearedReason }) => cleared.push(clearedReason))
const generation = (service as unknown as InternalSuggestionService)._generate(PREFIX, context, 3, 240)
await Promise.resolve()
expect(service.getState()).toMatchObject({ candidates: [], generating: true })
service.handleTypingContext({ ...context, ...update })
await generation
expect(signal?.aborted).toBe(true)
expect(cleared).toEqual([reason])
expect(service.getState()).toMatchObject({ candidates: [], generating: false, partialText: null })
})
it('후보 게시 updated 상태에는 생성 플래그와 부분 텍스트가 남지 않는다', async () => {
localLlm.streamGenerate.mockImplementation(async function* () {
yield '다음 단계도 확인하겠습니다.'
})
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService()
const states: Array<{ candidates: unknown[]; generating: boolean; partialText: string | null }> = []
service.on('updated', (state) => states.push(state))
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 })
})
it('stale 취소는 실패 쿨다운을 올리거나 즉시 재시작하지 않는다', 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
const cleared: string[] = []
service.on('cleared', ({ reason }) => cleared.push(reason))
const generation = internal._generate(PREFIX, context, 3, 240)
await Promise.resolve()
internal._abortStaleGeneration('완전히 다른 문맥으로 바뀐 입력입니다')
await generation
expect(internal._consecutiveFailures).toBe(0)
expect(internal._cooldownUntil).toBe(0)
expect(localLlm.streamGenerate).toHaveBeenCalledTimes(1)
expect(cleared).toEqual([])
})
it('watchdog는 실제 요청 signal을 abort하고 세대를 무효화한다', async () => {
let signal: AbortSignal | undefined
localLlm.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) => {
signal = options.signal
return waitForAbort(options.signal)
})
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))
const generation = internal._generate(PREFIX, context, 3, 240)
await Promise.resolve()
internal._lastRequestAt = Date.now() - 13001
internal._watchdog()
await generation
expect(signal?.aborted).toBe(true)
expect(internal._inFlight).toBe(false)
expect(internal._consecutiveFailures).toBe(0)
expect(cleared).toEqual([])
})
})

View file

@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import {
getVoiceConversationService,
resetVoiceConversationServiceForTests,
} from '../../../src/main/services/VoiceConversationService'
type ChatOptions = {
signal?: AbortSignal
maxTokens?: number
timeoutMs?: number
keepAlive?: string
}
const mocks = vi.hoisted(() => ({
audioStart: vi.fn(async () => undefined),
audioStop: vi.fn(async () => undefined),
chatStream: vi.fn(),
premiumCancel: vi.fn(),
speakSentences: vi.fn(async () => undefined),
ttsStop: vi.fn(),
send: vi.fn(),
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({
start: mocks.audioStart,
stop: mocks.audioStop,
on: vi.fn(),
off: vi.fn(),
}),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({ initialize: vi.fn(async () => undefined) }),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
cancelGeneration: mocks.premiumCancel,
isAvailable: () => false,
}),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
chatStream: mocks.chatStream,
}),
}))
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
getTTSPlaybackService: () => ({
speakSentences: mocks.speakSentences,
stop: mocks.ttsStop,
}),
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: () => undefined,
}))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({
canUse: () => ({ allowed: true }),
promptUpgrade: vi.fn(),
}),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: { send: mocks.send },
}),
}))
function waitForAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
async function* waitUntilAborted(options?: ChatOptions): AsyncGenerator<string, string> {
if (!options?.signal) throw new Error('Expected caller-owned abort signal')
await waitForAbort(options.signal)
return ''
}
describe('VoiceConversationService local response ownership', () => {
beforeEach(() => {
resetVoiceConversationServiceForTests()
vi.clearAllMocks()
mocks.chatStream.mockImplementation((_messages: unknown, options?: ChatOptions) => waitUntilAborted(options))
})
afterEach(() => {
resetVoiceConversationServiceForTests()
})
it('rejects a second send without starting a second local chat or adding duplicate history', async () => {
const service = getVoiceConversationService()
await service.startSession()
const firstResponse = service.sendTextMessage('first')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
await expect(service.sendTextMessage('second')).rejects.toMatchObject({
code: ErrorCode.ConversationLLMFailed,
})
expect(mocks.chatStream).toHaveBeenCalledTimes(1)
expect(service.getHistory().map((message) => message.content)).toEqual(['first'])
service.stopSession()
await firstResponse
})
it.each([
['stopSession', (service: ReturnType<typeof getVoiceConversationService>) => service.stopSession()],
['cancelResponse', (service: ReturnType<typeof getVoiceConversationService>) => service.cancelResponse()],
])('aborts the local stream signal when %s is called', async (_action, cancel) => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel me')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
const options = mocks.chatStream.mock.calls[0][1] as ChatOptions
cancel(service)
expect(options.signal?.aborted).toBe(true)
await response
})
it('sets bounded local chat options for voice responses', async () => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('options')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
expect(mocks.chatStream).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
signal: expect.any(AbortSignal),
maxTokens: 512,
timeoutMs: 60_000,
keepAlive: '2m',
}),
)
service.stopSession()
await response
})
it('does not publish late assistant output or start TTS after cancellation', async () => {
let releaseLateToken: (() => void) | undefined
const lateToken = new Promise<void>((resolve) => {
releaseLateToken = resolve
})
mocks.chatStream.mockImplementation(() => (async function* (): AsyncGenerator<string, string> {
await lateToken
yield 'late answer.'
return 'late answer.'
})())
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel before response')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
service.cancelResponse()
mocks.send.mockClear()
releaseLateToken?.()
await response
const lateConversationEvents = mocks.send.mock.calls.filter(([channel]) => (
channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_DELTA
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_MESSAGE
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED
))
expect(lateConversationEvents).toEqual([])
expect(mocks.speakSentences).not.toHaveBeenCalled()
expect(service.getHistory().map((message) => message.content)).toEqual(['cancel before response'])
})
})

View file

@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest'
import {
buildLocalSuggestionCandidates,
calculateFrictionInsight,
rankFlowWindows,
recommendAppExclusion,
selectPhraseHints,
type InputAppSuggestionStat,
type InputHourlyStat,
type InputPrivacyReceipt,
type PersonalPhrase
} from '@d3ro/core/input-intelligence'
import {
auditKeyBindingMap,
createDefaultBindingMap,
type KeyBinding
} from '@d3ro/core/keybinding'
const HOUR = 60 * 60 * 1000
const DAY = 24 * HOUR
function binding(code: number, modifiers: Partial<KeyBinding> = {}): KeyBinding {
return {
device: 'keyboard',
code,
ctrl: false,
alt: false,
shift: false,
meta: false,
...modifiers
}
}
describe('로컬 플로우 도메인', () => {
it('편집 마찰을 0~1 비율과 경계 등급으로 계산한다', () => {
expect(calculateFrictionInsight(0, 0)).toEqual({ rate: 0, editsPer100Chars: 0, band: 'steady' })
expect(calculateFrictionInsight(92, 8)).toMatchObject({ rate: 0.08, editsPer100Chars: 8.7, band: 'watch' })
expect(calculateFrictionInsight(82, 18)).toMatchObject({ rate: 0.18, editsPer100Chars: 22, band: 'high' })
})
it('플로우 시간대는 밀도·안정성 순으로 고르고 입력 없는 시간은 빼며 원본을 바꾸지 않는다', () => {
const hourly: InputHourlyStat[] = [
{ hour: 11, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 9, keystrokes: 1400, clicks: 2, chars: 1200, backspaces: 0, activeMs: HOUR },
{ hour: 13, keystrokes: 200, clicks: 0, chars: 300, backspaces: 100, activeMs: HOUR / 2 },
{ hour: 3, keystrokes: 0, clicks: 0, chars: 0, backspaces: 0, activeMs: 0 }
]
const before = structuredClone(hourly)
expect(rankFlowWindows(hourly, 1, 3)).toEqual([
{ hour: 9, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 11, score: 100, activeMinutes: 60, chars: 1200, frictionRate: 0 },
{ hour: 13, score: 44, activeMinutes: 30, chars: 300, frictionRate: 0.25 }
])
expect(hourly).toEqual(before)
})
it('활성 일수로 나눠 같은 시간대라도 하루 밀도를 낮춘다', () => {
const [window] = rankFlowWindows(
[{ hour: 9, keystrokes: 1400, clicks: 0, chars: 1200, backspaces: 0, activeMs: HOUR }],
2
)
expect(window).toMatchObject({ hour: 9, score: 58, activeMinutes: 30, chars: 1200 })
})
it('앱 문체 보너스와 반감기로 개인 문구를 정렬하며 원본 배열을 바꾸지 않는다', () => {
const now = 1_800_000_000_000
const phrases: PersonalPhrase[] = [
{
id: 'z-old-frequent',
phrase: '배포 일정을 공유합니다',
count: 16,
source: 'typed',
appName: 'Slack.exe',
lastUsedAt: now - DAY * 90,
createdAt: now - DAY * 90
},
{
id: 'b-current-app',
phrase: '검토 결과를 남깁니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
},
{
id: 'a-current-app-tie',
phrase: '다음 조치를 확인합니다',
count: 1,
source: 'typed',
appName: 'Notion.exe',
lastUsedAt: now,
createdAt: now
}
]
const before = structuredClone(phrases)
expect(selectPhraseHints(phrases, '', 3, { appName: 'notion.exe', now })).toEqual([
'다음 조치를 확인합니다',
'검토 결과를 남깁니다',
'배포 일정을 공유합니다'
])
expect(phrases).toEqual(before)
})
it('반복적인 비가독 앱만 제외를 권하고 읽힌 기록이 하나라도 있으면 멈춘다', () => {
expect(
recommendAppExclusion({ appName: 'Legacy.exe', samples: 4, readable: 0, unreadable: 3, empty: 1 })
).toEqual({ appName: 'Legacy.exe', reason: 'repeated-unreadable', samples: 4 })
expect(
recommendAppExclusion({ appName: 'Mixed.exe', samples: 6, readable: 1, unreadable: 5, empty: 0 })
).toBeNull()
expect(
recommendAppExclusion({ appName: ' ', samples: 6, readable: 0, unreadable: 6, empty: 0 })
).toBeNull()
})
it('로컬 제안은 출처 우선순위·접미 중첩 제거·중복 제거를 지킨다', () => {
expect(
buildLocalSuggestionCandidates(
'회의 결과',
{
continuationHints: ['를 공유합니다.'],
relatedHints: ['결과를 정리합니다.', '무관한 전체 문장'],
phraseHints: ['결과를 정리합니다.', '결과를 검토합니다.']
}
)
).toEqual(['를 공유합니다.', '를 정리합니다.', '를 검토합니다.'])
})
it('문장이 끝난 뒤에만 겹침 없는 전체 문구를 로컬 제안으로 허용한다', () => {
const hints = { continuationHints: [], relatedHints: ['다음 안건을 정리합니다.'], phraseHints: [] }
expect(buildLocalSuggestionCandidates('회의를 마쳤다', hints)).toEqual([])
expect(buildLocalSuggestionCandidates('회의를 마쳤다.', hints)).toEqual(['다음 안건을 정리합니다.'])
})
it('프라이버시·앱 품질 계약은 수량과 로컬 경계를 명시한다', () => {
const receipt: InputPrivacyReceipt = {
localOnly: true,
rawKeyContentStored: false,
retention: {
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
},
counts: { activityBuckets: 3, typingSamples: 2, personalPhrases: 4, suggestions: 5 }
}
const appStat: InputAppSuggestionStat = {
appName: 'notion.exe',
total: 4,
accepted: 2,
acceptRate: 0.5,
avgLatencyMs: 310
}
expect(receipt.counts.personalPhrases).toBe(4)
expect(receipt.retention).toEqual({
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
})
expect(appStat.acceptRate).toBe(0.5)
})
})
describe('단축키 안전 감사', () => {
it('유효하지 않은 바인딩과 충돌을 한 번씩 보고하고 홀드·더블프레스 기본 예외는 유지한다', () => {
const map = createDefaultBindingMap()
map.caption = [binding(0x56, { ctrl: true, shift: true })]
map.dictation = [binding(999)]
const issues = auditKeyBindingMap(map)
expect(issues).toContainEqual({
kind: 'invalid',
actionId: 'dictation',
bindingIndex: 0,
binding: binding(999),
reasonKey: 'keybinding.reject.unknownKey',
conflictActionIds: []
})
expect(issues).toContainEqual({
kind: 'conflict',
actionId: 'caption',
bindingIndex: 0,
binding: binding(0x56, { ctrl: true, shift: true }),
reasonKey: null,
conflictActionIds: ['history-popup']
})
expect(issues.filter((issue) => issue.kind === 'conflict')).toHaveLength(1)
})
})

View file

@ -0,0 +1,581 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inputActivity, personalPhrases, suggestions, typingSamples } from '../../../src/main/db/schema'
type ActivityRow = {
id: string
date: string
hour: number
appName: string
keystrokes: number
shortcuts: number
backspaces: number
clicks: number
doubleClicks: number
scrollTicks: number
mouseDistancePx: number
chars: number
words: number
sentences: number
activeMs: number
updatedAt: number
}
type PhraseRow = {
id: string
phrase: string
count: number
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
appName: string | null
lastUsedAt: number | null
createdAt: number
}
type SuggestionRow = {
id: string
appName: string | null
prefixText: string
suggestionText: string
candidateCount: number
model: string | null
latencyMs: number | null
accepted: boolean
createdAt: number
}
type SampleRow = {
id: string
text: string
wordCount: number
charCount: number
appName: string | null
windowTitle: string | null
source: 'typed' | 'voice' | 'suggestion' | 'clipboard'
createdAt: number
}
type StreamOptions = { signal?: AbortSignal }
type StreamFactory = (prompt: string, options: StreamOptions) => AsyncIterable<string>
const harness = vi.hoisted(() => {
const config = new Map<string, unknown>()
const failure = { read: false, delete: false }
const model: { available: boolean; streamGenerate: StreamFactory } = {
available: false,
streamGenerate: async function* () {
return
}
}
const rows: {
activity: ActivityRow[]
phrases: PhraseRow[]
samples: SampleRow[]
suggestions: SuggestionRow[]
} = { activity: [], phrases: [], samples: [], suggestions: [] }
const rowsFor = (table: unknown): ActivityRow[] | PhraseRow[] | SampleRow[] | SuggestionRow[] => {
if (table === inputActivity) return rows.activity
if (table === personalPhrases) return rows.phrases
if (table === typingSamples) return rows.samples
return rows.suggestions
}
const database = {
select(selection?: Record<string, unknown>) {
if (failure.read) throw new Error('receipt read failed')
return {
from(table: unknown) {
const selectedRows = rowsFor(table)
const chain = {
where: () => chain,
orderBy: () => chain,
limit: () => chain,
all: () => {
const keys = Object.keys(selection ?? {})
if (table === suggestions && keys.includes('appName')) {
return rows.suggestions.map(({ appName, accepted, latencyMs }) => ({ appName, accepted, latencyMs }))
}
return [...selectedRows]
},
get: () => {
const keys = Object.keys(selection ?? {})
if (keys.includes('total')) {
const suggestionRows = rows.suggestions
const latencyRows = suggestionRows.filter((row) => row.latencyMs !== null)
return {
total: suggestionRows.length,
accepted: suggestionRows.filter((row) => row.accepted).length,
avgLatency:
latencyRows.length === 0
? null
: latencyRows.reduce((total, row) => total + (row.latencyMs ?? 0), 0) /
latencyRows.length
}
}
if (keys.includes('value')) return { value: selectedRows.length }
return selectedRows[0]
}
}
return chain
}
}
},
insert(table: unknown) {
return {
values(value: Record<string, unknown>) {
const selectedRows = rowsFor(table)
const apply = () => {
if (table === personalPhrases) {
const phrase = value.phrase as string
const existing = rows.phrases.find((row) => row.phrase === phrase)
if (existing) {
existing.count += 1
existing.lastUsedAt = value.lastUsedAt as number
existing.appName = value.appName as string | null
return
}
}
selectedRows.push(value as never)
}
return {
run: () => apply(),
onConflictDoUpdate: () => ({ run: () => apply() })
}
}
}
},
delete(table: unknown) {
const selectedRows = rowsFor(table)
const chain = {
where: () => chain,
run: () => {
if (failure.delete) throw new Error('delete failed')
selectedRows.splice(0, selectedRows.length)
return { changes: 1 }
}
}
return chain
},
update: () => ({ set: () => ({ where: () => ({ run: () => ({ changes: 1 }) }) }) })
}
const uia = {
isAvailable: () => false,
lastReason: 'test',
lastSuccessAt: 0,
getSnapshot: vi.fn(async () => ({
available: false,
reason: 'test',
isPassword: false,
isEditable: false,
isComposing: false,
hasSelection: false,
textSource: 'none' as const,
text: '',
caretOffset: null,
caretRect: null,
elementRect: null,
windowTitle: null,
appName: null,
processId: null,
capturedAt: Date.now()
}))
}
return {
config,
failure,
model,
rows,
database,
uia,
graph: { continuations: [] as string[], related: [] as string[] }
}
})
vi.mock('electron', () => ({ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }) } }))
vi.mock('uiohook-napi', () => ({ uIOhook: { on: vi.fn(), removeListener: vi.fn() } }))
vi.mock('../../../src/main/db', () => ({ getDatabase: () => harness.database }))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => harness.config.get(key),
configSet: (key: string, value: unknown) => harness.config.set(key, value)
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })
}))
vi.mock('../../../src/main/services/global-input-hook', () => ({ acquireGlobalInputHook: () => () => undefined }))
vi.mock('../../../src/main/services/KeyBindingService', () => ({ uiohookCodeToVk: () => null }))
vi.mock('../../../src/main/services/UiaContextService', () => ({
getUiaContextService: () => harness.uia
}))
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({
clearAll: vi.fn(),
runMaintenance: vi.fn(),
indexText: vi.fn(),
retrieveContext: () => harness.graph
})
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => harness.model.available,
streamGenerate: (prompt: string, options: StreamOptions) => harness.model.streamGenerate(prompt, options)
})
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({ getTextInsertService: () => ({ insertText: vi.fn() }) }))
import {
getInputTelemetryService,
resetInputTelemetryServiceForTests
} from '../../../src/main/services/InputTelemetryService'
import {
getSuggestionService,
resetSuggestionServiceForTests
} from '../../../src/main/services/SuggestionService'
const now = Date.now()
function activity(overrides: Partial<ActivityRow> = {}): ActivityRow {
return {
id: crypto.randomUUID(),
date: new Date(now).toISOString().slice(0, 10),
hour: 9,
appName: 'Notion.exe',
keystrokes: 0,
shortcuts: 0,
backspaces: 0,
clicks: 0,
doubleClicks: 0,
scrollTicks: 0,
mouseDistancePx: 0,
chars: 0,
words: 0,
sentences: 0,
activeMs: 0,
updatedAt: now,
...overrides
}
}
function typingContext(overrides: Partial<Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0]> = {}) {
return {
prefix: '오늘 회의 결과를',
fullText: '오늘 회의 결과를',
caretOffset: 9,
anchor: { x: 1, y: 1, width: 1, height: 1 },
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'Notion.exe',
windowTitle: '회의록',
idleMs: 300,
capturedAt: now,
...overrides
}
}
beforeEach(() => {
harness.config.clear()
harness.config.set('inputExcludedApps', [])
harness.config.set('inputLearnTypedText', true)
harness.config.set('suggestionEnabled', true)
harness.config.set('suggestionMinPrefixChars', 4)
harness.config.set('suggestionTriggerDelayMs', 100)
harness.config.set('suggestionMaxRequestsPerMinute', 20)
harness.config.set('suggestionDailyBudget', 100)
harness.failure.read = false
harness.failure.delete = false
harness.model.available = false
harness.model.streamGenerate = async function* () {
return
}
harness.rows.activity.splice(0)
harness.rows.phrases.splice(0)
harness.rows.samples.splice(0)
harness.rows.suggestions.splice(0)
harness.graph.continuations = []
harness.graph.related = []
resetInputTelemetryServiceForTests()
resetSuggestionServiceForTests()
harness.uia.getSnapshot.mockClear()
})
describe('입력 플로우 서비스', () => {
it('제안 표시 중에만 오래된 키 입력과 mouse-up 후 UIA 포커스를 다시 확인한다', async () => {
const telemetry = getInputTelemetryService() as unknown as {
_running: boolean
_lastKeyAt: number
_sampleWhileTyping: () => Promise<void>
_handleMouseUp: (event: { x: number; y: number }) => void
setSuggestionPresentationActive: (active: boolean) => void
}
telemetry._running = true
telemetry._lastKeyAt = Date.now() - 6000
await telemetry._sampleWhileTyping()
expect(harness.uia.getSnapshot).not.toHaveBeenCalled()
telemetry.setSuggestionPresentationActive(true)
await telemetry._sampleWhileTyping()
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(1)
vi.useFakeTimers()
telemetry._handleMouseUp({ x: 1, y: 1 })
await vi.advanceTimersByTimeAsync(120)
expect(harness.uia.getSnapshot).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('시간대 집계에 플로우·마찰과 앱별 제안 품질을 함께 반환한다', () => {
harness.rows.activity.push(
activity({ hour: 9, chars: 1200, backspaces: 30, activeMs: 60 * 60 * 1000, keystrokes: 1300 }),
activity({ id: crypto.randomUUID(), hour: 14, chars: 120, backspaces: 60, activeMs: 20 * 60 * 1000 })
)
harness.rows.suggestions.push(
{ id: 's1', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'b', candidateCount: 1, model: 'm', latencyMs: 100, accepted: true, createdAt: now },
{ id: 's2', appName: 'Notion.exe', prefixText: 'a', suggestionText: 'c', candidateCount: 1, model: 'm', latencyMs: 300, accepted: false, createdAt: now },
{ id: 's3', appName: 'Slack.exe', prefixText: 'a', suggestionText: 'd', candidateCount: 1, model: 'm', latencyMs: null, accepted: true, createdAt: now }
)
const summary = getInputTelemetryService().getSummary(1)
expect(summary.hourly[9]).toMatchObject({ chars: 1200, backspaces: 30, activeMs: 3600000 })
expect(summary.friction).toMatchObject({ band: 'steady', editsPer100Chars: 6.8 })
expect(summary.flowWindows[0]).toMatchObject({ hour: 9 })
expect(summary.suggestionApps).toEqual([
{ appName: 'Notion.exe', total: 2, accepted: 1, acceptRate: 0.5, avgLatencyMs: 200 },
{ appName: 'Slack.exe', total: 1, accepted: 1, acceptRate: 1, avgLatencyMs: null }
])
})
it('개인정보 영수증은 로컬 보존 경계와 실제 행 수를 반환한다', () => {
harness.rows.activity.push(activity())
harness.rows.samples.push({ id: 'sample', text: '학습 문장입니다', wordCount: 2, charCount: 7, appName: null, windowTitle: null, source: 'typed', createdAt: now })
harness.rows.phrases.push({ id: 'phrase', phrase: '학습 문장입니다', count: 1, source: 'typed', appName: null, lastUsedAt: now, createdAt: now })
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
expect(getInputTelemetryService().getPrivacyReceipt()).toMatchObject({
localOnly: true,
rawKeyContentStored: false,
retention: {
activityDays: 30,
typingSamplesDays: 30,
suggestionDays: 30,
personalPhrases: 'until-deleted'
},
counts: { activityBuckets: 1, typingSamples: 1, personalPhrases: 1, suggestions: 1 }
})
})
it('개인정보 영수증 조회 실패를 빈 영수증으로 위장하지 않는다', () => {
harness.failure.read = true
expect(() => getInputTelemetryService().getPrivacyReceipt()).toThrow('receipt read failed')
})
it('전체 삭제는 제안 이력까지 제거한다', () => {
harness.rows.activity.push(activity())
harness.rows.suggestions.push({ id: 's', appName: null, prefixText: '', suggestionText: '후보', candidateCount: 1, model: 'm', latencyMs: 10, accepted: false, createdAt: now })
getInputTelemetryService().clearAll()
expect(getInputTelemetryService().getPrivacyReceipt().counts).toEqual({
activityBuckets: 0,
typingSamples: 0,
personalPhrases: 0,
suggestions: 0
})
})
it('전체 삭제 실패를 성공처럼 반환하지 않고 메모리 초기화도 건너뛴다', () => {
harness.rows.activity.push(activity())
const service = getInputTelemetryService() as unknown as {
_pending: Map<string, unknown>
clearAll: () => void
}
service._pending.set('pending', {})
harness.failure.delete = true
expect(() => service.clearAll()).toThrow('delete failed')
expect(harness.rows.activity).toHaveLength(1)
expect(service._pending.size).toBe(1)
})
it('보존 정리는 만료 제안 이력도 같은 경계로 정리한다', () => {
harness.rows.suggestions.push({
id: 'expired',
appName: null,
prefixText: '',
suggestionText: '만료 후보',
candidateCount: 1,
model: 'm',
latencyMs: 10,
accepted: false,
createdAt: now - 31 * 24 * 60 * 60 * 1000
})
const service = getInputTelemetryService() as unknown as {
_pruneOldData: (at: number) => void
}
service._pruneOldData(now)
expect(getInputTelemetryService().getPrivacyReceipt().counts.suggestions).toBe(0)
})
it('학습 문구에 마지막 앱을 저장하고 목록 계약에 매핑한다', () => {
const service = getInputTelemetryService() as unknown as {
_learnText: (text: string, meta: { appName: string | null; windowTitle: string | null; source: 'typed' }) => void
}
service._learnText('배포 일정을 공유합니다. 다음 조치를 확인합니다.', {
appName: 'Slack.exe',
windowTitle: '채널',
source: 'typed'
})
expect(getInputTelemetryService().listPhrases()).toEqual(
expect.arrayContaining([expect.objectContaining({ appName: 'Slack.exe' })])
)
})
it('비밀번호 스냅샷은 제외 추천 증거에 넣지 않고 반복 비가독 앱만 추천한다', () => {
const service = getInputTelemetryService() as unknown as {
_foreground: { appName: string | null }
_recordReadabilityEvidence: (snapshot: { isPassword: boolean; isEditable: boolean; textSource: 'none' | 'value'; text: string }) => void
}
service._foreground.appName = 'Legacy.exe'
for (let index = 0; index < 4; index += 1) {
service._recordReadabilityEvidence({ isPassword: false, isEditable: false, textSource: 'none', text: '' })
}
service._recordReadabilityEvidence({ isPassword: true, isEditable: false, textSource: 'none', text: '' })
expect(getInputTelemetryService().getState().exclusionRecommendation).toEqual({
appName: 'Legacy.exe',
reason: 'repeated-unreadable',
samples: 4
})
})
it('모델이 없을 때 정책을 지키며 로컬 기억 후보와 출처를 게시한다', () => {
harness.rows.phrases.push({ id: 'phrase', phrase: '검토 결과를 공유합니다', count: 4, source: 'typed', appName: 'Notion.exe', lastUsedAt: now, createdAt: now })
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
getSuggestionService().handleTypingContext({
prefix: '오늘 회의 결과를',
fullText: '오늘 회의 결과를',
caretOffset: 9,
anchor: { x: 1, y: 1, width: 1, height: 1 },
isPassword: false,
isEditable: true,
isComposing: false,
available: true,
appName: 'Notion.exe',
windowTitle: '회의록',
idleMs: 300,
capturedAt: now
})
expect(getSuggestionService().getState()).toMatchObject({
visible: true,
provenance: { mode: 'local-memory', continuationCount: 1, appPhraseCount: 1 }
})
expect(harness.rows.suggestions[0]).toMatchObject({ model: 'local-memory' })
})
it('timeout abort가 스트림에서 throw되어도 로컬 기억 fallback을 한 번 게시한다', async () => {
const context = typingContext()
const service = getSuggestionService() as unknown as {
_lastContext: typeof context | null
_lastContextAt: number
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
}
harness.config.set('llmModelId', 'local-model')
harness.config.set('suggestionRequestTimeoutMs', 10)
harness.model.available = true
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
harness.model.streamGenerate = (_prompt, options) => ({
async *[Symbol.asyncIterator]() {
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
})
}
})
service._lastContext = context
service._lastContextAt = Date.now()
await service._generate(context.prefix, context, 3, 160)
expect(getSuggestionService().getState()).toMatchObject({
visible: true,
provenance: { mode: 'local-memory', continuationCount: 1 }
})
expect(harness.rows.suggestions).toHaveLength(1)
})
it('timeout 중 현재 문맥과 세대가 바뀌면 로컬 기억 fallback을 게시하지 않는다', async () => {
const context = typingContext()
const service = getSuggestionService() as unknown as {
_generationToken: number
_lastContext: typeof context | null
_lastContextAt: number
_generate: (prefix: string, context: typeof context, maxCandidates: number, maxChars: number) => Promise<void>
}
harness.config.set('llmModelId', 'local-model')
harness.config.set('suggestionRequestTimeoutMs', 20)
harness.model.available = true
harness.graph.continuations = ['다음 조치를 정리하겠습니다']
harness.model.streamGenerate = (_prompt, options) => ({
async *[Symbol.asyncIterator]() {
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new Error('timeout abort')), { once: true })
})
}
})
service._lastContext = context
service._lastContextAt = Date.now()
const changed = typingContext({ prefix: '다른 문맥입니다', appName: 'Slack.exe', windowTitle: '채널' })
const invalidate = setTimeout(() => {
service._generationToken += 1
service._lastContext = changed
service._lastContextAt = Date.now()
}, 1)
await service._generate(context.prefix, context, 3, 160)
clearTimeout(invalidate)
expect(getSuggestionService().getState().visible).toBe(false)
expect(harness.rows.suggestions).toHaveLength(0)
})
it('세대가 달라진 로컬 후보는 게시하지 않아 명시 취소 뒤 되살아나지 않는다', () => {
const service = getSuggestionService() as unknown as {
_generationToken: number
_publishLocalMemory: (
prefix: string,
context: Parameters<ReturnType<typeof getSuggestionService>['handleTypingContext']>[0],
maxCandidates: number,
maxChars: number,
latencyMs: number,
token: number
) => boolean
}
service._generationToken = 2
expect(
service._publishLocalMemory(
'오늘 회의 결과를',
{
prefix: '오늘 회의 결과를', fullText: '', caretOffset: null, anchor: null, isPassword: false,
isEditable: true, isComposing: false, hasSelection: false, available: true, appName: 'Notion.exe', windowTitle: null,
idleMs: 300, capturedAt: now
},
3,
160,
0,
1
)
).toBe(false)
expect(getSuggestionService().getState().visible).toBe(false)
})
})

View file

@ -0,0 +1,470 @@
// tests/main/services/input-intelligence.test.ts
// 입력 인텔리전스 정책/집계 순수 함수 테스트.
//
// 여기 있는 함수들이 서비스의 판단 근거다:
// - 어떤 키를 어떤 종류로 세는가 (내용은 저장하지 않는다)
// - 케어앞 텍스트 diff 로 얼마나 셌는가
// - 언제 제안을 요청/스킵/삭제하는가
// - 오버레이를 어디에 붙이는가
// 따라서 LLM 없이도 전부 검증할 수 있어야 한다.
import { describe, it, expect } from 'vitest'
import {
INPUT_TELEMETRY_DEFAULTS,
PASTE_INSERTION_THRESHOLD_CHARS,
SUGGESTION_DEFAULTS,
anchorFloatingPanel,
classifyKeyStroke,
computeTypedDelta,
countSentences,
countWords,
decideSuggestion,
decideSuggestionRefresh,
emptyActivityBucket,
endsSentence,
extractPhrases,
isAppExcluded,
isWordBoundaryKey,
manhattanDistance,
mergeActivityBucket,
parseSuggestionCandidates,
pixelsToMeters,
sanitizeSuggestionLine,
selectPhraseHints,
summarizeActivity,
prefixTail,
textBeforeCaret,
type PersonalPhrase,
type SuggestionPolicyInput
} from '@d3ro/core/input-intelligence'
const NO_MODS = { ctrl: false, alt: false, shift: false, meta: false }
describe('classifyKeyStroke', () => {
it('문자/숫자/기능 키를 종류로 나눈다', () => {
expect(classifyKeyStroke(0x41, NO_MODS)).toBe('letter') // A
expect(classifyKeyStroke(0x5a, NO_MODS)).toBe('letter') // Z
expect(classifyKeyStroke(0x30, NO_MODS)).toBe('digit') // 0
expect(classifyKeyStroke(0x60, NO_MODS)).toBe('digit') // numpad 0
expect(classifyKeyStroke(0x20, NO_MODS)).toBe('space')
expect(classifyKeyStroke(0x0d, NO_MODS)).toBe('enter')
expect(classifyKeyStroke(0x08, NO_MODS)).toBe('backspace')
expect(classifyKeyStroke(0x2e, NO_MODS)).toBe('delete')
expect(classifyKeyStroke(0x27, NO_MODS)).toBe('arrow')
expect(classifyKeyStroke(0x70, NO_MODS)).toBe('function')
expect(classifyKeyStroke(0x11, NO_MODS)).toBe('modifier')
expect(classifyKeyStroke(0xe5, NO_MODS)).toBe('ime')
})
it('수정자 조합은 단축키로 다', () => {
expect(classifyKeyStroke(0x41, { ...NO_MODS, ctrl: true })).toBe('shortcut')
expect(classifyKeyStroke(0x41, { ...NO_MODS, alt: true })).toBe('shortcut')
expect(classifyKeyStroke(0x41, { ...NO_MODS, meta: true })).toBe('shortcut')
})
it('알 수 없는 키는 other 이며 키 내용을 담지 않는다', () => {
expect(classifyKeyStroke(0x0000, NO_MODS)).toBe('other')
})
it('단어 경계 키 판정', () => {
expect(isWordBoundaryKey('space')).toBe(true)
expect(isWordBoundaryKey('enter')).toBe(true)
expect(isWordBoundaryKey('letter')).toBe(false)
})
})
describe('텍스트 지표', () => {
it('단어 수 — 공백 분리 토큰 중 문자/숫자가 있는 것만', () => {
expect(countWords('')).toBe(0)
expect(countWords('hello world')).toBe(2)
expect(countWords('안녕하세요 오늘 날씨')).toBe(3)
expect(countWords(' , . ! ')).toBe(0)
expect(countWords('v1.2 배포 준비')).toBe(3)
})
it('문장 수 — 종결 부호와 개행', () => {
expect(countSentences('하나. 둘! 셋?')).toBe(3)
expect(countSentences('no terminator')).toBe(0)
expect(countSentences('첫 줄\n둘째 줄')).toBe(1)
expect(countSentences('첫 줄\n둘째 줄\n')).toBe(2)
})
it('문장 종결 판정', () => {
expect(endsSentence('났습니다.')).toBe(true)
expect(endsSentence('아직 쓰는 중')).toBe(false)
expect(endsSentence('')).toBe(false)
})
it('케어 앞 텍스트만 어낸다', () => {
expect(textBeforeCaret('hello world', 5)).toBe('hello')
expect(textBeforeCaret('hello', null)).toBe('hello')
expect(textBeforeCaret('hello', 99)).toBe('hello')
})
})
describe('computeTypedDelta', () => {
it('끝에 이어 쓰면 삽입 구간만 센다', () => {
const delta = computeTypedDelta('안녕하세요', '안녕하세요 반갑', { keepText: true })
expect(delta.insertedChars).toBe(3)
expect(delta.insertedText).toBe(' 반갑')
expect(delta.replaced).toBe(false)
})
it('IME 커밋 텍스트(한글)도 그대로 계산된다', () => {
const delta = computeTypedDelta('', '오늘 회의는', { keepText: true })
expect(delta.insertedChars).toBe(6)
expect(delta.insertedWords).toBe(2)
expect(delta.replaced).toBe(false)
})
it('중간 삽입도 찾아낸다', () => {
const delta = computeTypedDelta('abc', 'abXc', { keepText: true })
expect(delta.insertedText).toBe('X')
})
it('제만 있으면 0 이다', () => {
const delta = computeTypedDelta('abcdef', 'abc', { keepText: true })
expect(delta.insertedChars).toBe(0)
expect(delta.insertedText).toBe('')
})
it('여넣기 크기 삽입은 통계에서 제외한다', () => {
const pasted = 'x'.repeat(PASTE_INSERTION_THRESHOLD_CHARS + 1)
const delta = computeTypedDelta('', pasted, { keepText: true })
expect(delta.replaced).toBe(true)
expect(delta.insertedChars).toBe(0)
expect(delta.insertedText).toBe('')
})
it('학습이 꺼져 있으면 텍스트를 담지 않는다', () => {
const delta = computeTypedDelta('', 'hello', { keepText: false })
expect(delta.insertedChars).toBe(5)
expect(delta.insertedText).toBe('')
})
})
describe('decideSuggestion', () => {
function policy(overrides: Partial<SuggestionPolicyInput> = {}): SuggestionPolicyInput {
return {
enabled: true,
modelAvailable: true,
overlayVisible: false,
composing: false,
hasSelection: false,
isPassword: false,
isEditable: true,
appName: 'chrome.exe',
excludedApps: [],
prefix: '오늘 회의에서 논의한 내용을 정리해서',
idleMs: SUGGESTION_DEFAULTS.triggerDelayMs + 50,
triggerDelayMs: SUGGESTION_DEFAULTS.triggerDelayMs,
minPrefixChars: SUGGESTION_DEFAULTS.minPrefixChars,
sinceLastRequestMs: SUGGESTION_DEFAULTS.minIntervalMs + 1,
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
requestsThisMinute: 0,
maxRequestsPerMinute: SUGGESTION_DEFAULTS.maxRequestsPerMinute,
requestsToday: 0,
dailyBudget: SUGGESTION_DEFAULTS.dailyBudget,
...overrides
}
}
it('조건이 모두 맞으면 요청한다', () => {
const decision = decideSuggestion(policy())
expect(decision).toEqual({ action: 'request', prefix: '오늘 회의에서 논의한 내용을 정리해서' })
})
it('비밀번호 필드는 다른 조건보다 먼저 차단한다', () => {
expect(decideSuggestion(policy({ isPassword: true }))).toEqual({
action: 'clear',
reason: 'password-field'
})
})
it('IME 조합 중에도 손을 멈추면 제안을 만든다 (한국어 필수)', () => {
// 조합을 하드 차단하면 한국어에서 "한 번 뜨고 이후 안 뜨는" 증상이 된다.
const idleEnough = SUGGESTION_DEFAULTS.triggerDelayMs * 2 + 100
expect(decideSuggestion(policy({ composing: true, idleMs: idleEnough })).action).toBe('request')
})
it('IME 조합 중 타이핑이 이어지면 조용히 넘어간다', () => {
const decision = decideSuggestion(
policy({ composing: true, idleMs: SUGGESTION_DEFAULTS.triggerDelayMs - 50 })
)
expect(decision).toEqual({ action: 'skip', reason: 'composing' })
})
it('비활성/편집 불가/제외 앱은 clear', () => {
expect(decideSuggestion(policy({ enabled: false }))).toEqual({ action: 'clear', reason: 'disabled' })
expect(decideSuggestion(policy({ isEditable: false }))).toEqual({
action: 'clear',
reason: 'not-editable'
})
expect(decideSuggestion(policy({ excludedApps: ['Chrome'] }))).toEqual({
action: 'clear',
reason: 'excluded-app'
})
})
it('비축소 선택 중에는 제안을 즉시 지운다', () => {
expect(decideSuggestion(policy({ hasSelection: true }))).toEqual({
action: 'clear',
reason: 'selection-active'
})
})
it('문장이 마침표로 끝나도 다음 문장을 제안한다 (기능 목적)', () => {
// "다음 문장 제안" 이므로 종결 부호 뒤가 오히려 제안이 필요한 지점이다.
expect(decideSuggestion(policy({ prefix: '오늘 회의는 여기서 끝났습니다.' }))).toEqual({
action: 'request',
prefix: '오늘 회의는 여기서 끝났습니다.'
})
})
it('접두가 짧으면 지운다', () => {
expect(decideSuggestion(policy({ prefix: '짧음' }))).toEqual({
action: 'clear',
reason: 'prefix-too-short'
})
expect(decideSuggestion(policy({ prefix: ' ' }))).toEqual({
action: 'clear',
reason: 'empty-prefix'
})
})
it('디바운스/모델 없음/레이트 리밋/예산 초과는 skip', () => {
expect(decideSuggestion(policy({ idleMs: 10 }))).toEqual({ action: 'skip', reason: 'debounce' })
expect(decideSuggestion(policy({ modelAvailable: false }))).toEqual({
action: 'skip',
reason: 'model-unavailable'
})
expect(decideSuggestion(policy({ requestsThisMinute: 99 }))).toEqual({
action: 'skip',
reason: 'rate-limited'
})
expect(decideSuggestion(policy({ sinceLastRequestMs: 10 }))).toEqual({
action: 'skip',
reason: 'rate-limited'
})
expect(decideSuggestion(policy({ requestsToday: 999, dailyBudget: 500 }))).toEqual({
action: 'skip',
reason: 'budget-exhausted'
})
})
it('이미 떠 있는 제안은 다시 요청하지 않는다', () => {
expect(decideSuggestion(policy({ overlayVisible: true }))).toEqual({
action: 'skip',
reason: 'already-visible'
})
})
})
describe('표시 중 제안 재생성 정책', () => {
it('연속 접두가 12자 미만 성장하면 기존 제안을 유지한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 내일')).toBe('keep')
})
it('연속 접두가 12자 이상 성장하면 재생성한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '회의 결과를 공유합니다 다음 안건도 검토해 주세요')).toBe(
'regenerate'
)
})
it('생성 접두의 앞부분이 바뀌면 stale 로 처리한다', () => {
expect(decideSuggestionRefresh('회의 결과를 공유합니다', '프로젝트 결과를 공유합니다')).toBe('stale')
})
})
describe('isAppExcluded', () => {
it('대소문자와 .exe 를 무시하고 비교한다', () => {
expect(isAppExcluded('KeePassXC.exe', ['keepassxc.exe'])).toBe(true)
expect(isAppExcluded('KeePassXC', ['keepassxc.exe'])).toBe(true)
expect(isAppExcluded('chrome.exe', ['keepassxc.exe'])).toBe(false)
})
it(' 목록/빈 이름은 제외하지 않는다', () => {
expect(isAppExcluded('chrome.exe', [])).toBe(false)
expect(isAppExcluded('', ['chrome.exe'])).toBe(false)
})
})
describe('후보 정제', () => {
it('번호/따옴표/불릿을 걷어낸다', () => {
expect(sanitizeSuggestionLine('1. 다음 문장입니다.')).toBe('다음 문장입니다.')
expect(sanitizeSuggestionLine('- "quoted line"')).toBe('quoted line')
expect(sanitizeSuggestionLine('* 별표 항목')).toBe('별표 항목')
})
it('지시문을 되풀이한 줄은 버린다 (프롬프트 누출 방어)', () => {
expect(sanitizeSuggestionLine('Suggestion: ...')).toBeNull()
expect(sanitizeSuggestionLine('다음 문장: 이어집니다')).toBeNull()
expect(sanitizeSuggestionLine('{{prefix}}')).toBeNull()
})
it('너무 짧은 줄은 버린다', () => {
expect(sanitizeSuggestionLine('a')).toBeNull()
expect(sanitizeSuggestionLine(' ')).toBeNull()
})
it('길이를 제한한다', () => {
const long = 'word '.repeat(80)
const trimmed = sanitizeSuggestionLine(long, 40)
expect(trimmed).not.toBeNull()
expect((trimmed ?? '').length).toBeLessThanOrEqual(40)
})
it('접두를 되풀이하는 후보는 제외하고 중복도 제거한다', () => {
const prefix = '오늘 회의에서 논의한'
const raw = [
'오늘 회의에서 논의한 내용을 정리합니다.',
'내용을 정리합니다.',
'내용을 정리합니다.',
'결론부터 공유드립니다.'
].join('\n')
const candidates = parseSuggestionCandidates(raw, prefix, 3)
expect(candidates).toEqual(['내용을 정리합니다.', '결론부터 공유드립니다.'])
})
it('후보 개수 상한을 지킨다', () => {
const raw = '첫 번째 후보입니다.\n두 번째 후보입니다.\n세 번째 후보입니다.'
expect(parseSuggestionCandidates(raw, '', 2)).toHaveLength(2)
})
})
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)
expect(position).toEqual({ x: 400, y: 326 })
})
it('아래 공간이 없으면 위로 뒤집는다', () => {
const position = anchorFloatingPanel(
{ x: 400, y: 1000, width: 2, height: 20 },
{ x: 0, y: 0 },
size,
workArea
)
expect(position.y).toBe(1000 - 6 - 96)
})
it('작업영역 밖으로 나가지 않는다', () => {
const position = anchorFloatingPanel(
{ x: 1900, y: 10, width: 2, height: 20 },
{ x: 0, y: 0 },
size,
workArea
)
expect(position.x).toBe(workArea.width - size.width)
expect(position.x + size.width).toBeLessThanOrEqual(workArea.width)
})
it('앵커가 없으면 커서를 쓴다', () => {
const position = anchorFloatingPanel(null, { x: 200, y: 500 }, size, workArea)
expect(position).toEqual({ x: 200, y: 506 })
})
})
describe('마우스 이동', () => {
it('맨해튼 거리 — 축별 절대값 합 (ActivityWatch 방식)', () => {
expect(manhattanDistance({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7)
expect(manhattanDistance({ x: 10, y: 10 }, { x: 10, y: 12 })).toBe(2)
})
it('셀을 미터로 환산한다', () => {
// 96 DPI 에서 1인치 = 96px = 0.0254m
expect(pixelsToMeters(96, 1)).toBeCloseTo(0.0254, 6)
expect(pixelsToMeters(192, 2)).toBeCloseTo(0.0254, 6)
})
})
describe('집계 버킷', () => {
it('빈 버킷은 모든 카운터가 0 이다', () => {
const bucket = emptyActivityBucket()
expect(Object.values(bucket).every((value) => value === 0)).toBe(true)
})
it('부분 delta 를 누적한다', () => {
const bucket = emptyActivityBucket()
mergeActivityBucket(bucket, { keystrokes: 3, clicks: 1, mouseDistancePx: 120 })
mergeActivityBucket(bucket, { keystrokes: 2 })
expect(bucket.keystrokes).toBe(5)
expect(bucket.clicks).toBe(1)
expect(bucket.mouseDistancePx).toBe(120)
expect(bucket.words).toBe(0)
})
it('일 평균을 낸다', () => {
const totals = emptyActivityBucket()
mergeActivityBucket(totals, {
keystrokes: 7000,
clicks: 140,
words: 1400,
mouseDistancePx: 96 * 3
})
const averages = summarizeActivity(totals, 7)
expect(averages.keystrokes).toBe(1000)
expect(averages.clicks).toBe(20)
expect(averages.words).toBe(200)
expect(averages.mouseDistanceMeters).toBeGreaterThan(0)
})
it('0 일 구간에도 0 나눗셈을 하지 않는다', () => {
const averages = summarizeActivity(emptyActivityBucket(), 0)
expect(averages.keystrokes).toBe(0)
expect(averages.mouseDistanceMeters).toBe(0)
})
})
describe('개인 문구', () => {
it('문장 종결 단위로 나누고 짧은 조각은 버린다', () => {
const phrases = extractPhrases('오늘 회의는 짧았습니다. 내일 일정을 공유드릴게요. 짧음.')
expect(phrases).toContain('오늘 회의는 짧았습니다')
expect(phrases).toContain('내일 일정을 공유드릴게요')
expect(phrases).not.toContain('짧음')
})
it('프롬프트 힌트는 사용 빈도 순으로 뽑고 접두 중복을 피한다', () => {
const now = Date.now()
const phrases: PersonalPhrase[] = [
{ id: 'a', phrase: '자주 쓰는 문장입니다', count: 5, source: 'typed', appName: null, lastUsedAt: now, createdAt: now },
{ id: 'b', phrase: '가 쓰는 문장입니다', count: 1, source: 'typed', appName: null, lastUsedAt: now, createdAt: now },
{ id: 'c', phrase: '자주 쓰는 문장입니다', count: 9, source: 'suggestion', lastUsedAt: now, createdAt: now }
]
const hints = selectPhraseHints(phrases, '', 2)
expect(hints[0]).toBe('자주 쓰는 문장입니다')
expect(hints).toHaveLength(2)
})
})
describe('기본값 (실측 근거)', () => {
it('자동 제안의 요청 상한은 보수적 기본 정책을 지킨다', () => {
expect(SUGGESTION_DEFAULTS.triggerDelayMs).toBe(600)
expect(SUGGESTION_DEFAULTS.minIntervalMs).toBe(5000)
expect(SUGGESTION_DEFAULTS.maxRequestsPerMinute).toBe(6)
expect(SUGGESTION_DEFAULTS.dailyBudget).toBe(500)
expect(SUGGESTION_DEFAULTS.maxCandidates).toBe(3)
expect(SUGGESTION_DEFAULTS.maxOutputTokens).toBe(64)
expect(SUGGESTION_DEFAULTS.regenerateAfterChars).toBe(12)
})
it('prefixTail 은 공백을 정규화하고 마지막 n자를 취한다', () => {
expect(prefixTail('오늘 회의에서 논의한 내용')).toBe('오늘 회의에서 논의한 내용')
expect(prefixTail('12345678901234567890')).toBe('78901234567890')
expect(prefixTail('짧음')).toBe('짧음')
expect(prefixTail('가나다라마바사아자차카타파하', 4)).toBe('카타파하')
})
it('flush 주기는 ActivityWatch heartbeat(5초)와 같다', () => {
expect(INPUT_TELEMETRY_DEFAULTS.flushIntervalMs).toBe(5000)
})
it('응답 제한은 콜드 로딩(실측 24.7초)을 오래 붙잡지 않는다', () => {
// gemma4:e4b 실측: 콜드 24.7초 / 워밍업 후 4.9초(32토큰). 상한이 없으면 그 시간 동안 붙잡힌다.
expect(SUGGESTION_DEFAULTS.requestTimeoutMs).toBeGreaterThanOrEqual(5000)
expect(SUGGESTION_DEFAULTS.requestTimeoutMs).toBeLessThanOrEqual(30000)
})
})

View file

@ -20,8 +20,10 @@ import {
renderInstructionPrompt,
buildInstructionInvocation,
resolveSystemPrompt,
buildSuggestionPrompt,
DEFAULT_TARGET_LANGUAGE,
BASE_SYSTEM_PROMPTS,
SUGGESTION_NO_THINK_PREFIX,
} from '../../../src/main/services/llm-prompts'
beforeEach(() => {
@ -167,3 +169,47 @@ describe('resolveSystemPrompt', () => {
}
})
})
describe('buildSuggestionPrompt', () => {
it('지시문은 시스템 프롬프트에만 있고 사용자 텍스트에는 들어가지 않는다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: '오늘 회의에서 논의한 내용을 정리해서',
appName: 'chrome.exe',
windowTitle: '회의록 - Chrome',
phraseHints: ['지난번에는 이렇게 정리했습니다'],
candidates: 3,
maxChars: 120
})
// 지시문이 사용자 텍스트 자리로 새면 모델이 지시문 자체를 다듬어 돌려준다
// (commit 9c2b4d4 회와 같은 부류).
expect(systemPrompt).toContain('다음 문장')
expect(text).not.toContain('규칙:')
expect(text).toContain('오늘 회의에서 논의한 내용을 정리해서')
expect(text).toContain('지난번에는 이렇게 정리했습니다')
expect(text).toContain('chrome.exe')
})
it('후보 수와 길이 상한을 프롬프트에 반영한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({ prefix: 'abc', candidates: 2, maxChars: 60 })
expect(systemPrompt).toContain('60')
expect(text).toContain('2')
})
it('추론 모델용 no_think prefix 를 붙인다', () => {
const { systemPrompt } = buildSuggestionPrompt({ prefix: 'abc' })
expect(systemPrompt.startsWith(SUGGESTION_NO_THINK_PREFIX)).toBe(true)
})
it('후보 수/길이는 안전 범위로 클램프한다', () => {
const { systemPrompt, text } = buildSuggestionPrompt({
prefix: 'abc',
candidates: 99,
maxChars: 99999
})
expect(systemPrompt).toContain('400')
expect(text).toContain('5')
})
})

View file

@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getLocalLLMService,
resetLocalLLMServiceForTests,
} from '../../../src/main/services/LocalLLMService'
const encoder = new TextEncoder()
function completedStream(lines: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(lines.join('\n')))
controller.close()
},
})
}
function completedGenerateResponse(): Response {
return new Response(JSON.stringify({
model: 'gemma4:e4b',
response: 'ok',
done: true,
}), { status: 200 })
}
function markAvailable(): void {
const service = getLocalLLMService() as unknown as { _available: boolean }
service._available = true
}
describe('LocalLLMService request lifecycle', () => {
beforeEach(() => {
initInMemoryConfig()
resetLocalLLMServiceForTests()
markAvailable()
})
afterEach(() => {
resetLocalLLMServiceForTests()
resetInMemoryConfig()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('sends the bounded default num_predict to chat requests', async () => {
const cancel = vi.fn()
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { options: { num_predict: number } }
expect(body.options.num_predict).toBe(512)
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ message: { content: 'hello' }, done: false }),
JSON.stringify({ message: { content: '' }, done: true }),
].join('\n') + '\n'))
},
cancel,
}), { status: 200 })
})
vi.stubGlobal('fetch', fetchMock)
const output: string[] = []
for await (const token of getLocalLLMService().chatStream([{ role: 'user', content: 'hello' }])) {
output.push(token)
}
expect(output).toEqual(['hello'])
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(cancel).toHaveBeenCalledTimes(1)
})
it('propagates external cancellation and request deadlines to fetch', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
}))
vi.stubGlobal('fetch', fetchMock)
const external = new AbortController()
const externalRequest = getLocalLLMService().generate('first', { signal: external.signal })
const externalExpectation = expect(externalRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
external.abort()
await externalExpectation
expect(signals[0].aborted).toBe(true)
vi.useFakeTimers()
const timeoutRequest = getLocalLLMService().generate('second', { timeoutMs: 25 })
const timeoutExpectation = expect(timeoutRequest).rejects.toMatchObject({ code: ErrorCode.LLMProcessingTimeout })
await vi.advanceTimersByTimeAsync(25)
await timeoutExpectation
expect(signals[1].aborted).toBe(true)
})
it('keeps other active requests cancellable after one request completes', async () => {
const signals: AbortSignal[] = []
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal as AbortSignal
signals.push(signal)
if (signals.length === 1) return Promise.resolve(completedGenerateResponse())
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
})
})
vi.stubGlobal('fetch', fetchMock)
const first = getLocalLLMService().generate('first')
const second = getLocalLLMService().generate('second')
const third = getLocalLLMService().generate('third')
await expect(first).resolves.toMatchObject({ text: 'ok' })
const secondExpectation = expect(second).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
const thirdExpectation = expect(third).rejects.toMatchObject({ code: ErrorCode.LLMProcessingCancelled })
getLocalLLMService().cancelGeneration()
await secondExpectation
await thirdExpectation
expect(signals[1].aborted).toBe(true)
expect(signals[2].aborted).toBe(true)
})
it('rejects a stream that reaches EOF without a done frame', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(completedStream([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
]), { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello')
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).rejects.toMatchObject({ code: ErrorCode.LLMProcessingFailed })
})
it('finishes on a done frame without waiting for the server to close the stream', async () => {
const cancel = vi.fn()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
JSON.stringify({ model: 'gemma4:e4b', response: 'partial', done: false }),
JSON.stringify({ model: 'gemma4:e4b', response: '', done: true }),
].join('\n') + '\n'))
},
cancel,
})
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 200 })))
const stream = getLocalLLMService().streamGenerate('hello', { timeoutMs: 500 })
await expect(stream.next()).resolves.toMatchObject({ value: 'partial', done: false })
await expect(stream.next()).resolves.toMatchObject({ value: expect.objectContaining({ text: 'partial' }), done: true })
expect(cancel).toHaveBeenCalledTimes(1)
})
it('deduplicates concurrent ensureRunning calls and polling startup', async () => {
const service = getLocalLLMService()
const privateService = service as unknown as {
_ensureRunning: () => Promise<'running' | 'starting' | 'not-installed' | 'failed'>
_checkAvailability: () => Promise<void>
}
let release: ((value: 'running') => void) | undefined
const pending = new Promise<'running'>((resolve) => {
release = resolve
})
const ensure = vi.spyOn(privateService, '_ensureRunning').mockReturnValue(pending)
const first = service.ensureRunning()
const second = service.ensureRunning()
expect(ensure).toHaveBeenCalledTimes(1)
release?.('running')
await expect(Promise.all([first, second])).resolves.toEqual(['running', 'running'])
vi.useFakeTimers()
const availability = vi.spyOn(privateService, '_checkAvailability').mockResolvedValue()
service.startPolling()
service.startPolling()
expect(availability).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(5000)
expect(availability).toHaveBeenCalledTimes(2)
service.stopPolling()
})
})

View file

@ -0,0 +1,164 @@
import { EventEmitter } from 'events'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { promisify } from 'util'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const childProcess = vi.hoisted(() => ({
exec: vi.fn(),
execFile: vi.fn(),
execFileAsync: vi.fn(),
spawn: vi.fn(),
}))
vi.mock('child_process', () => childProcess)
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn(),
configSet: vi.fn(),
}))
vi.mock('../../../src/main/services/stt/STTManager', () => ({
getSTTManager: vi.fn(),
}))
vi.mock('../../../src/main/services/HistoryService', () => ({
getHistoryService: vi.fn(),
}))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({
getRuntimeProvisioner: vi.fn(),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: vi.fn(),
}))
function createProcess(args: unknown[]): EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
kill: ReturnType<typeof vi.fn>
} {
const process = Object.assign(new EventEmitter(), {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
kill: vi.fn(),
})
queueMicrotask(() => {
if (args.includes('null')) {
process.stderr.emit('data', Buffer.from('Duration: 00:00:01.00'))
}
if (args.includes('pipe:1')) {
process.stdout.emit('data', Buffer.from('pcm'))
}
process.emit('close', 0)
})
return process
}
function mockSuccessfulExec(): void {
childProcess.exec.mockImplementation((...args: unknown[]) => {
const callback = args.find((arg): arg is (error: Error | null, stdout: string) => void => typeof arg === 'function')
callback?.(null, '[{"InstanceId":"device-1","FriendlyName":"Mic"}]')
})
}
function expectWindowsHideOnAllCalls(mock: { mock: { calls: unknown[][] } }): void {
for (const call of mock.mock.calls) {
const options = call.find(
(arg): arg is { windowsHide?: boolean } => typeof arg === 'object' && arg !== null && !Array.isArray(arg),
)
expect(options?.windowsHide).toBe(true)
}
}
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
mockSuccessfulExec()
childProcess.spawn.mockImplementation((_command: unknown, args: unknown[]) => createProcess(args))
Reflect.set(
childProcess.execFile,
promisify.custom,
childProcess.execFileAsync,
)
childProcess.execFileAsync.mockResolvedValue({ stdout: 'TestApp\nTest window', stderr: '' })
})
describe('Windows child process visibility', () => {
it('hides the PowerShell SAPI process used for TTS playback', async () => {
const serviceModule = await import('../../../src/main/services/TTSPlaybackService')
serviceModule.resetTTSPlaybackServiceForTests()
const service = serviceModule.getTTSPlaybackService() as unknown as {
_speakOneWindows(text: string): Promise<void>
}
await service._speakOneWindows('테스트')
expect(childProcess.spawn).toHaveBeenCalledTimes(1)
expect(childProcess.spawn.mock.calls[0]?.[0]).toBe('powershell')
expect(childProcess.spawn.mock.calls[0]?.[2]).toMatchObject({ stdio: 'pipe', windowsHide: true })
})
it('hides cmd and PowerShell commands invoked by voice actions', async () => {
const serviceModule = await import('../../../src/main/services/VoiceActionService')
serviceModule.resetVoiceActionServiceForTests()
const service = serviceModule.getVoiceActionService() as unknown as {
_openApp(appName: string): Promise<void>
_simulateKeyboard(combo: string): Promise<void>
_runCommand(command: string): Promise<void>
}
await service._openApp('notepad')
await service._simulateKeyboard('volumeup')
await service._simulateKeyboard('volumedown')
await service._simulateKeyboard('volumemute')
await service._simulateKeyboard('ctrl+c')
await service._runCommand('echo test')
expect(childProcess.exec).toHaveBeenCalledTimes(6)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({ shell: 'cmd.exe', windowsHide: true })
expect(childProcess.exec.mock.calls[5]?.[1]).toMatchObject({ timeout: 10000, windowsHide: true })
expectWindowsHideOnAllCalls(childProcess.exec)
})
it('hides Windows device discovery and active-window PowerShell calls', async () => {
const audioModule = await import('../../../src/main/services/AudioCaptureService')
const audioService = audioModule.getAudioCaptureService() as unknown as {
_getDevicesWindows(): Promise<unknown>
}
await audioService._getDevicesWindows()
const contextModule = await import('../../../src/main/services/ScreenContextService')
const contextService = contextModule.getScreenContextService()
await contextService.captureContext(false)
expect(childProcess.exec.mock.calls[0]?.[1]).toMatchObject({
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
})
expect(childProcess.execFileAsync.mock.calls[0]?.[2]).toMatchObject({
timeout: 3000,
windowsHide: true,
})
})
it('declares hidden ffmpeg processes for conversion, duration, and chunk extraction', () => {
const source = readFileSync(
resolve(process.cwd(), 'src/main/services/FileTranscriptionService.ts'),
'utf8',
)
const ffmpegSpawns = source.match(
/spawn\(ffmpegPath, args, \{ stdio: \['pipe', 'pipe', 'pipe'\], windowsHide: true \}\)/g,
)
expect(ffmpegSpawns).toHaveLength(3)
})
})

View file

@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.4.0"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1041000
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.5.0"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1050000
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 = 1041000;
CURRENT_PROJECT_VERSION = 1050000;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -265,7 +265,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -287,14 +287,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1041000;
CURRENT_PROJECT_VERSION = 1050000;
INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.0;
MARKETING_VERSION = 1.5.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View file

@ -1,12 +1,12 @@
{
"name": "@d3ro/mobile-rn",
"version": "1.4.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@d3ro/mobile-rn",
"version": "1.4.0",
"version": "1.5.0",
"dependencies": {
"@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core",
@ -62,7 +62,7 @@
},
"../..": {
"name": "d3ro-voice-monorepo",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"workspaces": [
"apps/desktop",
@ -81,7 +81,7 @@
},
"../../packages/api-client": {
"name": "@d3ro/api-client",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -98,7 +98,7 @@
},
"../../packages/core": {
"name": "@d3ro/core",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@ -109,7 +109,7 @@
},
"../../packages/i18n": {
"name": "@d3ro/i18n",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@ -120,7 +120,7 @@
},
"../../packages/ui-native": {
"name": "@d3ro/ui-native",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"devDependencies": {
"@types/react": "*"

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/web",
"version": "1.4.0",
"version": "1.5.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.4.0
v1.5.0
</Box>
</Box>

View file

@ -4,10 +4,10 @@
// 설치 파일은 canonical Forgejo feed에서만 배포한다. 웹/사이트 빌드 산출물에는
// 설치 바이너리가 포함되지 않으므로 `/releases/...` 로컬 경로는 배포 환경에서 404다.
export const DESKTOP_VERSION = '1.4.0'
export const DESKTOP_VERSION = '1.5.0'
/** 릴리스 게시일. `release/product-version.json`의 releaseDate와 같아야 한다. */
export const DESKTOP_RELEASE_DATE = '2026-09-21'
export const DESKTOP_RELEASE_DATE = '2026-09-23'
const FORGEJO_ORIGIN = 'https://git.chanpaca.net'
const FORGEJO_OWNER = 'yunchan'

View file

@ -130,6 +130,12 @@
- 한글 문자열에 `letterSpacing` 양수 값을 직접 넣지 않는다.
- 11px 미만 텍스트를 추가하지 않는다 (라틴 텔레메트리 예외 하한 9px).
## 2026-09-22 Local Flow Intelligence extension
- 기존 **swiss-minimal × dark-instrument** 방향과 token/typography/a11y 규칙을 그대로 적용한다. Flow Radar·Edit Friction·App Quality는 색만으로 상태를 구분하지 않고 텍스트 라벨과 수치를 함께 보인다.
- Privacy Receipt은 `dl`의 정의/값 구조로 보존 기간과 실제 row count를 짝지어 읽게 한다. raw key 이벤트/키코드/콘텐츠 스트림 미저장과, 옵트인 학습 텍스트의 별도 보존 정책을 혼동시키지 않는다. `personal_phrases`는 나이 기반 자동 만료가 없고 개별 삭제·전체 삭제·동의 철회로 제거됨을 정확히 보인다.
- suggestion overlay는 좁은 폭을 유지한다. Why This Suggestion은 한 줄의 provenance와 count만 보이며 raw memory evidence를 노출하지 않는다.
## 2026-08-24 광고 수익화 E2E RED 스위트 (652 시나리오)
- 위치: `apps/desktop/tests/red/ads/` (ads-mediation 370 / admob-ssv 142 / ad-settlement 140)

View file

@ -2,8 +2,12 @@
> Status: ACTIVE
> Last full audit: 2026-09-13
> Last update: 2026-09-21 — 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; `11` §7 holds accepted design constraints (things deliberately kept, not gaps)
> Scope: entire monorepo `D:/workspace/D3ROVoice` at product version `1.4.0` (`release/product-version.json`, released 2026-09-21)
> 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)를 유지한다.
>
> 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)
> 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

@ -60,6 +60,8 @@ Note: root `package.json` declares npm workspaces `apps/desktop`, `apps/web`, `a
| Deno | 2.8.1 | CI (`edge-functions-quality`) |
| JDK | 17 | mobile CI |
| Electron | 33.4.11 | `apps/desktop/electron-builder.yml` |
| koffi | 3.3.1 | `apps/desktop/package.json` — FFI into user32/kernel32 for foreground window title/pid/exe/bounds (`utils/win32-foreground.ts`). Chosen over `get-windows` because that package needs an install script this repo does not run; koffi ships N-API 8 prebuilds as optional deps. `asarUnpack` covers `koffi`/`@koromix`. |
| uiautomation / comtypes | 2.0.29 / 1.4.17 | `apps/desktop/sidecar/requirements.txt` (Windows only) — UI Automation snapshot behind `GET /uia/focus` (`sidecar/uia_bridge.py`). Dev install: `apps/desktop/sidecar/.venv/Scripts/python.exe -m pip install uiautomation comtypes`. |
| React Native | 0.85 | `apps/mobile-rn/package.json` |
| Turborepo | turbo.json tasks: build/typecheck/test/lint/dev | `turbo.json` |
@ -192,7 +194,7 @@ Full detail: [`09-supabase-backend.md`](./09-supabase-backend.md).
| File | Purpose |
|---|---|
| `release/product-version.json` | version `1.4.0`, `androidVersionCode`/`iosBuildNumber` `1041000`, releaseDate `2026-09-21`, desktop license keyId |
| `release/product-version.json` | version `1.5.0`, `androidVersionCode`/`iosBuildNumber` `1050000`, releaseDate `2026-09-23`, 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

@ -15,8 +15,9 @@ The canonical place for types and cross-surface logic. Both desktop and web/mobi
|---|---|---|
| Types | `./types` | Domain types shared across surfaces |
| Errors | `./errors` | `D3ROError`, `ErrorCode` |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT) |
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (6 rebindable actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
| IPC channels | `./ipc-channels` | `IPC_CHANNELS` object + `IPCChannel` union. **SSOT** for every desktop IPC channel (VOICE, AUDIO, STT, TTS, LLM, KEYBINDING, CONFIG, HISTORY, DICTIONARY, WINDOW, SYSTEM, STATS, MEMO, VOICE_COMMAND, CONTEXT, CHAIN, CAPTION, FILE_TRANSCRIPTION, MEETING_SUMMARY, DICTATION_TEMPLATE, VOICE_CONVERSATION, RAG, VOICE_ACTION, MEETING_MODE, MEETING_DOC_TEMPLATE, MEETING_CHAT, LICENSE, CLOUD_SYNC, INSTRUCTION, SYSTEM_AUDIO, POPUP_RESULT, POPUP_HISTORY, POPUP_COMMAND, POPUP_CAPTION, VOICE_PARTIAL, CLIPBOARD, APP, ONLINE_AUTH, ADS, SUPPORT, PAYMENT, INPUT_TELEMETRY, SUGGESTION, POPUP_SUGGESTION) |
| Key bindings | `./keybinding` | **SSOT** for every global shortcut in the app: `KeyBinding` (`device`/`code`/`ctrl`/`alt`/`shift`/`meta`), `KEY_CATALOG` (10 selectable groups incl. mouse), `KEYBINDING_ACTIONS` (9 rebindable actions incl. the three suggestion actions), `bindingKey`/`normalizeBinding`/`validateBinding`/`detectBindingConflicts`/`formatBindingSegments`/`searchKeyCatalog`/`parseBindingMap`. Persisted as `AppConfig.keyBindings`. i18n keys are exposed as plain `string` so core stays independent of `@d3ro/i18n`; consumers narrow at the boundary (`asTranslationKey`) and a contract test guards the keys — accepted constraint, `11` §7 CONSTRAINT-I18N-01. Tests: `__tests__/keybinding*.test.ts` via `vitest.config.ts` (`npm run test --workspace=@d3ro/core`), 117 cases as of 2026-09-21 |
| Input intelligence | `./input-intelligence` | **SSOT** for the input-telemetry and next-sentence-suggestion domain (added 2026-09-21): key classification (`classifyKeyStroke`), text metrics (`countWords`/`countSentences`/`endsSentence`/`textBeforeCaret`), `computeTypedDelta` (UIA snapshot diff — the IME-safe way to count typed text), `decideSuggestion` + `isAppExcluded` (when to request / skip / clear), `parseSuggestionCandidates`/`sanitizeSuggestionLine` (prompt-leak and prefix-echo defence), `anchorFloatingPanel` (caret-anchored overlay placement), `mergeActivityBucket`/`summarizeActivity`/`pixelsToMeters`, `extractPhrases`/`selectPhraseHints`, `INPUT_TELEMETRY_DEFAULTS`/`SUGGESTION_DEFAULTS` |
| Constants | `./constants` | Shared constants |
| Crypto license | `./utils/crypto-license` | Ed25519 license sign/verify (used by admin issuer + desktop verifier) |
| PII | `pii-redactor`, `secure-memory` | Redaction + secure memory helpers |

View file

@ -45,7 +45,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
### LLM layer
| Service | Purpose |
|---|---|
| `LocalLLMService` | Ollama REST (models, pull w/ progress, server start, NDJSON streaming) |
| `LocalLLMService` | Ollama REST (models, pull w/ progress, server start, NDJSON streaming). The 2026-09-22 runaway guard gives each request its own `AbortController` (external caller signals are relayed and detached on completion), requires `done` before an NDJSON stream succeeds, and clears incomplete streams. Generation/stream requests are bounded to 2048 tokens / 120 s; chat is bounded to 512 / 60 s. Ollama server spawn and polling are deduplicated, and lifecycle dispose terminates owned work. |
| `PremiumLLMService` | Claude via Supabase `llm-proxy`, local fallback |
| `OnlineLLMService` | JWT-authenticated .NET backend client |
| `llm-prompts.ts` | **SSOT for prompt resolution, placeholder substitution, and argument placement.** `resolveSystemPrompt` (`:118`) maps an `LLMAction` to its base prompt and handles `custom` explicitly instead of dropping silently to `refine`. `renderInstructionPrompt` (`:78`) substitutes `{{text}}` / `{{userPrompt}}` / `{{targetLanguage}}` and **warns by name** for any placeholder left standing rather than letting it reach the model. `buildInstructionInvocation` (`:101`) decides where an instruction goes in `processText(text, action, targetLanguage, customPrompt)`: the instruction becomes the **system prompt** and the transcript the **text**, except for instructions that spell out `{{text}}`, which keep the old meaning for backward compatibility. `resolveTargetLanguage` (`:50`) is the one place translate targets are decided (still `English`, see `11` GAP-LLM-01). **All three LLM entry paths call the same functions** — `VoiceModeService` (`:838`), `ChainService` (`:196`), and the `LLM.PROCESS` IPC handler (`llm-handlers.ts:96`) — so no caller re-implements the rules |
@ -69,7 +69,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `FileTranscriptionService` | Audio/video file → ffmpeg → 30s chunks → STT merge (events progress, complete, error, state-changed) |
| `MeetingSummaryService` | Post-caption LLM summary |
| `DictationTemplateService` | Field-by-field voice form filling |
| `VoiceConversationService` | STT→LLM→TTS loop, 10-turn memory |
| `VoiceConversationService` | STT→LLM→TTS loop, 10-turn memory. Local conversations are single-flight and pass their request signal through to local LLM chat, so a voice cancel aborts its own active request rather than only changing UI/watchdog state. |
| `TTSPlaybackService` | Platform TTS (macOS `say`, Windows SAPI), sentence queue |
| `VoiceActionService` | Voice → LLM JSON action plan → OS execution (dangerous blocked) |
@ -79,6 +79,18 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
| `MeetingModeService` | Meeting recording: live transcript, timestamp memos, doc generation/export, diarization |
| `MeetingDocTemplateService` | Meeting-doc templates (built-ins + CRUD) |
### Input intelligence (2026-09-21; Local Flow Intelligence extension 2026-09-22)
| Service / file | Purpose |
|---|---|
| `InputTelemetryService` | Global input telemetry: keystroke/click/scroll counters, mouse travel (Manhattan sum of per-axis deltas), active time, per-hour-per-app buckets flushed every 5 s; typed-text learning via UIA snapshot diffs. It extends the existing telemetry store with Flow Radar (hourly activity density + chars + edit stability; a potential-focus score, **not** a real session), Edit Friction (chars/backspaces; edits per 100 chars), App Quality (per-app suggestion total/accepted/accept rate/average latency), Privacy Receipt (actual row counts and retention), and Smart Exclusion evidence. Raw key events, key codes and content streams are never retained; separately, opt-in learned text may be retained in `typing_samples` and `personal_phrases` under the stated receipt policy. Consent is opt-in (`inputTelemetryEnabled`, `inputLearnTypedText`). |
| `UiaContextService` | Client for the sidecar UIA bridge (`GET /uia/focus`): focused text, caret rect/offset, `IsPassword`, IME composition state and `hasSelection`. The sidecar derives `hasSelection` only by comparing the TextPattern selection range Start/End endpoints: it does not call `GetText` on that range or add a separate selected-text payload (the existing focused-field text can still include a selection). Fail-closed on password/unavailable; transient failures back off for 1.5/3/6/12/30 s and permanent failures for 5 min. |
| `SuggestionService` | Next-sentence ghost text: 600 ms debounce, then `buildSuggestionPrompt` → `LocalLLMService.streamGenerate` with a caller-owned `AbortSignal` → candidate ranking and accept (via `TextInsertService`). Presentation-active includes candidates plus `generating`, `warmingUp` and `partialText`; clear/dismiss aborts the active request, invalidates its generation token, clears TTL state, then emits `cleared`/hide so a spinner-only popup closes. A successful final candidate payload resets `generating=false` and `partialText=null`. App DNA extends phrase ranking with `appName` context and a 1.75× same-app bonus; Memory Decay uses a 30-day half-life plus frequency. Why This Suggestion exposes only `local-model`/`local-memory` provenance and continuation/related/phrase/appPhrase **counts** to the overlay, never raw evidence text. Instant Recall uses local-memory provenance without another model-budget spend only when the model is unavailable, raises a non-cancellation error, truly times out, or returns empty; it never publishes after dismiss, new typing, token/context mismatch or staleness. Its runaway limits are a 5 s minimum interval, 6 requests/min default (hard-config maximum 12), 3 candidates, 64 output tokens, 12-character regeneration growth and an 8 s timeout; app startup does not warm the model and suggestion requests use `keep_alive: 2m`. |
| `global-input-hook.ts` | Ref-counted owner of the single process-wide `uiohook` hook so `KeyBindingService` and telemetry can both attach without one stopping the other. |
| `utils/win32-foreground.ts` | Foreground window title/pid/exe/bounds via `koffi` FFI into user32/kernel32 (chosen over `get-windows`, which needs an install script this repo does not run). |
| `sidecar/uia_bridge.py` | Windows UIA snapshot (`uiautomation` 2.0.29, comtypes) on a dedicated COM-initialised thread with a 1.5 s budget; never walks the UIA tree (Chrome/VS Code tree walks take 10-30 s). Warm calls measure 0-15 ms. |
The existing `input-telemetry-handlers` and `suggestion-handlers` IPC extensions expose the receipt and the flow/suggestion summaries. Receipt reads or deletes report an IPC error when storage access fails; they do not return invented counts or claim a purge succeeded. The receipt is local-only: `input_activity`, `typing_samples`, and `suggestions` retain 30 days; `personal_phrases` has no age-based automatic expiry and is removed by individual deletion, delete-all, or consent withdrawal. Smart Exclusion never records password evidence and recommends only the current app after at least four observations, zero readable results, and a problematic ratio of at least 75%; its one-click action adds an exclusion and never auto-excludes. Shortcut Safety Audit is core `auditKeyBindingMap` applied by the Settings UI; it surfaces invalid/conflict issues while preserving the existing hold/double-press exceptions.
### Account / infra / monetization
| Service | Purpose |
|---|---|
@ -104,7 +116,7 @@ Singleton + `EventEmitter` pattern (`getXService()` accessors).
## 3. IPC layer
Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order. Channel SSOT: `packages/core/src/ipc-channels.ts`.
Registry: `src/main/ipc/index.ts` calls 31 `registerXHandlers()` in fixed order. Channel SSOT: `packages/core/src/ipc-channels.ts`.
| Handler | Channel group(s) |
|---|---|
@ -118,6 +130,7 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `dictionary-handlers` | DICTIONARY |
| `file-transcription-handlers` | FILE_TRANSCRIPTION |
| `history-handlers` | HISTORY + `stats:getSummary` |
| `input-telemetry-handlers` | INPUT_TELEMETRY |
| `instruction-handlers` | INSTRUCTION |
| `keybinding-handlers` | KEYBINDING |
| `license-handlers` | LICENSE |
@ -129,6 +142,7 @@ Registry: `src/main/ipc/index.ts` calls 29 `registerXHandlers()` in fixed order.
| `payment-handlers` | PAYMENT |
| `rag-handlers` | RAG |
| `stt-handlers` | STT |
| `suggestion-handlers` | SUGGESTION + POPUP_SUGGESTION |
| `support-handlers` | SUPPORT |
| `system-handlers` | SYSTEM |
| `template-handlers` | DICTATION_TEMPLATE |
@ -142,19 +156,20 @@ The **`KEYBINDING`** group replaced the old per-action `HOTKEY` group. `HOTKEY`
**`LLM.PROCESS` normalizes at the IPC boundary.** The handler runs `buildInstructionInvocation` itself when `action === 'custom'` with a `customPrompt` (`llm-handlers.ts:94-108`), so the renderer passes the **raw instruction text** and never duplicates the substitution or argument-placement rules. This is what makes `VoiceModeService`, `ChainService`, and `LLM.PROCESS` literally share one implementation. No channel or type changed for this; `LLMProcessParams` is unchanged.
Preload exposes **`window.electronAPI`** with 33 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
Preload exposes **`window.electronAPI`** with 35 namespaces: `platform, audio, config, voice, stt, keybinding, llm (incl. premium), history, dictionary, stats, window, system, instruction, app, memo, voiceCommand, context, chain, caption, license, fileTranscription, meetingSummary, dictationTemplate, rag, voiceAction, voiceConversation, meetingMode, meetingChat, meetingDocTemplate, cloudSync, onlineAuth, ads, support, payment, inputTelemetry, suggestion`. The `keybinding` bridge is 9 methods mirroring the channels above (`src/preload/index.ts:323`), replacing the 11-method `hotkey` bridge. Envelope: `IPCResult<T>` (success/error); `app.onDataChanged` is the global refresh channel.
---
## 4. Windows & popups
`windows/WindowManager.ts` creates 6 windows: main (borderless, custom TitleBar; macOS `hiddenInset`), recording-tip, result-popup, history-popup, command-popup, caption-overlay. Injects popup theme CSS + i18n strings; 2-phase resize. `windows/TrayManager.ts` — tray icon + menu + double-click show.
`windows/WindowManager.ts` creates 7 windows: main (borderless, custom TitleBar; macOS `hiddenInset`), recording-tip, result-popup, history-popup, command-popup, caption-overlay, suggestion-overlay. Injects popup theme CSS + i18n strings; 2-phase resize. `windows/TrayManager.ts` — tray icon + menu + double-click show.
**Popup invariants** (each shipped broken once — do not regress):
- 팝업 HTML의 스크립트는 반드시 `<script type="module">`로 선언한다. Vite는 모듈 스크립트만 번들에 포함하므로 classic `<script src="./script.js">`는 dev에서만 로드되고 패키징 산출물에서는 파일 자체가 사라진다(오버레이가 정적 HTML로 멈춘 원인). `scripts/ci/verify-desktop-renderer-bundles.mjs`가 빌드 HTML이 참조하는 모든 로컬 asset의 존재를 검사한다.
- 렌더러 로드 전의 `webContents.send`는 조용히 버려진다. 팝업 전송은 `sendToPopupWindow`를 쓰고, 이 함수가 `did-finish-load`까지 메시지를 보관했다가 전달한다. `attachPopupLifecycle`이 로드 상태 추적·테마 주입·팝업 렌더러 진단 로그를 한 곳에서 묶는다.
- 팝업 표시는 `presentPopup`으로 통일한다(`showInactive` + topmost 재선언 + `moveTop` + `webContents.invalidate`). 한 번 `hide()`된 팝업이 두 번째 표시에서 z-order/repaint를 잃어 보이지 않던 문제를 막는다.
- `suggestion-overlay`는 비축소 UIA 선택이 생기면 `selection-active`로 즉시 clear한다. 실제 표시 중일 때만 5초 이후에도 UIA를 주기 검증하고 mouse-up 뒤 120 ms에 다시 검증한다. 입력 focus가 사라져 `available`/`editable`이 false가 되거나 선택이 생기면 요청을 abort하고 hide한다. X는 renderer panel을 먼저 즉시 숨긴 뒤 main IPC가 `BrowserWindow.hide()`를 직접 호출하고 service dismiss를 수행한다. 늦은 결과는 generation token으로 재표시할 수 없다.
Vanilla popups (`src/renderer/popups/`):
| Popup | Purpose |
@ -164,6 +179,7 @@ Vanilla popups (`src/renderer/popups/`):
| `history-popup` | Recent transcriptions; ↑↓/Enter/1-9/ESC. Opened by the `history-popup` action (default `Ctrl+Shift+V`, rebindable) |
| `command-popup` | Command selection. Opened by the `command-popup` action (default `Ctrl+Shift+C`, rebindable) |
| `caption-overlay` | Live caption overlay (font/opacity/maxLines) |
| `suggestion-overlay` | Next-sentence ghost text: caret-anchored (`anchorFloatingPanel`), non-focusable, click-through unless `suggestionOverlayInteractive`; accept/next/dismiss come from global key bindings (the window never owns focus). Its narrow layout shows a one-line local provenance/count summary only, never raw memory evidence. Presentation includes candidate, generating, warm-up and partial-text states; its X hides the panel before main-process dismissal. |
---
@ -181,21 +197,24 @@ Routing is state-based in `AppLayout.tsx` (`Route` union + `NAV_ITEMS`), no reac
| `KnowledgeBasePage` | knowledge | Local RAG: add/index docs, semantic query, reindex/remove |
| `MeetingModePage` | meeting | Meeting studio: live transcript, memos, doc generation/export, diarization |
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Modals/components: `SettingsModal` (tabs General/Audio/STT/LLM/Input/License/Cloud/About), `LicenseModal`, `LicenseTab`, `CloudSyncSection`, `InputInsightsPanel` (consent + suggestion policy + weekly insights + learned phrases), `InputConsentPanel` (receipt + current-app exclusion recommendation), `InputInsightsView` (flow, friction and app-quality summaries), `OnboardingModal`, `UpgradePromptModal`, `ProBadge`, `TemplateSection`, `FileDropZone`, `OllamaGuideModal`, `CodexOAuthGuideModal`, `TitleBar`, `StatusBar`, meeting components (9), voice-conversation, payment (`CheckoutModal`, `checkout-flow.ts`), support (`SupportModal`), ads (`AdBanner`, `RewardedQuotaModal`), shared cards.
Key-binding UI lives in `components/keybinding/` (`Keycap`, `KeyBindingPicker`, `KeyBindingField`, `translation-key`), embedded in the Settings **General** tab (`SettingsModal.tsx:239`) — one field per action plus a global on/off switch. The picker offers both key recording and a searchable grouped dropdown (MUI `Autocomplete` over `KEY_CATALOG`, `KeyBindingPicker.tsx:536`). It replaced `HotkeyRecordModal`. `renderer/utils/format-hotkey.ts` is now a 17-line platform adapter only; key names, modifier glyphs, and join rules come from `@d3ro/core/keybinding`.
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`, `useKeyBindingMap` (subscribes to `keybinding:changed`; the dashboard renders the live `dictation` binding through `BindingKeycaps`).
Hooks: `useRealtimeConversation` (OpenAI Realtime WebRTC), `useLicenseState`, `useProFeature`, `useKeyBindingMap` (subscribes to `keybinding:changed`; the dashboard renders the live `dictation` binding through `BindingKeycaps`), `useInputInsights` (telemetry + suggestion state + weekly summary + phrases; the Dashboard shows a weekly input card when collection is on).
DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `stats`, `memo_tags`, `daily_usage`, `rag_documents`, `rag_chunks`, `meeting_sessions`, `meeting_memos`, `meeting_documents`.
DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `stats`, `memo_tags`, `daily_usage`, `rag_documents`, `rag_chunks`, `meeting_sessions`, `meeting_memos`, `meeting_documents`, `input_activity` (hour × app counters), `typing_samples`, `personal_phrases`, `suggestions`.
---
## 6. Desktop status summary
- Core dictation/LLM/history pipeline: **implemented + tested**. The vitest case count in `apps/desktop` is **1360** after the 2026-09-21 LLM fix added 46 cases; playwright e2e is separate. Read the pass numbers together with the `better-sqlite3` ABI the tree is built for (`11` GAP-INFRA-06) — they are not comparable across configurations:
- Core dictation/LLM/history pipeline: **implemented + tested**. The vitest case count in `apps/desktop` is **1465** after input intelligence and the Local Flow Intelligence extension; playwright e2e is separate. Read the pass numbers together with the `better-sqlite3` ABI the tree is built for (`11` GAP-INFRA-06) — they are not comparable across configurations:
- **Host Node ABI** (2026-09-21, before the LLM fix): 1311 / 1314 passing. The three failures are environment-dependent rather than regressions — two need a local sidecar venv or embedding server, one pins an error message that has since changed (`11` GAP-QA-02). **This configuration has not been re-measured since the LLM fix.**
- **Electron ABI** (2026-09-21, after the LLM fix): `366 failed | 994 passed (1360)`, against a clean-tree baseline of `366 failed | 948 passed (1314)` in the same configuration — identical failure count, +46 passed, **zero new failures**. 365 of those 366 are `tests/red/*.usecase.test.ts` files dying at DB creation because of the ABI mismatch, not assertions.
- **Electron ABI** (2026-09-21, after input intelligence): `366 failed | 1042 passed (1408)`, against a clean-tree baseline of `366 failed | 994 passed (1360)` in the same configuration — identical failure count, +48 passed, **zero new failures**. 365 of those 366 are `tests/red/*.usecase.test.ts` files dying at DB creation because of the ABI mismatch, not assertions.
- **Native-ABI mismatch run** (2026-09-23, `1.5.0` release verification): `366 failed | 1099 passed (1465)`. The `better-sqlite3` module is still built for Electron ABI 130 while the host Node is ABI 131, so this is the same mismatch configuration. The failure count is unchanged (365 ABI + 1 stale sidecar error-message assertion, `11` GAP-QA-02) and the passed count rose with the new tests, so there are **zero new failures**. Full strict/full suite and Electron GUI verification remain open gates, not evidence of green.
- Input intelligence (`2026-09-21`): telemetry capture, weekly insights, next-sentence ghost text and phrase learning are implemented; real typing has verified capture → UIA snapshot → policy decisions, while overlay position/appearance, accept-insert, password blocking and weekly numbers remain manual gates (`11` GAP-INPUT-01). The 24.7 s cold / 4.9 s warm figures are a **historical latency diagnosis**, not current behaviour. **2026-09-22 operating verification:** at 19:14:12 app startup warmup plus `keep_alive: 30m` held `gemma4:e4b` resident through 19:44:12 at VRAM 3,226,342,521 bytes and context 4096. Windows GPU Engine PID sampling found no active Ollama compute then, so this was forced residency rather than infinite inference. Separately, 19:11:17–19:11:58 logs show automatic suggestions repeatedly generated under the old 900 ms / 12 per min / 5-candidate / 128-token / one-character-growth policy: a permitted burst, not proof of a single stuck request. The replacement guard removes boot warmup, uses `keep_alive: 2m`, and sets 600 ms debounce, 5 s minimum interval, 6 per min (hard maximum 12), 3 candidates, 64 tokens, 12-character growth and 8 s timeout. `LocalLLMService` adds per-request cancellation, bounded generate/stream/chat calls, required `done` frames and incomplete cleanup; voice cancellation is single-flight and reaches the request; server spawn/polling are deduplicated and disposed. Independent targeted verification passed 6 test files / 69 tests with 0 failures; changed code/tests ESLint and `git diff --check` exited 0. Raw Ollama proof: a cold bounded request hit the client hard timeout at 15.044 s then left `/api/ps` empty and `/api/version` recovered in 80 ms; explicit warmup returned HTTP 200 in 16.639 s; the subsequent `num_predict=1`, `keep_alive='2m'` request returned HTTP 200 in 553 ms with `done:true`, `eval_count:1`, `response=OK`, `done_reason:length`, and an observed `/api/ps` expiry of about 119.9 s. At 19:48:59 +09:00, with no intervening generate/unload/kill/retry, a single `/api/ps` returned HTTP 200 in 45.8 ms with `{models:[]}` and `/api/version` returned HTTP 200 in 7.3 ms with `0.32.13`: raw API expiry/unload evidence only. This does not prove an app restart, GUI overlay or real automatic-typing path, so those remain `[~]` runtime gates (`11` GAP-LLM-04, GAP-INPUT-06).
- **2026-09-23 overlay lifecycle / Windows child-process audit:** focused five test files passed 80 tests; core had 131 passing tests in an earlier independent verification; desktop typecheck/lint, Python `py_compile`, and `git diff --check` exited 0. Code and automated-test audit covers `windowsHide:true` on TTS PowerShell, VoiceAction cmd/PowerShell/general exec, audio-device and active-window PowerShell, plus all three ffmpeg paths; with existing SoX/STT/Ollama/sound-effect coverage, no Windows-capable desktop-main child-process call is known to be omitted. This is not Electron GUI runtime proof. Keep INPUT-07 and GAP-INPUT runtime gates `[~]` until an external-terminal `run-desktop.bat` restart confirms dismissal under generation/selection/focus loss and no cmd/PowerShell window recurrence for TTS, voice action, audio enumeration, screen context and file transcription.
- Cross-platform packaging: Windows NSIS (signed, `forceCodeSigning`), macOS DMG/ZIP arm64 (ad-hoc signing); auto-update via canonical Forgejo feed with update policy (`release/update-policy.json`).
- Local-first AI (SoX + faster-whisper sidecar + bundled Ollama) and cloud paths both present.
- **Local STT is packaged** (`1.3.0`): `electron-builder.yml` `extraResources` copies `sidecar-dist/sidecar` → `resources/sidecar` and `resources/ffmpeg` → `resources/ffmpeg`; `scripts/ci/verify-sidecar-bundle.mjs` gates packaging. Build locally with `npm --prefix apps/desktop run sidecar:setup && npm --prefix apps/desktop run sidecar:build`. The sidecar stays in console mode so `stdout`/`stderr` reach the app log (UTF-8, line-buffered); a packaged sidecar **must** exist or startup fails loudly instead of silently falling back to a system Python.
@ -220,6 +239,12 @@ DB schema (`src/main/db/schema.ts`, drizzle SQLite): `history`, `dictionary`, `s
| IPC registry | `src/main/ipc/index.ts` |
| IPC channel SSOT | `packages/core/src/ipc-channels.ts` |
| Key-binding contract SSOT | `packages/core/src/keybinding.ts` (catalog, actions, validation, conflicts, formatting, parsing) |
| Input intelligence domain SSOT | `packages/core/src/input-intelligence.ts` (key classification, typed delta, suggestion policy, overlay anchoring, flow/friction aggregation, phrase decay/ranking, provenance and exclusion recommendation) |
| Input intelligence services | `src/main/services/InputTelemetryService.ts`, `SuggestionService.ts`, `UiaContextService.ts`, `global-input-hook.ts` |
| Foreground window FFI | `src/main/utils/win32-foreground.ts` (koffi → user32/kernel32) |
| UIA bridge (sidecar) | `sidecar/uia_bridge.py` + `GET /uia/focus` in `sidecar/main.py` |
| Input UI | `src/renderer/components/input-insights/{InputConsentPanel,InputInsightsView}.tsx`, `src/renderer/hooks/useInputInsights.ts`, `src/renderer/popups/suggestion-overlay/` |
| Local Flow Intelligence tests | `tests/main/services/input-flow-domain.test.ts` (9), `input-flow-services.test.ts` (12); targeted suite 21/21 at the documented handoff point |
| Key-binding service / IPC / UI | `src/main/services/KeyBindingService.ts`, `src/main/ipc/keybinding-handlers.ts`, `src/renderer/components/keybinding/` |
| Preload API | `src/preload/index.ts` |
| Windows | `src/main/windows/WindowManager.ts` |

View file

@ -37,7 +37,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
| ID | Feature | D | W | M | B | Anchors / notes |
|---|---|---|---|---|---|---|
| AI-01 | Local LLM (Ollama) | [x] | [-] | [ ] | [-] | Desktop bundled Ollama |
| AI-01 | Local LLM (Ollama) | [~] | [-] | [ ] | [-] | Desktop bundled Ollama. 2026-09-22 guard contract: no boot warmup; suggestion `keep_alive: 2m`; request-owned cancellation; generate/stream 2048 tokens / 120 s and chat 512 / 60 s; `done` frame required with incomplete-stream cleanup; deduplicated Ollama spawn/poll plus lifecycle disposal. GAP-LLM-03 has targeted evidence (6 test files / 69 passed / 0 failed; changed code/tests ESLint and diff check exit 0). Raw Ollama confirms cold timeout cleanup, a 553 ms `num_predict=1`/`keep_alive='2m'` response, and after the about-119.9 s expiry a 19:48:59 +09:00 `/api/ps` HTTP 200 / 45.8 ms `{models:[]}` observation with no intervening generate/unload/kill/retry; `/api/version` was HTTP 200 / 7.3 ms / `0.32.13`. This is raw API expiry/unload evidence, not app-restart or GUI/runtime proof (GAP-LLM-04). |
| AI-02 | Cloud LLM (Claude/OpenAI) | [x] | [x] | [x] | [x] | Desktop `PremiumLLMService`; web/mobile via `llm-proxy`; .NET `LlmProxyService` |
| AI-03 | Auto Polish (cleanup/filler removal) | [x] | [~] | [~] | [x] | Desktop built-in; web/mobile via commands. Desktop Auto Polish is the plain `refine` action (`llm-prompts.ts:14`), not a custom instruction, so it was **not** affected by the 2026-09-21 instruction-prompt fix (AI-05); regression cases now pin `refine`/`summarize`/`grammar`/`expand` (`VoiceModeService.test.ts:435`, `llm-prompts.test.ts:153`) |
| AI-04 | Translate / summarize / rephrase | [~] | [x] | [x] | [x] | Built-in instructions. **Desktop has two paths and only one of them worked.** The plain-action path (Settings → `defaultLLMAction`, `SettingsModal.tsx:653`) reads `BASE_SYSTEM_PROMPTS` directly and was always correct. The built-in *instruction* presets (`CustomInstructionService.ts:26/35/44/53/62`) ran through the custom-instruction path and inserted the instruction's own wording instead of the result — see AI-05. Fixed in `9c2b4d4` (2026-09-21), **not verified in a running app** (`11` GAP-LLM-02). Translate still always targets English: `AppConfig` has no target-language key and neither `language` (UI locale) nor `sttLanguage` (source language) can stand in (`llm-prompts.ts:37-52`, `11` GAP-LLM-01) |
@ -155,6 +155,36 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]`
---
## INPUT — Input Intelligence (typing telemetry & next-sentence suggestions)
Everything here is **desktop-only** (Windows today) and **opt-in**: `inputTelemetryEnabled` and
`inputLearnTypedText` default to `false`. D=desktop, W=web, M=mobile, B=backend.
Status is `[~]` on desktop for one shared reason: the code and its 48 unit cases exist, but the
end-to-end behaviour has **not been verified by typing in a real app** (`11` GAP-INPUT-01).
| ID | Feature | D | W | M | B | Anchors / notes |
|---|---|---|---|---|---|---|
| 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-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. |
| INPUT-11 | App DNA | [~] | [-] | [-] | [-] | Personal phrase ranking passes `appName` context and applies a 1.75× same-app ranking bonus (`selectPhraseHints`); learned text remains opt-in. |
| INPUT-12 | App Quality | [~] | [-] | [-] | [-] | `InputTelemetryService.getSummary` aggregates per-app suggestion total, accepted count, accept rate and average latency from local suggestion history. |
| INPUT-13 | Privacy Receipt | [~] | [-] | [-] | [-] | Local-only receipt shows actual row counts. Raw individual key events/key codes/content stream are not retained; opt-in learned text can reside in `typing_samples`/`personal_phrases`. `input_activity`, `typing_samples` and `suggestions` retain 30 days; `personal_phrases` has no age-based automatic expiry and is removed by individual deletion, delete-all, or consent withdrawal. Receipt read/delete storage failures are IPC errors. |
| INPUT-14 | Smart Exclusion | [~] | [-] | [-] | [-] | Password fields add no evidence. Only the current app may receive a one-click recommendation after ≥4 observations, readable=0 and problematic ratio ≥75%; it never auto-excludes. |
| INPUT-15 | Why This Suggestion | [~] | [-] | [-] | [-] | Overlay exposes only local-model/local-memory provenance and continuation/related/phrase/appPhrase counts; raw evidence text is not shown. |
| INPUT-16 | Memory Decay | [~] | [-] | [-] | [-] | Phrase ranking combines a 30-day half-life, frequency and the App DNA 1.75× same-app bonus. |
| INPUT-17 | Instant Recall | [~] | [-] | [-] | [-] | Local-memory fallback (`provenance=local-memory`) occurs only for unavailable model, non-cancellation error, actual timeout or empty model result; it spends no additional model budget and is blocked on dismiss, new typing, token/context mismatch or staleness. |
| INPUT-18 | Shortcut Safety Audit | [~] | [-] | [-] | [-] | Settings UI applies core `auditKeyBindingMap` to surface invalid/conflict issues while hold/double-press exceptions remain unchanged. |
---
## SHELL — Platform Shell, Settings, Onboarding, Support
| ID | Feature | D | W | M | B | Anchors / notes |

View file

@ -72,6 +72,16 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| GAP-INFRA-05 | Build | 패키징된 렌더러 팝업 스크립트가 번들에 없었다. 팝업 HTML이 classic `<script src="./script.js">`를 참조해 Vite가 처리하지 않았고, dev에서는 로드되지만 설치본에는 파일이 없었다. 그래서 녹음 오버레이가 0:00에서 멈추고 웨이브 바가 뜨지 않았으며 실시간 자막이 렌더되지 않았다. 로드 전 `webContents.send`가 조용히 버려지는 문제와 `hide()` 이후 재표시의 z-order/repaint 유실도 함께 있었다. | `apps/desktop/src/renderer/popups/*/index.html`, `apps/desktop/src/main/windows/WindowManager.ts`, `scripts/ci/verify-desktop-renderer-bundles.mjs` | `[x]` 2026-09-19: 팝업 5종을 `type="module"`로 전환해 Vite가 해시된 번들로 방출하도록 고쳤고, 빌드 HTML이 참조하는 모든 로컬 asset이 디스크에 있는지 검사하는 `verify-desktop-renderer-bundles.mjs`(+ self-test)를 `.forgejo`/`.github` 패키징 파이프라인에 연결했다. WindowManager는 렌더러 준비 전 IPC를 `did-finish-load`까지 보관하고, 팝업을 표시할 때마다 topmost 재선언 + 강제 repaint를 수행하며, 팝업 렌더러 콘솔/로드 실패를 main 로그로 승격한다. |
| GAP-INPUT-01 | Input intelligence | **2026-09-22 10:57 실사용 검증 성공** — 카톡 타이핑 중 제안 5개 생성(1436ms)이 로그로 확인됐고(조합 중·idle 25ms에서도), 50초 관찰에 스냅샷 67건/생성 3회/실패 0건이었다. 남은 확인: 오버레이 위치·외관, 수락 삽입, 비밀번호 차단, 주간 수치 48개 유닛 케이스는 정책/집계/프롬프트 같은 순수 함수만 덮는다. 검증되지 않은 것: (1) `Alt+Shift+←` 수락이 실제 앱에 문장을 삽입하는지, (2) 오버레이가 케어렛 옆에 붙는지(케어렛 rect Chromium 에서만 UIA 로 얻어지고 네이티브 Win32 앱은 `GetGUIThreadInfo` 경로가 아예 없다), (3) `TypeError` 없이 Electron 메인에서 `koffi` 가 로드되는지(현재 호스트 Node 에서만 확인), (4) PyInstaller 번들에 `uiautomation`/`comtypes` 가 실제로 들어가는지(`build-sidecar.mjs` 에 `--collect-all` 을 추가했지만 번들을 다시 빌드해 확인하지 않았다). 에이전트는 데스크톱 GUI 를 띄울 수 없다(`AGENTS.md` §3). | `apps/desktop/src/main/services/{InputTelemetryService,SuggestionService,UiaContextService}.ts`, `apps/desktop/src/main/utils/win32-foreground.ts`, `apps/desktop/sidecar/{uia_bridge.py,requirements.txt}`, `apps/desktop/scripts/build-sidecar.mjs`, `apps/desktop/tests/main/services/input-intelligence.test.ts`(45) + `llm-prompts.test.ts`(+4) | **2026-09-21 23:13 부분 검증 완료** (사용자가 실제로 Notepad 에 타이핑한 로그): 텔레메트리 기동, uiohook 후킹, UIA 스냅샷(Notepad — `edit=true pw=false comp=false src=value`), WindowsTerminal 의 문서형 컨트롤을 `not-editable` 로 정확히 거부, 판단 로그까지 전부 실동작 확인. 이 과정에서 결함 4건을 잡아 고쳤다: (a) 트리거 지연(1000ms)이 스냅샷 디바운스(700ms)보다 커서 **"멈춘 뒤" 게이트가 결코 열리지 않던 문제**(settle 패스 추가), (b) 켜 둔 상태로 앱을 켜면 워밍업이 한 번도 안 돌던 문제(부팅 시 워밍업 + 가용성 폴링 대기 재시도), (c) 케어렛 오프셋을 못 주는 앱에서 문서 전체를 접두로 쓰던 문제(tail 폴백), (d) 후보가 도착해야 오버레이가 떠서 "아무것도 안 나옴" 으로 보이던 문제(요청 즉시 "생성 중" 표시). **2026-09-22 09:00~09:17 추가 실측** (사용자가 KakaoTalk·WindowsTerminal·Agent Switchboard 에 타이핑한 17개 스냅샷): 편집 가능으로 판정된 것은 3건뿐이고, 그중 KakaoTalk 입력창은 `edit=true src=value` 인데도 `len=0` 이라 실제 내용을 못 읽었다 — 커스텀 렌더 앱(카톡·터미널·에이전트 UI)에서는 UIA 가 텍스트를 노출하지 않는다. 즉 "아무것도 안 나옴" 의 상당 부분은 결함이 아니라 **읽을 수 없는 앱에서의 정상 동작**이며, 이 구분이 사용자에게 보이지 않던 것이 문제였다. 그래서 설정 > 입력에 **실시간 진단 줄**(포커스 앱 · 읽기 가부 · 소스 · 글자 수 · 비밀번호/케어렛 폴백 표시)을 추가하고 12개 로케일에 문구를 넣었다. 남은 확인: 오버레이의 실제 위치·외관, `Alt+Shift+←` 수락 삽입, 비밀번호 필드 차단, 주간 수치 정확도. 함께: `sidecar:build` 후 `GET /uia/focus` 응답을 확인하고 그 결과를 `scripts/ci/verify-sidecar-bundle.mjs` 의 필수 항목에 반영한다. **검증 도구 주의**: 데스크톱 `npm run typecheck` 는 문서화된 대로 no-op 이라(GAP-INFRA-04) 이번 작업에서도 거짓 통과를 냈다 — `typecheck:strict` 로 다시 돌려 이 작업이 만든 타입 오류 7건(core 에 없는 `InputTelemetryState` 참조 5건 등)을 찾아 고쳤다. 현재 strict 기준 이 작업 파일들의 오류는 0건이다(main 13 / renderer 35 는 전부 선재). |
| GAP-INPUT-07 | Input intelligence | `[~]` **2026-09-23 focused automated evidence:** five test files / 80 tests passed; desktop typecheck/lint, Python `py_compile`, and `git diff --check` exited 0 (the core 131-test pass is earlier independent evidence). The overlay lifecycle and `windowsHide:true` child-process audit are code/automation evidence only, not Electron GUI or external-app insertion proof. | `InputTelemetryService.ts`, `SuggestionService.ts`, `KeyBindingService.ts`, `components/input-insights/{InputConsentPanel,InputInsightsView}.tsx`, `popups/suggestion-overlay/`, `tests/main/services/{input-flow-domain,input-flow-services,windows-child-process-hide}.test.ts` | From an external terminal restart with `run-desktop.bat`, verify: (1) click X during generation closes immediately and never reappears; (2) selecting text closes the overlay; (3) input-focus loss closes it; (4) TTS, voice action, audio enumeration, screen context and file transcription do not revive a cmd/PowerShell window. Retain the existing editable/read-unavailable/password, Flow Radar/Edit Friction/App Quality, receipt, Smart Exclusion, provenance/fallback, shortcut-audit and narrow-geometry checks. Automated tests do **not** replace this manual proof. |
| GAP-INPUT-02 | Input intelligence | **IME 조합 중 텍스트(preedit)는 수집되지 않는다.** 우리는 조합 중에는 통계·제안을 모두 억제하고 조합이 끝난 커밋 텍스트만 UIA 로 읽는다. 조합 문자열 자체를 읽으려면 `IUIAutomationTextEditPattern::GetActiveComposition`(또는 레거시 앱은 IME `ImmGetCompositionStringW`)이 필요하다. KeyType.Windows 도 이 부분을 명시적으로 다음 사이클로 미룬다 — 즉 업계 공통 미해결 지점이다. 조합 중 억제 자체는 MS Learn 의 IME 문서상 필수 조치이며 구현돼 있다. | `apps/desktop/sidecar/uia_bridge.py`(`_caret_rect_and_offset` 의 TextEditPattern 블록), `packages/core/src/input-intelligence.ts`(`decideSuggestion` 의 `composing` 분기) | 조합 범위를 읽어 "조합 중 미리보기"를 제안 후보로 쓸지 검토한다. 지금은 억제만 하고 있어 한국어 사용자는 조합을 끝내야 제안이 뜬다. |
| GAP-INPUT-03 | Input intelligence | **공백이 없는 언어(중국어)는 단어 수가 과소 집계된다.** `countWords` 가 공백 분리 토큰을 세므로 "今天开会讨论了三件事" 는 1단어로 계산된다. 한국어/영어/일본어(공백 사용)는 정상이다. | `packages/core/src/input-intelligence.ts`(`countWords`), 카탈로그 INPUT-03, 테스트 `input-intelligence.test.ts` | CJK 연속 구간을 문자 단위로 세는 분기를 추가하거나, 통계 라벨을 "단어" 대신 "어절"로 바꾼다. |
| GAP-INPUT-04 | Input intelligence | **UIA 브리지가 Windows 전용이다.** `uiautomation` 은 Windows UI Automation 래퍼이므로 macOS 는 AX API, Linux 는 AT-SPI 구현이 따로 필요하다. 그래서 입력 인텔리전스는 카탈로그에서 데스크톱(`D`)만 표기하고 나머지 표면은 `[-]` 다. 또한 Chromium 138 미만 앱은 접근성 트리가 켜져 있으면 입력창 텍스트를 노출하지 않는데, 그 앱들을 `--force-renderer-accessibility` 로 켜도록 강제할 방법이 없다(우리 자신은 `app.setAccessibilitySupportEnabled(true)` 로 처리했다 — `bootstrap.ts`). | `apps/desktop/sidecar/uia_bridge.py`, `apps/desktop/src/main/bootstrap.ts`(`initInputIntelligence`) | macOS AX 경로를 붙일지 결정한다. 붙이지 않으면 카탈로그에서 macOS 를 명시적 N/A 로 유지한다. |
| GAP-INPUT-05 | Input intelligence | **수락/닫기 키가 사용자 습관과 충돌할 수 있다.** 기본값을 `Alt+Shift+←/↓/↑` 로 둔 이유는 Tab·Escape·Ctrl+Space 같은 관례 키를 뺏지 않기 위해서다(우리는 키를 삼키지 않으므로 원래 동작이 함께 실행된다). 그 대가는 "Tab 으로 수락" 같은 자연스러운 조작이 아니라는 점이고, 인라인 컴플리션 도구 대부분(Tab)과 다르다. | `packages/core/src/keybinding.ts`(`suggestion-accept`/`next`/`dismiss`), `apps/desktop/src/main/bootstrap.ts`(트리거 구독) | 오버레이가 보일 때만 Tab 을 삼키는 경로(전역 후킹에서 조건부 suppress)를 검토한다. 지금은 불가능하지 않지만 포커스 없는 창에서 키를 가로채는 설계가 필요하다. |
| GAP-LLM-03 | LLM | `[x]` **2026-09-22 코드 구현 완료.** 공유 `_abortController`를 요청별 controller로 대체하고 외부 signal을 각 요청에 연결/정리한다. generate/stream은 2048 tokens / 120 s, chat은 512 / 60 s로 제한하며, stream은 `done` frame 없이는 성공 처리하지 않고 불완전 응답을 정리한다. VoiceConversation은 single-flight와 request signal을 통해 local cancel을 실제 chat 취소로 전달한다. | `apps/desktop/src/main/services/LocalLLMService.ts`, `VoiceConversationService.ts` | 독립 표적 검증: 6 test files / 69 tests passed / 0 failed. 변경 코드·테스트 ESLint exit 0, `git diff --check` exit 0. 이는 전체 strict typecheck 또는 GUI 검증이 아니다. 앱 재시작 뒤 동시 제안·음성 요청의 요청별 취소와 `done` 누락 실패 처리는 GAP-LLM-04에서 계속 확인한다. |
| GAP-LLM-04 | LLM | `[~]` **Gemma/Ollama residency·burst·unbounded chat root cause (2026-09-22).** 19:14:12 boot warmup의 `keep_alive: 30m`가 `gemma4:e4b`를 expiry 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 per min / 5 candidates / 128 tokens / 1-char growth가 허용한 burst였다. 위험 경로는 chat의 무제한 `num_predict`(Ollama 기본 `-1`), timeout·외부 abort 부재, 공유 취소, `done` 없는 EOF 성공, voice cancel 미전파, watchdog의 비취소였다. | `apps/desktop/src/main/services/{LocalLLMService,SuggestionService,VoiceConversationService}.ts`, Ollama `/api/ps` and Windows GPU Engine observations | 구현 계약: boot warmup 제거, suggestion `keep_alive: 2m`; 600 ms debounce / 5 s interval / 6 per min (hard max 12) / 3 candidates / 64 tokens / 12-char growth / 8 s timeout; 요청별 cancellation, bounded requests, done/cleanup, voice single-flight, spawn/poll dedupe와 dispose. Raw Ollama 증거: cold bounded 요청은 client hard timeout 15.044 s에 취소된 뒤 `/api/ps` empty와 `/api/version` 80 ms 회복을 보였고, explicit warmup HTTP 200은 16.639 s, 후속 `num_predict=1` / `keep_alive='2m'` 요청은 553 ms HTTP 200 / `done:true` / `eval_count:1` / `response=OK` / `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·실제 자동제안 타이핑 증거는 아니므로 그 경로에서 rate limit, timeout/cancel, 2분 residency를 확인한다. |
| GAP-INPUT-06 | Input intelligence | `[~]` **제안 폭주 상한 및 runtime 확인.** 이전 24.7 s / 4.9 s, boot warmup, `keep_alive: 30m`, 48/96/128 tokens 및 2/5 candidates 기록은 역사적 가설/완화 이력이며 현재 정책이 아니다. 현 정책은 600 ms debounce, 5 s minimum interval, 6 requests/min default (hard max 12), 3 candidates, 64 output tokens, 12-character growth, 8 s timeout, `keep_alive: 2m` 및 boot warmup 제거다. | `apps/desktop/src/main/services/SuggestionService.ts`, `packages/core/src/input-intelligence.ts`, `LocalLLMService.ts` | 표적 자동 검증은 6 files / 69 passed / 0 failed이며 raw Ollama의 15.044 s cold timeout cleanup, 약 119.9 s residency expiry, 그리고 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`)도 확인됐다. 전체 strict typecheck는 green이 아니며, 이 raw API unload 증거는 앱 재시작·GUI·실제 자동제안 타이핑 검증이 아니다. 그 경로에서 rate limit, 8 s abort, 3-candidate/64-token 상한을 확인한다. |
---
## 2. Mobile checklist roll-up (from `MOBILE_APP_COMPLETION_SSOT.md` §4)
@ -188,4 +198,5 @@ Actionable checklist for the work started this session. Fields to fill are blank
| ID | 제약 | 왜 이대로 두는가 | 완화 장치 |
|---|---|---|---|
| CONSTRAINT-INPUT-01 | 입력 레메트리는 **키 내용을 저장하지 않는다.** 카운터(키 입력·클릭·스크롤)와 마우스 이동 거리, 그리고 **사용자가 명시적으로 동의한 경우에만** 읽은 텍스트(학습 DB)만 남긴다. 키코드→문자 복원은 시도하지 않는다. | ActivityWatch `aw-watcher-input` 이 같은 이유로 같은 선택을 한다 — 전역 키 내용 수집은 보안·프라이버시 파급이 크고, 신뢰를 잃으면 기능 자체가 사라진다(README 원문: "This does not track which keys you press … This is not a keylogger, and never will be"). 게다가 키코드 복원은 한/일 IME 에서 원리적으로 불가능하다(조합 결과가 텍스트다). | 수집은 옵트인(`inputTelemetryEnabled`/`inputLearnTypedText` 기본 false), 비밀번호 필드는 `IsPassword` 로 fail-closed 차단, 앱별 제외, 30일 보존 후 자동 삭제, "수집된 데이터 삭제" 버튼(동의 철회 시 즉시 삭제 + 학습 플래그 해제), 전송 없음(로컬 SQLite 전용). |
| CONSTRAINT-I18N-01 | `packages/core/src/keybinding.ts` 는 i18n 키를 평범한 `string` 으로 노출한다. 렌더러가 `asTranslationKey()`(`apps/desktop/src/renderer/components/keybinding/translation-key.ts:7`)로 경계에서 캐스팅하므로, 존재하지 않는 키를 넘겨도 컴파일러가 잡지 못한다. | core 가 로케일 패키지에 의존하지 않게 하려는 의도적 설계다. 검토한 대안 둘 다 성립하지 않는다 — (A) 키 필드를 리터럴 유니온으로 좁히는 방식은 `KEY_CATALOG` 가 `letterEntries()` 같은 함수 생성부를 포함해 불가능하고, (B) core 가 `@d3ro/i18n` 의 타입 가드를 쓰는 방식은 의존 방향을 core → i18n 으로 역전시켜 `03-shared-packages.md` §6 의 전제를 깬다. 2026-09-21 결정: 현행 유지. | `packages/core/__tests__/keybinding-i18n.test.ts` (14 케이스). core 가 참조하는 키가 12개 로케일 전부에 있는지, 값이 빈 문자열이 아닌지, core 가 렌더러 전용 `keybinding.ui.*` 를 참조하지 않는지 검사한다. 거부 사유 키는 하드코딩 목록이 아니라 실제 `validateBinding` 경로를 태워 수집하므로 새 사유가 생기면 자동으로 커버된다. |

View file

@ -1,5 +1,145 @@
# D3RO-VOICE 프로젝트 현황
## Local Flow Intelligence 10-pack — 문서/자동 증거 handoff (2026-09-22)
- 데스크톱 기존 InputTelemetry/Suggestion/UI 확장으로 Flow Radar, Edit Friction, App DNA, App Quality, Privacy Receipt, Smart Exclusion, Why This Suggestion, Memory Decay, Instant Recall, Shortcut Safety Audit을 구현했다. raw key events/key codes/content stream은 저장하지 않지만, 동의한 학습 텍스트는 `typing_samples`/`personal_phrases`에 별도 정책으로 남을 수 있다. `personal_phrases`는 나이 기반 자동 만료가 없고 개별 삭제·전체 삭제·동의 철회로 제거된다.
- 이 handoff 시점의 자동 증거는 domain 9/9 + service 12/12 = targeted 21/21, desktop build passed다. 이는 strict/full suite 전체 또는 GUI 증거가 아니다.
- 재시작 가능한 다음 게이트: 외부 터미널에서 `run-desktop.bat`을 실행해 편집 가능/읽기 불가/비밀번호 앱, flow·friction·app-quality 화면, receipt count와 failure, exclusion recommendation, provenance·local fallback, shortcut audit, 좁은 overlay geometry를 수동으로 확인한다. 전체 strict/full suite와 Electron GUI 검증은 별도 미완료 게이트로 유지한다.
---
## 입력 인텔리전스 — 속도 진단 정정 + 토큰 스트리밍 (2026-09-22 09:40~09:55) ⚡
사용자 지적: "gemma4 로컬에서 엄청 빠르지 않나". **맞았고 내 이전 측정이 틀렸다.**
**재측정 (`ollama ps` / `/api/generate`, 이 머신)**
| 항목 | 값 |
|---|---|
| 로드 상태 | `gemma4:e4b` 3.2GB **100% GPU**, 컨텍스트 4096 |
| 단문 생성 | 126~137 tok/s |
| 제안형 프롬프트(400자 접두 + 64토큰) | wall **1.11초** (load 0.56 + prompt 0.06 + eval 0.48) |
→ 앞서 기록한 24.7초(콜드)/4.9초(32토큰)는 **Ollama 기본 `keep_alive` 5분 때문에 매 요청 모델을 다시 올린 비용**이었다. 내 측정 스크립트가 `load_duration` 을 분리하지 않아 지연 전체를 모델 탓으로 오진했다. (`keep_alive: 30m` 적용 후 부팅 워밍업 1.8~2.5초, 요청 ~1초.)
**UX 변경 (사용자 요청: "계속 생성되서 올라왔으면")**
- 트리거 1000 → **300ms** (Continue 350 / Tabby 250 / twinny 300 구간 복귀).
- **토큰 스트리밍**: 생성 중 도착하는 대로 오버레이에 흘려보낸다(120ms 간격 방출, `partialText`), 팝업은 깜빡이는 커서와 함께 부분 텍스트를 표시.
- **지속 갱신**: 접두가 12자 이상 자라면 다시 생성(`SUGGESTION_REGENERATE_GROWTH_CHARS`) — "한 번 뜨면 끝" 이 아니다.
- 토큰 상한 48, 몇 초 멈출 필요 없이 치면서 갱신된다.
**검증**: 유닛 70건 GREEN, lint/design clean, build 성공, `typecheck:strict` 로 이 작업 파일 오류 0건(main 13 / renderer 35 는 선재). 앱 09:52 재기동 + 워밍업 1.8초.
---
## 입력 인텔리전스 — 실사용 피드백 4건 수정 (2026-09-22 11:00~11:15) 🔧
**사용자 신고**
1. `Ctrl+Alt+화살표` 를 누르면 음성 전사가 뜬다 → 후보 탐색 불가
2. X를 눌러도 생성 중이면 안 꺼진다
3. 입력창을 클릭만 해도 제안이 만들어진다
4. 엔터로 입력을 끝낸 뒤에도 계속 제안한다
**원인과 수정**
- **AltGr 함정**: 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보낸다. 저장된 바인딩이 `command = Ctrl + RightAlt` 였으므로 Ctrl+Alt+화살표에서는 **Alt 를 누르는 순간** command 가 발동해 음성 파이프라인이 돌았다. → 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는 280ms 보류하고, 그 사이 다른 키가 이어지면 취소(+ released 도 억제). 받아쓰기(Alt 단독)·Alt+Shift 계열은 모양이 달라 영향 없음.
- **닫기 무효화**: 진행 중이던 생성이 나중에 완료되며 결과를 다시 표시했다. → **생성 세대 토큰**을 두고, 닫으면 증가시켜 늦게 온 결과를 폐기.
- **클릭만으로 제안**: 백그라운드 샘플러가 마우스 이벤트에도 돌았다. 유휴 시간도 `_lastEventAt`(키+마우스) 기준이었다. → **키보드 기준(`_lastKeyAt`)** 으로 게이트.
- **엔터 후 반복**: 엔터 뒤 UIA 가 옛 텍스트를 돌려주면 같은 텍스트로 재요청했다. → **동일 접두 가드**(같은 텍스트는 다시 만들지 않음, 더 치면 자연히 새로 생성, 수동 "지금 제안 받기" 는 우회).
**함께**: 직전 배치에서 KeyBindingService 의 emit 블록에 중복 브레이스가 생겨 빌드가 깨졌던 것을 복구하고, AltGr 취소 키를 서비스 내부 bindingKey 와 일치시켰다(불일치 시 release 만 남아 음성이 오작동할 수 있었다).
검증: 데스크톱 유닛 73 + core 117 GREEN, lint clean, build 성공, 11:10 재기동.
---
## 입력 인텔리전스 — 실사용 성공 + 연속 샘플링 (2026-09-22 10:40~11:00) ✅
**사용자 피드백**: ①계속 치면 잘 안 나옴(백그라운드에서 부단히 돌아야) ②후보 2개는 적다, 5개+스크롤 ③워밍업/생성 중에도 반응이 보여야.
**근본 원인 (치명적)**: 스냅샷이 "타이핑을 멈춘 뒤"에만 예약돼 있어(디바운스 타이머가 키 입력마다 리셋), 에이전트 개발처럼 끊김 없이 치면 **샘플이 하나도 만들어지지 않았다**.
→ 최근 입력이 있는 동안 800ms 주기로 도는 **백그라운드 샘플러**를 추가.
**함께 고친 것**
- 후보 2 → **5개**, 토큰 48 → 128, 스트리밍 유지. 오버레이 높이 96 → 240 + 목록 내부 스크롤.
- **워밍업 스피너**: 모델 적재 중이면 오버레이에 스피너 + "모델 준비 중" 을 띄운다(무반응 = 고장으로 보이던 문제).
- **생성 스피너**: 후보 도착 전에도 스피너 + 도착한 부분 텍스트(깜빡이는 커서).
- 재생성 최소 간격 1500 → 900ms (지속 갱신 체감).
- 터미널: Windows Terminal 은 TextPattern 은 있으나 ${b('GetSelection()')} 이 0개라 "편집 불가" 로 판정되던 것을, TextPattern 존재 기준으로 변경.
- 통계 UI: inner tab(요약/키보드/마우스/앱/문구·제안) 재구성 + 막대 그래프를 축·라벨·고정폭으로 다시 구현(1일치 데이터에서 거대 사각형이 되던 버그). 통계는 **지식 베이스 > 입력 인사이트** 로 이동, 설정 > 입력은 동의·정책·진단만.
**검증 (10:57, 카톡 타이핑 중 실제 로그)**
- 스냅샷 67건/50초 · 제안 생성 3회 · 실패 0건 · 삭제 20건
- `제안 5개 생성 (1436ms, model=gemma4:e4b)` — 조합 중(comp=true, idle=25ms)에서도 생성됨
---
## 입력 인텔리전스 — 실기동 검증에서 잡은 결함 6건 (2026-09-22 09:00~09:20) 🔧
사용자가 실제로 앱을 띄우고 타이핑한 로그로 검증하면서, 유닛 테스트가 못 잡는 결함을 순서대로 잡았다.
**측정/관찰 (실제 로그)**
- UIA 자체는 정상: Notepad 는 edit=true src=value len=4000 anchor=yes, WindowsTerminal 은 not-editable 로 정확히 거부.
- KakaoTalk 은 legacy/len=0 → 이후 edit=true src=value 인데도 len=0 → 커스텀 렌더 앱은 텍스트를 노출하지 않는다(결함 아님).
- 제안 생성은 시도됐으나 "LLM generation cancelled" — 6초 제한이 워밍업 후 4.9초 요청까지 잘라냈다.
- 워밍업+keep_alive 적용 후 모델 재적재 2.5초(콜드 24.7초 대비).
**고친 결함**
1. settle 게이트 미개방(치명적): 트리거 지연(1000ms)이 스냅샷 디바운스(700ms)보다 커 "멈춘 뒤" 조건이 열리지 않았다 → settle 스냅샷 추가.
2. 부팅 시 워밍업 미실행: 이미 켜 둔 설정으로 켜면 워밍업이 안 돌았다(가용성 폴링 전 early return) → 부팅 워밍업 + 대기 재시도(2초×15).
3. 케어렛 폴백 부재: 오프셋이 없는 앱에서 문서 전체를 접두로 썼다 → tail 400자 + 진단 플래그.
4. 오버레이 지연 표시: 후보 도착 후에야 떠서 "아무것도 안 나옴" 으로 보였다 → 요청 즉시 "생성 중" 표시.
5. 느린 하드웨어 대응: 컨텍스트 600→400자, 토큰 96→64, 후보 3→2, 응답 제한 6→12초, 트리거 320→1000ms, keep_alive 30m.
6. 진단 불가: 왜 안 뜨는지 알 수 없었다 → 설정 > 입력에 실시간 진단 줄(포커스 앱·읽기 가부·소스·글자 수·비밀번호/케어렛 폴백), 12개 로케일 8키.
**검증 도구 주의**: 데스크톱 npm run typecheck 는 no-op(GAP-INFRA-04)이라 이번에도 "clean" 이 거짓이었다. typecheck:strict 로 이 작업이 만든 타입 오류 7건을 고쳤다(main 13 / renderer 35 는 전부 선재). 유닛 70건 GREEN.
**남은 검증**: 오버레이 위치·외관, Alt+Shift+← 수락 삽입, 비밀번호 필드 차단, 주간 수치 정확도, PyInstaller 번들에 uiautomation 포함 여부.
---
## 입력 인텔리전스 — 입력 수집 + 다음 문장 제안 (2026-09-21) ⌨️
"일본어 IME 처럼 다음에 칠 문장을 커서 옆에 제시" 요청. **뇌피셜 대신 조사 먼저** 지침으로 받았다.
**조사로 확정한 사실 (설계 근거)**
- 케어렛 위치: `GetGUIThreadInfo.rcCaret`(AutoHotkey `CaretGetPos` 방식)은 **Chromium/Electron/VS Code 에서 아무것도
돌려주지 않는다**(자체 커서 렌더). 정본은 UIA `TextPattern.GetSelection()` → `ExpandToEnclosingUnit(Character)` →
`GetBoundingRectangles()`. Chromium 은 `ITextProvider` 는 구현하지만 `ITextPattern2::GetCaretRange` 는 미구현.
- 입력창 읽기: `ValuePattern.Value` → `TextPattern.DocumentRange` → `LegacyIAccessiblePattern` 순서,
비밀번호는 `UIA_IsPasswordPropertyId(30019)` 로 **읽기 전에** 차단. 트리 전체 순회 금지
(PowerToys #46385: Chrome/VS Code UIA 트리 워크 10~30초).
- 한/일 IME: 키코드로 텍스트 복원은 **원리적으로 불가능**(조합 결과가 텍스트). 그래서 UIA 로 커밋된 텍스트를
읽어 **스냅샷 diff**(최장 공통 접두/접미)로 계산한다. 조합 중에는 통계·제안을 모두 억제한다.
- Node 후보 비교: `koffi` 3.3.1(N-API prebuild) 채택. `selection-hook`(선택 스트만·케어렛 없음),
`@crowecawcaw/xa11y`(caret rect 없음·IsPassword 미검사), `get-windows`(ESM + install script 필요 → 이 저장소 정책과 충돌),
`node-ffi-napi`(2021년 이후 방치) 배제. UIA COM vtable 을 JS 로 직접 다루는 것은 크래시 위험으로 배제.
- 입력 수집 정책: ActivityWatch `aw-watcher-input` 그대로 — `presses/clicks/deltaX/deltaY/scroll`, **키 내용 미저장**,
5초 heartbeat. 마우스 거리는 축별 절대값 합(맨해튼).
- 제안 타이밍: Continue 350 / Tabby 250(adaptive) / twinny 300 / minuet 400 ms, Zed p50<200ms,
출력 토큰 Tabby 64 / KeyType 4~16 → 기본 320ms·96토큰·후보 3개.
**구현**
- core SSOT `packages/core/src/input-intelligence.ts`(정책·집계·정제·앵커 전부 순수 함수), IPC 3그룹
(`INPUT_TELEMETRY`/`SUGGESTION`/`POPUP_SUGGESTION`), 키바인딩 션 3개(`suggestion-accept/next/dismiss`).
- 메인: `InputTelemetryService`(uiohook + koffi 포그라운드 창 + UIA diff 학습), `UiaContextService`(사이드카 브리지, fail-closed+백오프),
`SuggestionService`(디바운스→`buildSuggestionPrompt`→`streamGenerate`(호출자 시그널 취소)→수락 시 `TextInsertService` 삽입),
`global-input-hook.ts`(uiohook 참조 카운트 — KeyBindingService 와 공존).
- 사이드카: `uia_bridge.py` + `GET /uia/focus`(전용 COM 스레드, `uiautomation` 2.0.29). 실측 워 0~15ms / 최초 200~590ms.
- DB: `input_activity`(시간×앱), `typing_samples`, `personal_phrases`, `suggestions` + 30일 보존 정리.
- UI: 설정 > 입력 탭(동의·정책·주간 인사이트·문구 관리), 대시보드 주간 카드, `suggestion-overlay` 팝업(케어 앵커,
클릭 통과 기본).
- 검증: 유닛 48건(`input-intelligence.test.ts` 44 + `llm-prompts.test.ts` 4) GREEN, typecheck/lint/design GREEN,
Electron ABI 전체 실행에서 **신규 실패 0건**(베이스라인 366 failed/994 passed → 366 failed/1042 passed).
사이드카 `/uia/focus` 실제 호출 확인(200, 포커스 요소 반환).
**라이브 실측 (실제 Ollama + 프로덕션 프롬프트, 2026-09-21)**
- 프롬프트/품질은 정상: 한국어 "오늘 회의에서 논의한 내용을 정리해서" → "관련 자료와 함께 다시 한번 정리해서 보내드릴게요."
(후보 3개 전부 정제 통과), 영어도 자연스럽게 이어졌다.
- **속도가 예산 밖**: `gemma4:e4b` 96토큰 한도에서 **콜드 24.7초 / 워 4.9초(32토큰)**. Zed 목표 p90<500ms 와 두 자릿수 차이.
→ 완화 3종을 넣었다: 활성화 시 워밍업(1토큰), `keep_alive: 30m` (Ollama 기본 5분 → 재콜드로딩 방지), 응답 제한
(기본 6초, 설정 > 입력에서 조절, 초과 시 그 요청을 버리고 다음 타이핑 주기에 재시도). 설정 패널이 마지막 응답 시간을
보여주고 1.5초 초과면 경고해 사용자가 더 작은 모델(`suggestionModelId`)을 고를 수 있다. (GAP-INPUT-06)
**남은 것 (GAP-INPUT-01~06, GAP-LLM-03)**: 실 타이핑 검증(수락 삽입·오버레이 위치·비밀번호 차단),
PyInstaller 번들에 `uiautomation`/`comtypes` 포함 확인, IME preedit 미수집, 중국어 단어 수 과소, macOS/Linux 미지원.
---
## v1.3.3 — 배포본 시작 실패(네이티브 ABI) + gemma4 기본값 (2026-09-18) 🛠️
배포한 1.3.2 설치본이 **시작 즉시 죽었다**: `NODE_MODULE_VERSION 131 ... requires 130`.

373
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "d3ro-voice-monorepo",
"version": "1.4.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "d3ro-voice-monorepo",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"workspaces": [
"apps/desktop",
@ -25,7 +25,7 @@
},
"apps/admin": {
"name": "@d3ro/admin",
"version": "1.4.0",
"version": "1.5.0",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@ -109,7 +109,7 @@
},
"apps/desktop": {
"name": "@d3ro/desktop",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -128,6 +128,7 @@
"electron-log": "^5.2.0",
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"koffi": "^3.3.1",
"lucide-react": "^1.25.0",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
@ -158,7 +159,7 @@
},
"apps/web": {
"name": "@d3ro/web",
"version": "1.4.0",
"version": "1.5.0",
"dependencies": {
"@d3ro/api-client": "*",
"@d3ro/core": "*",
@ -3044,6 +3045,326 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@koromix/koffi-android-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.1.tgz",
"integrity": "sha512-S3yMzUGca5UJRioY7wdBjeO+LuW9mQhrksL+Wd5ri1X7kWMB2IGLzrT1mn1xqx02Vicgvuy5GtkhHuxG5mkzYw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-android-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.1.tgz",
"integrity": "sha512-QaKSyVi37j19CfN2tR3q8lQMosZ+mQIOQXcNQmWXfYDUs9x2CSCxzvMTeQyFyGdAqB58dBluEiy8V5ZMvARSHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.1.tgz",
"integrity": "sha512-xVLQMZzACn6TQJsGp/mBF71mfRyuhmxTBGcQLfJM59YArNN/9+1JV8FFCElmgspffijASZsLwbSEdPwZMNeDhQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-darwin-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.1.tgz",
"integrity": "sha512-+VGqqnBQvR+AbAbRyRDsxK0D6wwAivUURWsyVX8NhR5agf+0+sSAdNMsSy+9DyO7CmioTUVwV+Do+12uWnFrmA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.1.tgz",
"integrity": "sha512-oPkfxEiLiZs1jV6qqw3DzZy3aigzOb9mGbRtK6ENNgoiZ2QdAw55cXUTOMNiAwgbkBwt7SsorFdUnYUuP5b0Gg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-ia32": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.1.tgz",
"integrity": "sha512-uLb93keIlk6TkECtb17z2d5nehEIJ2labIXF0ava3s+9w7VE7QZUw3O2IQVq4t95sIm5rMoE1Y+cFa0aN6444w==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-freebsd-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.1.tgz",
"integrity": "sha512-+Iuh+m5Ln0fjubgw/6bzEkliBcFBIFWWL7IqzmktPy0Q0Bq+XCGtUf+5/KTlodYlTOLv/SE/YTbVkk3c02+E6w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.1.tgz",
"integrity": "sha512-rE6QypE/h4nDHPlzzVPok5W2gL5CopvBYI44lJXO2SauxVAeV86bbf+ucmF/toYENdjF0DBdY7GbloyixwQpPQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.1.tgz",
"integrity": "sha512-Itv2N+TvBFTSdEZdokM4ydjbBwr6GNKd51+UXoh/5HVXALBRnMQek2QhBnG7z6H9+/p2SB4rqBnvhuLQpv4B7g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ia32": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.1.tgz",
"integrity": "sha512-pb1NJ/Ih4MAPB1e2koQOKxGtLuPWKMT3QXxBrPptkkkDz/XpV6+bAA4ys7amXxpA0ybM2u/+VM0gGzdJRThkRg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-loong64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.1.tgz",
"integrity": "sha512-RdS57JuF39tLXS/yYq4KlFnU7CMrn1+x/4v171bji+1aPYOXPxzYDAJVjapv/6tnrESrMTnfWIja3jfzieFmpQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-ppc64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.1.tgz",
"integrity": "sha512-nmOTpQQS5pWG73X01y7K70JQaL+5T/v04i0G4BtXDAxQq3WHmyeJCgmdHhz5W0JVtjHicitSZoothTspAopF9A==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-riscv64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.1.tgz",
"integrity": "sha512-mk0Av5QmK0BPD3OHytOThVhEe4VCTrYv3+em6h75E1rNYiCfixzeh3d5+s5x2vCaby8zzxvNd39sO89Bp1juwg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-linux-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.1.tgz",
"integrity": "sha512-uU5cJNe145TvITqrUWvBmZO4WLNObdZ2Gnm8eNk/OdrhRFG9LB44RUv07aQwPbdo1438j+Cr+tFNnpjTBadZFQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.1.tgz",
"integrity": "sha512-owXmXHI+5kkDyVjqGPfPgIHIzH1swsGY+YNbQ4ozkO23UKzJElAtmQPcmA1pQwiDCeyojHEelgd9+BV58uOIkA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-ia32": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.1.tgz",
"integrity": "sha512-fvIKWycDdc9XL3ATQT0uvt/j82EB3/JWzJU4yqUCJbMotTP8mvo/c1D1Jus4mO+fNGEYM33YuFDkJcQkHJd9Dg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-openbsd-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.1.tgz",
"integrity": "sha512-UYk2xNAK5wEyskLtrgTRt+2lwfBOvgaK8UJDz4B2qm57VvRrqb7ye1hzKFZrRVa8NWRPUk6W/zsqZyFHu4BoLA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-arm64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.1.tgz",
"integrity": "sha512-SZsmAWUKxBHdUR6Z5IJTslOOy0VEFHxoDHMZkn3k0P5TuP/aOZfQYoEa34UwnLI8t2Fmgyhmympth+39tR/XUw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-ia32": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.1.tgz",
"integrity": "sha512-pARisOvxCPkk6Wg1HkwRCnrkJIVS31kDweWLo+bQay/Dr1915s7skVydWpTKS30EcfOh4Jay7B4c0dgamTLBHg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@koromix/koffi-win32-x64": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.1.tgz",
"integrity": "sha512-LYoO19cTIA7xjl+3s4VeGagCAO38VNXZ969DNszb1wTwJUjrHLrAbHFkk8fYkXineM3HJkFwElW0VLmZqXcfRg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"funding": {
"url": "https://liberapay.com/Koromix"
}
},
"node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
@ -10689,6 +11010,38 @@
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/koffi": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.1.tgz",
"integrity": "sha512-FZYfhBfYQr/cmHhZpaQ9rhbKpkjHhxaytn2A0mbPhiD5yV8QzF3SiQwR1w/34rCJ1fw+6qBQSj/ZoXRdC+Xc2Q==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"url": "https://liberapay.com/Koromix"
},
"optionalDependencies": {
"@koromix/koffi-android-arm64": "3.3.1",
"@koromix/koffi-android-x64": "3.3.1",
"@koromix/koffi-darwin-arm64": "3.3.1",
"@koromix/koffi-darwin-x64": "3.3.1",
"@koromix/koffi-freebsd-arm64": "3.3.1",
"@koromix/koffi-freebsd-ia32": "3.3.1",
"@koromix/koffi-freebsd-x64": "3.3.1",
"@koromix/koffi-linux-arm": "3.3.1",
"@koromix/koffi-linux-arm64": "3.3.1",
"@koromix/koffi-linux-ia32": "3.3.1",
"@koromix/koffi-linux-loong64": "3.3.1",
"@koromix/koffi-linux-ppc64": "3.3.1",
"@koromix/koffi-linux-riscv64": "3.3.1",
"@koromix/koffi-linux-x64": "3.3.1",
"@koromix/koffi-openbsd-arm64": "3.3.1",
"@koromix/koffi-openbsd-ia32": "3.3.1",
"@koromix/koffi-openbsd-x64": "3.3.1",
"@koromix/koffi-win32-arm64": "3.3.1",
"@koromix/koffi-win32-ia32": "3.3.1",
"@koromix/koffi-win32-x64": "3.3.1"
}
},
"node_modules/layout-base": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
@ -16861,7 +17214,7 @@
},
"packages/api-client": {
"name": "@d3ro/api-client",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*",
@ -16878,7 +17231,7 @@
},
"packages/core": {
"name": "@d3ro/core",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"docx": "^9.6.1"
@ -16889,7 +17242,7 @@
},
"packages/i18n": {
"name": "@d3ro/i18n",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"devDependencies": {
"@types/react": "^19.0.0"
@ -16900,7 +17253,7 @@
},
"packages/ui": {
"name": "@d3ro/ui",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"dependencies": {
"@d3ro/core": "*"
@ -16920,7 +17273,7 @@
},
"packages/ui-native": {
"name": "@d3ro/ui-native",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"devDependencies": {
"@types/react": "*"

View file

@ -1,6 +1,6 @@
{
"name": "d3ro-voice-monorepo",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO",

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/api-client",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT",

View file

@ -0,0 +1,123 @@
// packages/core/__tests__/personal-graph.test.ts
// 개인 그래프 순수 함수 테스트 — 용어 추출/문장 분해/관계/순위.
import { describe, expect, it } from 'vitest'
import {
SHARES_TERMS_MIN_SIMILARITY,
buildSequenceEdges,
continuationsFrom,
extractTerms,
jaccard,
rankRelated,
splitSentences
} from '../src/personal-graph'
describe('extractTerms', () => {
it('소문자화하고 조사/불용어를 걷어낸다', () => {
const terms = extractTerms('오늘 회의에서 논의한 내용을 정리해서 공유드립니다')
expect(terms).toContain('회의에서')
expect(terms).toContain('정리해서')
expect(terms).not.toContain('그리고')
})
it('영어 불용어를 제거한다', () => {
const terms = extractTerms('Thanks for the quick turnaround on the report')
expect(terms).toContain('quick')
expect(terms).toContain('turnaround')
expect(terms).toContain('report')
expect(terms).not.toContain('the')
})
it('빈도 순으로 상위 limit 개만 돌려준다', () => {
const terms = extractTerms('배포 배포 배포 점검 점검 테스트', 2)
expect(terms).toEqual(['배포', '점검'])
})
it('1자 토큰은 버린다', () => {
expect(extractTerms('a b c 배포')).toEqual(['배포'])
})
})
describe('splitSentences', () => {
it('종결 부호와 개행으로 나누고 짧은 조각은 버린다', () => {
const sentences = splitSentences('오늘 회의는 여기서 끝. 짧음. 내일 일정을 공유드릴게요!')
expect(sentences).toContain('오늘 회의는 여기서 끝')
expect(sentences).toContain('내일 일정을 공유드릴게요')
expect(sentences).not.toContain('짧음')
})
})
describe('buildSequenceEdges', () => {
it('인접한 문장 쌍만 만든다', () => {
const edges = buildSequenceEdges(['A 문장입니다', 'B 문장입니다', 'C 문장입니다'])
expect(edges).toEqual([
{ from: 'A 문장입니다', to: 'B 문장입니다' },
{ from: 'B 문장입니다', to: 'C 문장입니다' }
])
})
it('같은 문장이 반복되면 만들지 않는다', () => {
expect(buildSequenceEdges(['같은 문장입니다', '같은 문장입니다'])).toEqual([])
})
})
describe('jaccard', () => {
it('겹침 비율을 계산한다', () => {
expect(jaccard(['a', 'b'], ['a', 'b'])).toBe(1)
expect(jaccard(['a', 'b'], ['c', 'd'])).toBe(0)
expect(jaccard(['a', 'b', 'c'], ['a', 'b', 'd'])).toBeCloseTo(0.5, 5)
})
it('빈 집합은 0 이다', () => {
expect(jaccard([], ['a'])).toBe(0)
})
})
describe('continuationsFrom', () => {
it('꼬리 뒤에 실제로 이어 쓴 텍스트만 뽑는다', () => {
const out = continuationsFrom(
['오늘 회의에서 논의한 내용을 정리해서 공유드립니다', '관계 없는 문장입니다'],
'논의한 내용을'
)
expect(out).toEqual(['정리해서 공유드립니다'])
})
it('꼬리가 너무 짧으면 아무것도 만들지 않는다', () => {
expect(continuationsFrom(['아무 문장'], '아무')).toEqual([])
})
it('길이를 제한하고 중복을 없앤다', () => {
const out = continuationsFrom(
['접두 뒤에 이어지는 아주 긴 문장이 계속 이어집니다 그리고 더 이어집니다', '접두 뒤에 이어지는 아주 긴 문장이 계속 이어집니다'],
'접두 뒤에',
2,
20
)
expect(out).toHaveLength(1)
expect(out[0].length).toBeLessThanOrEqual(20)
})
})
describe('rankRelated', () => {
it('관계 가중치와 용어 유사도, 최근성을 함께 본다', () => {
const now = Date.now()
const ranked = rankRelated(
['회의', '일정'],
[
{ text: '회의 일정을 공유드립니다', terms: ['회의', '일정'], weight: 0, lastUsedAt: now },
{ text: '전혀 상관 없는 문장입니다', terms: ['바다', '산'], weight: 0, lastUsedAt: now },
{ text: '강하게 이어지는 문장입니다', terms: [], weight: 10, lastUsedAt: null }
],
3
)
// 관련도 순: 강한 엣지 → 용어 유사 + 최근성 → 무관(마지막)
expect(ranked[0]).toBe('강하게 이어지는 문장입니다')
expect(ranked[1]).toBe('회의 일정을 공유드립니다')
expect(ranked[2]).toBe('전혀 상관 없는 문장입니다')
})
it('용어 유사도 임계값 상수는 0과 1 사이이다', () => {
expect(SHARES_TERMS_MIN_SIMILARITY).toBeGreaterThan(0)
expect(SHARES_TERMS_MIN_SIMILARITY).toBeLessThan(1)
})
})

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/core",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT",
@ -34,6 +34,10 @@
"types": "./src/constants.ts",
"default": "./src/constants.ts"
},
"./input-intelligence": {
"types": "./src/input-intelligence.ts",
"default": "./src/input-intelligence.ts"
},
"./entitlement": {
"types": "./src/entitlement.ts",
"default": "./src/entitlement.ts"

View file

@ -192,6 +192,20 @@ export enum ErrorCode {
QuotaExceeded = 861,
TierRequired = 862,
// === Input Intelligence (970-989) ===
/** UIA 브리지(사이드카 /uia/focus)를 쓸 수 없음 */
UiaBridgeUnavailable = 970,
/** 입력 텔레메트리 수집 시작 실패 */
InputTelemetryStartFailed = 971,
/** 입력 텔레메트리 설정/저장 실패 */
InputTelemetryConfigFailed = 972,
/** 다음 문장 제안 생성 실패 */
SuggestionGenerationFailed = 973,
/** 제안 접수/삽입 실패 */
SuggestionAcceptFailed = 974,
/** 제안 기능 비활성 상태에서의 호출 */
SuggestionDisabled = 975,
// === System / Window (900-999) ===
WindowCreationFailed = 900,
WindowNotFound = 901,

View file

@ -4,6 +4,8 @@
export * from './types'
export * from './keybinding'
export * from './input-intelligence'
export * from './personal-graph'
export * from './errors'
export * from './ipc-channels'
export * from './constants'

File diff suppressed because it is too large Load diff

View file

@ -527,6 +527,50 @@ export const IPC_CHANNELS = {
GET_SUBSCRIPTION_STATUS: 'payment:getSubscriptionStatus',
CANCEL_SUBSCRIPTION: 'payment:cancelSubscription',
},
// ── Input telemetry (수집·동의·리포트) ──
INPUT_TELEMETRY: {
GET_STATE: 'inputTelemetry:getState',
SET_ENABLED: 'inputTelemetry:setEnabled',
SET_PAUSED: 'inputTelemetry:setPaused',
GET_SUMMARY: 'inputTelemetry:getSummary',
GET_PRIVACY_RECEIPT: 'inputTelemetry:getPrivacyReceipt',
GET_PHRASES: 'inputTelemetry:getPhrases',
DELETE_PHRASE: 'inputTelemetry:deletePhrase',
CLEAR_ALL: 'inputTelemetry:clearAll',
GET_GRAPH: 'inputTelemetry:getGraph',
QUERY_GRAPH: 'inputTelemetry:queryGraph',
// Main → Renderer events
ACTIVITY: 'inputTelemetry:activity',
STATE_CHANGED: 'inputTelemetry:stateChanged',
},
// ── Next-sentence suggestion (ghost text) ──
SUGGESTION: {
GET_STATE: 'suggestion:getState',
SET_CONFIG: 'suggestion:setConfig',
REQUEST_NOW: 'suggestion:requestNow',
ACCEPT: 'suggestion:accept',
NEXT: 'suggestion:next',
PREV: 'suggestion:prev',
DISMISS: 'suggestion:dismiss',
GET_HISTORY: 'suggestion:getHistory',
// Main → Renderer events
UPDATED: 'suggestion:updated',
CLEARED: 'suggestion:cleared',
STATE_CHANGED: 'suggestion:stateChanged',
},
// ── Popup Internal Channels (SuggestionOverlay) ──
POPUP_SUGGESTION: {
SHOW: 'suggestionPopup:show',
UPDATE: 'suggestionPopup:update',
HIDE: 'suggestionPopup:hide',
ACCEPT: 'suggestionPopup:accept',
DISMISS: 'suggestionPopup:dismiss',
ACCEPTED: 'suggestionPopup:accepted',
DISMISSED: 'suggestionPopup:dismissed',
},
} as const
// 타입 유틸리티: 채널명 유니온 추출

View file

@ -677,9 +677,13 @@ export type KeyBindingActionId =
| 'caption'
| 'history-popup'
| 'command-popup'
| 'suggestion-accept'
| 'suggestion-next'
| 'suggestion-prev'
| 'suggestion-dismiss'
/** 액션 그룹 (설정 화면 섹션) */
export type KeyBindingActionGroup = 'voice' | 'window'
export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
export interface KeyBindingActionSpec {
id: KeyBindingActionId
@ -771,6 +775,44 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
holdMode: false,
doublePress: false,
defaultBindings: [kb(0x43 /* C */, { ctrl: true, shift: true })]
},
{
id: 'suggestion-accept',
group: 'input',
labelKey: 'keybinding.action.suggestionAccept',
descriptionKey: 'keybinding.action.suggestionAccept.desc',
holdMode: false,
doublePress: false,
// 사용자 요청으로 Ctrl+Alt+화살표 계열로 통일했다:
// 오른쪽 수락 / 아래 다음 후보 / 위 이전 후보 / 왼쪽 닫기.
defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })]
},
{
id: 'suggestion-next',
group: 'input',
labelKey: 'keybinding.action.suggestionNext',
descriptionKey: 'keybinding.action.suggestionNext.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.ArrowDown, { ctrl: true, alt: true })]
},
{
id: 'suggestion-prev',
group: 'input',
labelKey: 'keybinding.action.suggestionPrev',
descriptionKey: 'keybinding.action.suggestionPrev.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })]
},
{
id: 'suggestion-dismiss',
group: 'input',
labelKey: 'keybinding.action.suggestionDismiss',
descriptionKey: 'keybinding.action.suggestionDismiss.desc',
holdMode: false,
doublePress: false,
defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
}
])
@ -1035,6 +1077,67 @@ export function detectBindingConflicts(
return conflicts
}
/** 저장된 전체 단축키 설정에서 사용 불가·중복 바인딩을 수집한 결과. */
export interface KeyBindingAuditIssue {
kind: 'invalid' | 'conflict'
actionId: KeyBindingActionId
bindingIndex: number
binding: KeyBinding
reasonKey: string | null
conflictActionIds: KeyBindingActionId[]
}
/**
* 전체 바인딩 맵을 한 번에 검수한다.
*
* hold/double-press 예외는 detectBindingConflicts()의 기존 계약을 그대로 따른다.
*/
export function auditKeyBindingMap(map: Readonly<KeyBindingMap>): KeyBindingAuditIssue[] {
const issues: KeyBindingAuditIssue[] = []
const reportedPairs = new Set<string>()
for (const action of KEYBINDING_ACTIONS) {
const bindings = map[action.id] ?? []
for (const [bindingIndex, binding] of bindings.entries()) {
const validation = validateBinding(binding)
if (!validation.valid) {
issues.push({
kind: 'invalid',
actionId: action.id,
bindingIndex,
binding: { ...binding },
reasonKey: validation.reasonKey,
conflictActionIds: []
})
}
const conflictActionIds = detectBindingConflicts(action.id, binding, map)
.map((conflict) => conflict.actionId)
.filter((otherActionId) => action.id.localeCompare(otherActionId) < 0)
.filter((otherActionId) => {
const pairKey = `${bindingKey(binding)}:${action.id}:${otherActionId}`
if (reportedPairs.has(pairKey)) return false
reportedPairs.add(pairKey)
return true
})
.sort((a, b) => a.localeCompare(b))
if (conflictActionIds.length > 0) {
issues.push({
kind: 'conflict',
actionId: action.id,
bindingIndex,
binding: { ...binding },
reasonKey: null,
conflictActionIds
})
}
}
}
return issues
}
// ============================================================
// 검색 (드롭다운 필터)
// ============================================================

View file

@ -0,0 +1,222 @@
// packages/core — 개인 그래프(관계형 개인화) 정본.
//
// 왜 그래프인가:
// n-gram 문자열 조회(1단계)는 "같은 꼬리 뒤에 이어 쓴 문장" 만 찾는다. 하지만 사용자의
// 글은 관계로 이어진다 — 어떤 문장은 늘 다른 문장 뒤에 오고("follows"), 어떤 문장들은
// 같은 용어를 공유한다("shares_terms"). 그 관계를 저장해 두면 접두가 조금 달라도
// 관련 문맥을 끌어올 수 있다.
//
// 전부 순수 함수로 두어 LLM/DB 없이 검증 가능하게 한다.
import { countWords } from './input-intelligence'
import type { PhraseSource } from './input-intelligence'
// 렌더러/프리로드도 같은 계약을 쓴다 (main 모듈을 import 할 수 없다).
export type { PhraseSource }
/** 그래프 노드 = 사용자의 문장 하나. */
export interface GraphNode {
text: string
terms: string[]
source: PhraseSource
appName: string | null
count: number
lastUsedAt: number | null
}
export type GraphEdgeKind = 'follows' | 'shares_terms'
/** 그래프 엣지 = 두 문장의 관계. */
export interface GraphEdge {
from: string
to: string
kind: GraphEdgeKind
weight: number
}
/** 그래프 통계 (지식베이스 그래프 탭). */
export interface PersonalGraphStats {
nodes: number
followsEdges: number
sharesTermsEdges: number
/** 가장 강한 follows 엣지 (A → B) */
topEdges: Array<{ from: string; to: string; kind: string; weight: number }>
/** 최근 노드 */
recentNodes: Array<{ text: string; terms: string[]; count: number; appName: string | null }>
}
/** 특정 텍스트 주변 그래프 조회 결과. */
export interface PersonalGraphQuery {
anchors: Array<{ text: string; terms: string[]; count: number }>
neighbors: Array<{ text: string; kind: string; weight: number }>
}
export interface GraphContext {
/** 접두 꼬리 뒤에 실제로 이어 쓴 텍스트 (가장 강한 신호) */
continuations: string[]
/** 관계로 끌어온 관련 문장 (follows 우선, 그다음 용어 공유) */
related: string[]
}
/** 용어 추출에서 제외할 불용어 (한/영 최소 집합). */
const STOPWORDS: ReadonlySet<string> = new Set([
'그리고',
'그러나',
'하지만',
'그래서',
'저는',
'제가',
'이거',
'그거',
'저거',
'있습니다',
'합니다',
'입니다',
'the',
'and',
'for',
'with',
'that',
'this',
'from',
'have',
'will',
'your',
'you',
'are',
'was',
'were',
'not',
'but'
])
/** 최소 길이 (1~2자 토큰은 노이즈가 많다). */
const MIN_TERM_CHARS = 2
/**
* 문장에서 비교용 용어를 뽑는다.
*
* 소문자화 → 문자/숫자 경계로 분리 → 불용어/짧은 토큰 제거 → 빈도 순.
*/
export function extractTerms(text: string, limit = 8): string[] {
const tokens = text
.toLowerCase()
.split(/[^0-9a-z\uac00-\ud7a3\u3040-\u30ff\u4e00-\u9fff]+/u)
.filter((token) => token.length >= MIN_TERM_CHARS)
.filter((token) => !STOPWORDS.has(token))
const frequency = new Map<string, number>()
for (const token of tokens) {
frequency.set(token, (frequency.get(token) ?? 0) + 1)
}
return [...frequency.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([token]) => token)
}
/** 문장 단위로 쪼갠다 (종결 부호 + 개행). 2단어 이상만 남긴다. */
export function splitSentences(text: string): string[] {
return text
.split(/[.!?。!?…\n]+/u)
.map((segment) => segment.replace(/\s+/gu, ' ').trim())
.filter((segment) => segment.length >= 4 && countWords(segment) >= 2)
}
/** 한 텍스트 안에서 인접한 문장 쌍 (follows 엣지의 근거). */
export function buildSequenceEdges(sentences: readonly string[]): Array<{ from: string; to: string }> {
const edges: Array<{ from: string; to: string }> = []
for (let index = 0; index + 1 < sentences.length; index += 1) {
const from = sentences[index]
const to = sentences[index + 1]
if (from === to) continue
edges.push({ from, to })
}
return edges
}
/** 자카드 유사도 — 두 용어 집합이 얼마나 겹치는가 (0~1). */
export function jaccard(a: readonly string[], b: readonly string[]): number {
if (a.length === 0 || b.length === 0) return 0
const setA = new Set(a)
const setB = new Set(b)
let intersection = 0
for (const term of setA) {
if (setB.has(term)) intersection += 1
}
const union = setA.size + setB.size - intersection
return union === 0 ? 0 : intersection / union
}
/** 용어 공유 엣지로 볼 최소 자카드. */
export const SHARES_TERMS_MIN_SIMILARITY = 0.34
/**
* 접두 꼬리 뒤에 실제로 이어 쓴 텍스트를 뽑는다.
*
* `texts` 는 꼬리를 포함하는 후보 문장들(최신순)이다.
*/
export function continuationsFrom(
texts: readonly string[],
tail: string,
limit = 3,
maxChars = 60
): string[] {
const needle = tail.trim()
if (needle.length < 4) return []
const out: string[] = []
for (const text of texts) {
const index = text.indexOf(needle)
if (index < 0) continue
const continuation = text
.slice(index + needle.length)
.replace(/\s+/gu, ' ')
.trim()
if (continuation.length < 3) continue
const clipped = continuation.slice(0, maxChars)
if (out.includes(clipped)) continue
out.push(clipped)
if (out.length >= limit) break
}
return out
}
export interface RelatedCandidate {
text: string
terms: readonly string[]
/** 엣지 가중치 합 (follows 가중치가 더 크게 반영된다) */
weight: number
lastUsedAt: number | null
}
/**
* 관련 문장 순위를 매긴다.
*
* 점수 = 엣지 가중치 + 용어 유사도 + 최근성 보너스.
* 이미 접두에 포함된 문장(후보 자신)은 제외한다.
*/
export function rankRelated(
anchorTerms: readonly string[],
candidates: readonly RelatedCandidate[],
limit = 4
): string[] {
const scored = candidates
.map((candidate) => {
const similarity = jaccard(anchorTerms, candidate.terms)
const recency = candidate.lastUsedAt
? Math.max(0, 1 - (Date.now() - candidate.lastUsedAt) / (7 * 86400000))
: 0
return { text: candidate.text, score: candidate.weight * 1.0 + similarity * 2.0 + recency * 0.5 }
})
.sort((a, b) => b.score - a.score)
const out: string[] = []
for (const item of scored) {
if (out.includes(item.text)) continue
out.push(item.text)
if (out.length >= limit) break
}
return out
}

View file

@ -501,6 +501,55 @@ export interface AppConfig {
updateDeviceId: string
/** 사용자가 건너뛴 버전 (강제 업데이트에는 적용되지 않음) */
skippedUpdateVersion: string | null
/**
* 입력 텔레메트리 (키/마우스 집계) 수집.
*
* 기본값은 false — 옵트인. 수집 항목은 카운터/거리/집계뿐이고
* 키 내용은 저장하지 않는다 (ActivityWatch aw-watcher-input 과 동일한
* 데이터 최소화 정책).
*/
inputTelemetryEnabled: boolean
/** 동의는 유지한 채 수집만 시 중단 */
inputTelemetryPaused: boolean
/**
* 타이한 실제 텍스트 학습 (UIA 로 포커스 입력창을 읽어 개인 문구 생성).
*
* 기본값 false — 텔레메트리보다 한 단계 더 강한 동의가 필요하다.
* 비밀번호 필드(IsPassword)는 항상 제외된다.
*/
inputLearnTypedText: boolean
/** 수집/학습 제외 (실행 파일명, 예: 'KeePassXC.exe') */
inputExcludedApps: string[]
/** 다음 문장 제안 (ghost text) 활성 */
suggestionEnabled: boolean
/** 제안 전용 Ollama 모델 (null → llmModelId 사용) */
suggestionModelId: string | null
/** 타이핑 정지 후 요청까지 지연 (ms) */
suggestionTriggerDelayMs: number
/** 제안 요청 최소 접두 길이 (문자) */
suggestionMinPrefixChars: number
/** 분당 최대 요청 수 (과금/부하 방지) */
suggestionMaxRequestsPerMinute: number
/** 일일 요청 예산 */
suggestionDailyBudget: number
/** 오버레이 클릭 허용 (false → 완전 클릭 통과) */
suggestionOverlayInteractive: boolean
/**
* 제안 응답 제한 (ms).
*
* 실측(gemma4:e4b, 이 개발 머신): 콜드 첫 요청 24.7초 / 워 4.9초(32토큰).
* 느린 하드웨어에서 생성이 UI 를 붙잡지 않도록 상한을 다.
*/
suggestionRequestTimeoutMs: number
/**
* 제안/텔레메트리 튜닝 값의 개정판.
*
* electron-store 는 기본값을 설정 파일에 함께 써버리므로, 기본값을 바꿔도
* 기존 사용자 파일에는 옛 값이 굳어 있다(실측: 트리거 1000ms 가 남아
* 조합 게이트 기준이 2000ms 로 계산돼 제안이 영영 안 떴다). 기본값을
* 바꿀 때마다 ConfigService 의 개정판을 올려 1회 마이그레이션한다.
*/
suggestionTuningRevision: number
}
export interface ConfigGetParams {

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/i18n",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT",

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Gib eine Kombination (z. B. Ctrl+Shift+Q) oder eine einzelne Taste (z. B. F5) ein",
"keybinding.ui.recordAgain": "Erneut eingeben",
"keybinding.ui.saveFailed": "Das Tastenkürzel konnte nicht gespeichert werden",
"keybinding.ui.globalEnabled": "Globale Tastenkürzel aktivieren"
"keybinding.ui.globalEnabled": "Globale Tastenkürzel aktivieren",
"input.tab": "Eingabe",
"input.consent.title": "Erfassung von Eingabedaten",
"input.consent.description": "Optional. Wir zählen Tastenanschläge und Klicks und messen die Mausstrecke, um dein Tippverhalten zu zeigen. Welche Tasten du drückst, wird nie gespeichert.",
"input.consent.collect": "Tastatur- und Mausstatistik erfassen",
"input.consent.running": "Erfassung läuft.",
"input.consent.stopped": "Erfassung ist aus. Es wird nichts aufgezeichnet.",
"input.consent.pause": "Erfassung pausieren",
"input.consent.learnText": "Den von mir getippten Text lernen",
"input.consent.learnTextHint": "Liest das fokussierte Textfeld, um deine Formulierungen zu lernen. Passwortfelder werden immer übersprungen.",
"input.consent.excludedApps": "Ausgeschlossene Apps",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Kommagetrennte Programmnamen. In diesen Apps wird nichts gelesen oder gezählt.",
"input.consent.clearAll": "Erfasste Daten löschen",
"input.suggestion.title": "Vorschläge für den nächsten Satz",
"input.suggestion.description": "Sagt den Satz voraus, den du schreiben willst, und zeigt ihn neben dem Cursor. Übernimm ihn zum Einfügen.",
"input.suggestion.enabled": "Vorschläge beim Tippen anzeigen",
"input.suggestion.modelReady": "Lokales Modell bereit.",
"input.suggestion.modelMissing": "Lokales Modell läuft nicht — Vorschläge bleiben aus.",
"input.suggestion.delay": "Verzögerung (ms)",
"input.suggestion.minPrefix": "Mindestzeichen",
"input.suggestion.modelDefault": "Standardmodell verwenden",
"input.suggestion.overlayInteractive": "Klick auf das Vorschlagfeld erlauben",
"input.suggestion.overlayVisible": "Vorschlag wird angezeigt",
"input.suggestion.keyHint": "Übernehmen, wechseln und schließen sind globale Tastenkürzel — ändere sie unter Einstellungen > Tastenkürzel.",
"input.suggestion.requestNow": "Jetzt vorschlagen",
"input.suggestion.usage": "Heute {{requests}} von {{budget}} Anfragen genutzt",
"input.suggestion.lastSkip": "Zuletzt übersprungen: {{reason}}",
"input.insights.title": "Letzte {{days}} Tage",
"input.insights.keystrokes": "Tastenanschläge",
"input.insights.clicks": "Klicks",
"input.insights.words": "Getippte Wörter",
"input.insights.sentences": "Sätze",
"input.insights.mouseDistance": "Mausstrecke",
"input.insights.perDayMeters": " m/Tag",
"input.insights.phrases": "Eigene Phrasen",
"input.insights.empty": "Noch keine Aktivität erfasst.",
"input.insights.topApps": "Wo du am meisten tippst",
"input.insights.appRow": "{{keystrokes}} Tasten · {{clicks}} Klicks",
"input.insights.privacyNote": "Auf diesem Gerät werden nur Zähler und Strecken gespeichert. Tasteninhalte werden nie gespeichert.",
"input.phrases.title": "Eigene Phrasen",
"input.phrases.description": "Sätze, die aus deinem Tippen und Diktieren gelernt wurden, um deinen Stil zu treffen.",
"input.phrases.empty": "Noch nichts gelernt.",
"input.phrases.delete": "Phrase löschen",
"input.feedback.excludedSaved": "Ausschlüsse gespeichert.",
"input.feedback.cleared": "Erfasste Daten gelöscht.",
"popup.suggestion.hintAccept": "Übernehmen",
"popup.suggestion.hintNext": "Weiter",
"popup.suggestion.hintDismiss": "Schließen",
"popup.suggestion.loading": "Wird erzeugt…",
"keybinding.ui.sectionInput": "Eingabevorschläge",
"keybinding.action.suggestionAccept": "Vorschlag übernehmen",
"keybinding.action.suggestionAccept.desc": "Fügt den vorgeschlagenen Satz in die aktive App ein.",
"keybinding.action.suggestionNext": "Nächster Vorschlag",
"keybinding.action.suggestionNext.desc": "Wechselt zum nächsten Kandidaten.",
"keybinding.action.suggestionDismiss": "Vorschlag schließen",
"keybinding.action.suggestionDismiss.desc": "Blendet den aktuellen Vorschlag aus.",
"input.suggestion.timeout": "Antwortlimit (ms)",
"input.suggestion.latency": "Letzte Antwort: {{ms}} ms",
"input.suggestion.slowWarning": "Langsamer als das Tippen — kleineres Modell probieren.",
"input.diagnostics.title": "Live-Prüfung",
"input.diagnostics.app": "Fokussierte App: {{app}}",
"input.diagnostics.unknownApp": "(unbekannt)",
"input.diagnostics.noSnapshot": "Noch kein Feld gelesen — in ein Textfeld klicken und tippen.",
"input.diagnostics.readable": "Feld lesbar ({{source}}, {{length}} Zeichen) — Vorschläge können laufen.",
"input.diagnostics.notReadable": "Diese App gibt ihr Textfeld nicht preis — Eingaben sind nicht lesbar, daher kein Vorschlag.",
"input.diagnostics.password": "Passwortfeld — Lesen ist konstruktiv blockiert.",
"input.diagnostics.caretFallback": "Cursorposition unbekannt; Dokumentende wird verwendet.",
"input.diagnostics.composing": "IME-Komposition läuft — Vorschlag erscheint etwas später.",
"input.suggestion.onScreenLabel": "Overlay-Status",
"input.suggestion.onScreen": "Sichtbar",
"input.suggestion.offScreen": "Ausgeblendet",
"input.suggestion.generating": "Wird erzeugt…",
"input.kbView.knowledge": "Wissensbasis",
"input.kbView.insights": "Eingabe-Analyse",
"input.insights.tabs.overview": "Überblick",
"input.insights.tabs.keyboard": "Tastatur",
"input.insights.tabs.mouse": "Maus",
"input.insights.tabs.apps": "Apps",
"input.insights.tabs.phrases": "Phrasen · Vorschläge",
"input.insights.chars": "Getippte Zeichen",
"input.insights.wordChars": "Zeichen",
"input.insights.backspaces": "Rücktaste",
"input.insights.shortcuts": "Tastenkürzel",
"input.insights.doubleClicks": "Doppelklicks",
"input.insights.scrollTicks": "Scrollen",
"input.insights.activeMinutes": "Aktive Zeit",
"input.insights.activeDays": "Erfasste Tage",
"input.insights.streak": "Längste Serie",
"input.insights.peakDay": "Stärkster Tag",
"input.insights.perDayAverage": "Tagesdurchschnitt",
"input.insights.mouseDistanceTotal": "Mausstrecke gesamt",
"input.insights.hourlyTitle": "Nach Stunde (0-23)",
"input.insights.dailyTitle": "Täglicher Verlauf",
"input.insights.wordsDailyTitle": "Wörter pro Tag",
"input.insights.distanceDailyTitle": "Mausstrecke pro Tag",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Vorschläge",
"input.insights.suggestionsTotal": "Erzeugt",
"input.insights.suggestionsAccepted": "Übernommen",
"input.insights.acceptRate": "Übernahmerate",
"input.insights.avgLatency": "Ø Antwortzeit",
"input.insights.suggestionHistory": "Letzte Vorschläge",
"input.insights.accepted": "übernommen",
"input.insights.notAccepted": "nicht übernommen",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Zeitraum",
"input.insights.rangeDays": "{{days}} Tage",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Detaillierte Statistiken findest du unter Wissensbasis > Eingabe-Analyse.",
"input.chart.max": "max. {{value}}",
"input.chart.noData": "Noch keine Daten.",
"input.unit.perDay": "/Tag",
"input.unit.perDayMeters": "m/Tag",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Modell wird vorbereitet…",
"popup.suggestion.hintGenerating": "Wird erzeugt…",
"keybinding.action.suggestionPrev": "Vorheriger Vorschlag",
"keybinding.action.suggestionPrev.desc": "Zum vorherigen Kandidaten wechseln.",
"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",
"input.graph.followsEdges": "Fortsetzungs-Kanten",
"input.graph.sharesEdges": "Begriffs-Kanten",
"input.graph.topEdges": "Stärkste Verbindungen",
"input.graph.recentNodes": "Neueste Knoten",
"input.graph.searchLabel": "Satz suchen",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Suchen",
"input.graph.neighbors": "Verbundene Sätze",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "folgt",
"input.graph.kindShares": "teilt Begriffe",
"popup.suggestion.generating": "Wird erzeugt… ({{seconds}} s)",
"keybinding.ui.auditClear": "Keine Kurzbefehls-Konflikte oder ungültigen Einstellungen.",
"keybinding.ui.auditIssues": "{{count}} Kurzbefehlsprobleme",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} steht in Konflikt mit {{conflicts}}",
"keybinding.ui.auditUnknown": "Unbekanntes Problem",
"input.flow.title": "Wahrscheinliche Fokuszeiten",
"input.flow.empty": "Noch nicht genügend stündliche Eingabedaten.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Wert {{score}} · {{minutes}} aktive Min./Tag · {{friction}} % Bearbeitungen",
"input.friction.title": "Bearbeitungsreibung",
"input.friction.value": "{{count}} pro 100 Zeichen",
"input.friction.band.steady": "Stabil",
"input.friction.band.watch": "Beobachten",
"input.friction.band.high": "Hoch",
"input.appQuality.title": "Vorschlagsqualität nach App",
"input.appQuality.empty": "Noch kein Vorschlagsverlauf pro App.",
"input.appQuality.row": "{{accepted}}/{{total}} angenommen · {{rate}} % · {{latency}} ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Gemeinsam",
"input.privacy.title": "Lokaler Datenbeleg",
"input.privacy.localOnly": "Diese Daten bleiben auf diesem Gerät.",
"input.privacy.rawKeys": "Rohe einzelne Tastenereignisse und Tastencodes werden nicht gespeichert. Gelernte Texteingabeproben werden unten separat aufgeführt.",
"input.privacy.activityRetention": "Aufbewahrung Aktivitäten",
"input.privacy.suggestionRetention": "Aufbewahrung Vorschläge",
"input.privacy.learnedRetention": "Aufbewahrung gelernter Texte",
"input.privacy.activityBuckets": "Aktivitätsblöcke",
"input.privacy.typingSamples": "Eingabeproben",
"input.privacy.personalPhrases": "Persönliche Phrasen",
"input.privacy.suggestions": "Vorschlagsaufzeichnungen",
"input.privacy.days": "{{days}} Tage",
"input.privacy.untilDeleted": "Bis zur Löschung",
"input.exclusion.recommendation": "{{app}} hatte {{samples}} wiederholt unlesbare Eingaben ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "wiederholt unlesbar",
"input.exclusion.reason.repeated-empty": "wiederholt leer",
"input.exclusion.addButton": "{{app}} zu Ausschlüssen hinzufügen",
"input.feedback.recommendationSaved": "{{app}} wurde zu Ausschlüssen hinzugefügt.",
"popup.suggestion.sourceModel": "Lokales Modell",
"popup.suggestion.sourceMemory": "Lokaler Speicher",
"popup.suggestion.continuations": "Fortsetzungen",
"popup.suggestion.related": "verwandt",
"popup.suggestion.phrases": "Phrasen",
"popup.suggestion.appPhrases": "App-Phrasen",
"input.privacy.typingSamplesRetention": "Aufbewahrung von Texteingabeproben",
"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."
}

View file

@ -1707,5 +1707,192 @@
"keybinding.ui.recordHint": "Enter a combination (e.g. Ctrl+Shift+Q) or a single key (e.g. F5)",
"keybinding.ui.recordAgain": "Clear",
"keybinding.ui.saveFailed": "The shortcut could not be saved",
"keybinding.ui.globalEnabled": "Enable global shortcuts"
"keybinding.ui.globalEnabled": "Enable global shortcuts",
"input.tab": "Input",
"input.consent.title": "Input data collection",
"input.consent.description": "Optional. We count keystrokes and clicks and measure how far the mouse travels so you can see your own typing patterns. Which keys you press is never stored.",
"input.consent.collect": "Collect keyboard and mouse statistics",
"input.consent.running": "Collecting now.",
"input.consent.stopped": "Collection is off. Nothing is recorded.",
"input.consent.pause": "Pause collection",
"input.consent.learnText": "Learn the text I type",
"input.consent.learnTextHint": "Reads the focused text field to learn your phrasing. Password fields are always skipped.",
"input.consent.excludedApps": "Excluded apps",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Comma-separated executable names. Nothing is read or counted in these apps.",
"input.consent.clearAll": "Delete collected data",
"input.suggestion.title": "Next-sentence suggestions",
"input.suggestion.description": "Predicts the sentence you are about to write and shows it next to your caret. Accept to insert it.",
"input.suggestion.enabled": "Show suggestions while typing",
"input.suggestion.modelReady": "Local model ready.",
"input.suggestion.modelMissing": "Local model is not running — suggestions stay off.",
"input.suggestion.delay": "Delay (ms)",
"input.suggestion.minPrefix": "Min. characters",
"input.suggestion.modelDefault": "Use default model",
"input.suggestion.overlayInteractive": "Allow clicking the suggestion box",
"input.suggestion.overlayVisible": "Suggestion is on screen",
"input.suggestion.keyHint": "Accept, cycle and dismiss are global key bindings — change them in Settings > Shortcuts.",
"input.suggestion.requestNow": "Suggest now",
"input.suggestion.usage": "{{requests}} of {{budget}} requests used today",
"input.suggestion.lastSkip": "Last skipped: {{reason}}",
"input.insights.title": "Last {{days}} days",
"input.insights.keystrokes": "Keystrokes",
"input.insights.clicks": "Clicks",
"input.insights.words": "Words typed",
"input.insights.sentences": "Sentences",
"input.insights.mouseDistance": "Mouse travel",
"input.insights.perDayMeters": " m/day",
"input.insights.phrases": "Personal phrases",
"input.insights.empty": "No activity collected yet.",
"input.insights.topApps": "Where you type most",
"input.insights.appRow": "{{keystrokes}} keys · {{clicks}} clicks",
"input.insights.privacyNote": "Only counters and distances are stored on this device. Key contents are never saved.",
"input.phrases.title": "Personal phrases",
"input.phrases.description": "Sentences learned from your typing and dictation, used to match your wording.",
"input.phrases.empty": "Nothing learned yet.",
"input.phrases.delete": "Delete phrase",
"input.feedback.excludedSaved": "Exclusions saved.",
"input.feedback.cleared": "Collected data deleted.",
"popup.suggestion.hintAccept": "Accept",
"popup.suggestion.hintNext": "Next",
"popup.suggestion.hintDismiss": "Dismiss",
"popup.suggestion.loading": "Generating…",
"keybinding.ui.sectionInput": "Input suggestions",
"keybinding.action.suggestionAccept": "Accept suggestion",
"keybinding.action.suggestionAccept.desc": "Insert the suggested sentence into the focused app.",
"keybinding.action.suggestionNext": "Next suggestion",
"keybinding.action.suggestionNext.desc": "Cycle to the next suggestion candidate.",
"keybinding.action.suggestionDismiss": "Dismiss suggestion",
"keybinding.action.suggestionDismiss.desc": "Hide the current suggestion.",
"input.suggestion.timeout": "Response limit (ms)",
"input.suggestion.latency": "Last response: {{ms}} ms",
"input.suggestion.slowWarning": "Slower than a typing-speed suggestion — try a smaller model or fewer candidates.",
"input.diagnostics.title": "Live check",
"input.diagnostics.app": "Focused app: {{app}}",
"input.diagnostics.unknownApp": "(unknown)",
"input.diagnostics.noSnapshot": "No field read yet — click into a text field and type.",
"input.diagnostics.readable": "Field is readable ({{source}}, {{length}} chars) — suggestions can run.",
"input.diagnostics.notReadable": "This app does not expose its text field — typing here cannot be read, so no suggestion is made.",
"input.diagnostics.password": "Password field — reading is blocked by design.",
"input.diagnostics.caretFallback": "Caret position unavailable; using the end of the document as the typing position.",
"input.diagnostics.composing": "IME composition in progress — suggestions wait a little longer, then still appear.",
"input.suggestion.onScreenLabel": "Overlay status",
"input.suggestion.onScreen": "On screen",
"input.suggestion.offScreen": "Hidden",
"input.suggestion.generating": "Generating…",
"input.kbView.knowledge": "Knowledge base",
"input.kbView.insights": "Input insights",
"input.insights.tabs.overview": "Overview",
"input.insights.tabs.keyboard": "Keyboard",
"input.insights.tabs.mouse": "Mouse",
"input.insights.tabs.apps": "Apps",
"input.insights.tabs.phrases": "Phrases · suggestions",
"input.insights.chars": "Characters typed",
"input.insights.wordChars": "Characters",
"input.insights.backspaces": "Backspaces",
"input.insights.shortcuts": "Shortcuts",
"input.insights.doubleClicks": "Double clicks",
"input.insights.scrollTicks": "Scroll ticks",
"input.insights.activeMinutes": "Active time",
"input.insights.activeDays": "Days recorded",
"input.insights.streak": "Longest streak",
"input.insights.peakDay": "Busiest day",
"input.insights.perDayAverage": "Daily average",
"input.insights.mouseDistanceTotal": "Total mouse travel",
"input.insights.hourlyTitle": "By hour (0-23)",
"input.insights.dailyTitle": "Daily trend",
"input.insights.wordsDailyTitle": "Words per day",
"input.insights.distanceDailyTitle": "Mouse travel per day",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Suggestions",
"input.insights.suggestionsTotal": "Generated",
"input.insights.suggestionsAccepted": "Accepted",
"input.insights.acceptRate": "Accept rate",
"input.insights.avgLatency": "Average response",
"input.insights.suggestionHistory": "Recent suggestions",
"input.insights.accepted": "accepted",
"input.insights.notAccepted": "not accepted",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Range",
"input.insights.rangeDays": "{{days}} days",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Detailed statistics live in Knowledge base > Input insights.",
"input.chart.max": "max {{value}}",
"input.chart.noData": "No data yet.",
"input.unit.perDay": "/day",
"input.unit.perDayMeters": "m/day",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Preparing model…",
"popup.suggestion.hintGenerating": "Generating…",
"keybinding.action.suggestionPrev": "Previous suggestion",
"keybinding.action.suggestionPrev.desc": "Move to the previous suggestion candidate.",
"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",
"input.graph.followsEdges": "Follows edges",
"input.graph.sharesEdges": "Term-sharing edges",
"input.graph.topEdges": "Strongest links",
"input.graph.recentNodes": "Recent nodes",
"input.graph.searchLabel": "Search a sentence",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Search",
"input.graph.neighbors": "Connected sentences",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "follows",
"input.graph.kindShares": "shares terms",
"popup.suggestion.generating": "Generating… ({{seconds}}s)",
"keybinding.ui.auditClear": "No shortcut conflicts or invalid settings.",
"keybinding.ui.auditIssues": "{{count}} shortcut issues",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} conflicts with {{conflicts}}",
"keybinding.ui.auditUnknown": "Unknown issue",
"input.flow.title": "Likely flow hours",
"input.flow.empty": "Not enough hourly typing data yet.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Score {{score}} · {{minutes}} active min/day · {{friction}}% edits",
"input.friction.title": "Editing friction",
"input.friction.value": "{{count}} per 100 characters",
"input.friction.band.steady": "Steady",
"input.friction.band.watch": "Watch",
"input.friction.band.high": "High",
"input.appQuality.title": "Suggestion quality by app",
"input.appQuality.empty": "No per-app suggestion history yet.",
"input.appQuality.row": "{{accepted}}/{{total}} accepted · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Shared",
"input.privacy.title": "Local data receipt",
"input.privacy.localOnly": "This data stays on this device.",
"input.privacy.rawKeys": "Raw individual key events and key codes are not retained. Learned text samples are listed separately below.",
"input.privacy.activityRetention": "Activity retention",
"input.privacy.suggestionRetention": "Suggestion retention",
"input.privacy.learnedRetention": "Learned text retention",
"input.privacy.activityBuckets": "Activity buckets",
"input.privacy.typingSamples": "Typing samples",
"input.privacy.personalPhrases": "Personal phrases",
"input.privacy.suggestions": "Suggestion records",
"input.privacy.days": "{{days}} days",
"input.privacy.untilDeleted": "Until deleted",
"input.exclusion.recommendation": "{{app}} had {{samples}} repeated unreadable inputs ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "repeatedly unreadable",
"input.exclusion.reason.repeated-empty": "repeatedly empty",
"input.exclusion.addButton": "Add {{app}} to exclusions",
"input.feedback.recommendationSaved": "Added {{app}} to exclusions.",
"popup.suggestion.sourceModel": "Local model",
"popup.suggestion.sourceMemory": "Local memory",
"popup.suggestion.continuations": "continuations",
"popup.suggestion.related": "related",
"popup.suggestion.phrases": "phrases",
"popup.suggestion.appPhrases": "app phrases",
"input.privacy.typingSamplesRetention": "Typed text sample retention",
"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."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Introduce una combinación (ej: Ctrl+Shift+Q) o una tecla sola (ej: F5)",
"keybinding.ui.recordAgain": "Volver a introducir",
"keybinding.ui.saveFailed": "No se ha podido guardar el atajo",
"keybinding.ui.globalEnabled": "Usar atajos globales"
"keybinding.ui.globalEnabled": "Usar atajos globales",
"input.tab": "Entrada",
"input.consent.title": "Recopilación de datos de entrada",
"input.consent.description": "Opcional. Contamos pulsaciones y clics y medimos el recorrido del ratón para mostrar tus patrones de escritura. Nunca se guarda qué teclas pulsas.",
"input.consent.collect": "Recopilar estadísticas de teclado y ratón",
"input.consent.running": "Recopilando ahora.",
"input.consent.stopped": "La recopilación está desactivada. No se registra nada.",
"input.consent.pause": "Pausar la recopilación",
"input.consent.learnText": "Aprender el texto que escribo",
"input.consent.learnTextHint": "Lee el campo de texto enfocado para aprender tu forma de escribir. Los campos de contraseña siempre se omiten.",
"input.consent.excludedApps": "Aplicaciones excluidas",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Nombres de ejecutables separados por comas. En estas aplicaciones no se lee ni se cuenta nada.",
"input.consent.clearAll": "Eliminar los datos recopilados",
"input.suggestion.title": "Sugerencias de siguiente frase",
"input.suggestion.description": "Predice la frase que vas a escribir y la muestra junto al cursor. Acéptala para insertarla.",
"input.suggestion.enabled": "Mostrar sugerencias al escribir",
"input.suggestion.modelReady": "Modelo local listo.",
"input.suggestion.modelMissing": "El modelo local no está en ejecución: las sugerencias están desactivadas.",
"input.suggestion.delay": "Retardo (ms)",
"input.suggestion.minPrefix": "Caracteres mínimos",
"input.suggestion.modelDefault": "Usar el modelo predeterminado",
"input.suggestion.overlayInteractive": "Permitir clic en el cuadro de sugerencia",
"input.suggestion.overlayVisible": "La sugerencia está en pantalla",
"input.suggestion.keyHint": "Aceptar, alternar y descartar son atajos globales: cámbialos en Ajustes > Atajos.",
"input.suggestion.requestNow": "Sugerir ahora",
"input.suggestion.usage": "{{requests}} de {{budget}} solicitudes usadas hoy",
"input.suggestion.lastSkip": "Último descarte: {{reason}}",
"input.insights.title": "Últimos {{days}} días",
"input.insights.keystrokes": "Pulsaciones",
"input.insights.clicks": "Clics",
"input.insights.words": "Palabras escritas",
"input.insights.sentences": "Frases",
"input.insights.mouseDistance": "Recorrido del ratón",
"input.insights.perDayMeters": " m/día",
"input.insights.phrases": "Frases personales",
"input.insights.empty": "Aún no hay actividad recopilada.",
"input.insights.topApps": "Donde más escribes",
"input.insights.appRow": "{{keystrokes}} teclas · {{clicks}} clics",
"input.insights.privacyNote": "En este dispositivo solo se guardan contadores y distancias. El contenido de las teclas nunca se guarda.",
"input.phrases.title": "Frases personales",
"input.phrases.description": "Frases aprendidas de tu escritura y dictado, usadas para imitar tu estilo.",
"input.phrases.empty": "Aún no se ha aprendido nada.",
"input.phrases.delete": "Eliminar frase",
"input.feedback.excludedSaved": "Exclusiones guardadas.",
"input.feedback.cleared": "Datos recopilados eliminados.",
"popup.suggestion.hintAccept": "Aceptar",
"popup.suggestion.hintNext": "Siguiente",
"popup.suggestion.hintDismiss": "Descartar",
"popup.suggestion.loading": "Generando…",
"keybinding.ui.sectionInput": "Sugerencias de entrada",
"keybinding.action.suggestionAccept": "Aceptar sugerencia",
"keybinding.action.suggestionAccept.desc": "Inserta la frase sugerida en la aplicación enfocada.",
"keybinding.action.suggestionNext": "Siguiente sugerencia",
"keybinding.action.suggestionNext.desc": "Pasa al siguiente candidato.",
"keybinding.action.suggestionDismiss": "Descartar sugerencia",
"keybinding.action.suggestionDismiss.desc": "Oculta la sugerencia actual.",
"input.suggestion.timeout": "Límite de respuesta (ms)",
"input.suggestion.latency": "Última respuesta: {{ms}} ms",
"input.suggestion.slowWarning": "Más lento que escribir — prueba un modelo más pequeño.",
"input.diagnostics.title": "Diagnóstico en vivo",
"input.diagnostics.app": "Aplicación enfocada: {{app}}",
"input.diagnostics.unknownApp": "(desconocida)",
"input.diagnostics.noSnapshot": "Aún no se ha leído ningún campo: haz clic en un campo de texto y escribe.",
"input.diagnostics.readable": "Campo legible ({{source}}, {{length}} caracteres) — las sugerencias pueden funcionar.",
"input.diagnostics.notReadable": "Esta aplicación no expone su campo de texto: lo escrito no se puede leer y no se sugiere nada.",
"input.diagnostics.password": "Campo de contraseña: la lectura está bloqueada por diseño.",
"input.diagnostics.caretFallback": "Sin posición del cursor; se usa el final del documento.",
"input.diagnostics.composing": "Composición IME en curso: la sugerencia aparece un poco más tarde.",
"input.suggestion.onScreenLabel": "Estado del overlay",
"input.suggestion.onScreen": "En pantalla",
"input.suggestion.offScreen": "Oculto",
"input.suggestion.generating": "Generando…",
"input.kbView.knowledge": "Base de conocimiento",
"input.kbView.insights": "Análisis de entrada",
"input.insights.tabs.overview": "Resumen",
"input.insights.tabs.keyboard": "Teclado",
"input.insights.tabs.mouse": "Ratón",
"input.insights.tabs.apps": "Aplicaciones",
"input.insights.tabs.phrases": "Frases · sugerencias",
"input.insights.chars": "Caracteres escritos",
"input.insights.wordChars": "Caracteres",
"input.insights.backspaces": "Borrados",
"input.insights.shortcuts": "Atajos",
"input.insights.doubleClicks": "Doble clic",
"input.insights.scrollTicks": "Desplazamiento",
"input.insights.activeMinutes": "Tiempo activo",
"input.insights.activeDays": "Días registrados",
"input.insights.streak": "Racha más larga",
"input.insights.peakDay": "Día con más actividad",
"input.insights.perDayAverage": "Promedio diario",
"input.insights.mouseDistanceTotal": "Recorrido total del ratón",
"input.insights.hourlyTitle": "Por hora (0-23)",
"input.insights.dailyTitle": "Tendencia diaria",
"input.insights.wordsDailyTitle": "Palabras por día",
"input.insights.distanceDailyTitle": "Recorrido diario del ratón",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Sugerencias",
"input.insights.suggestionsTotal": "Generadas",
"input.insights.suggestionsAccepted": "Aceptadas",
"input.insights.acceptRate": "Tasa de aceptación",
"input.insights.avgLatency": "Respuesta media",
"input.insights.suggestionHistory": "Sugerencias recientes",
"input.insights.accepted": "aceptada",
"input.insights.notAccepted": "no aceptada",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Rango",
"input.insights.rangeDays": "{{days}} días",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Las estadísticas detalladas están en Base de conocimiento > Análisis de entrada.",
"input.chart.max": "máx. {{value}}",
"input.chart.noData": "Aún no hay datos.",
"input.unit.perDay": "/día",
"input.unit.perDayMeters": "m/día",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Preparando el modelo…",
"popup.suggestion.hintGenerating": "Generando…",
"keybinding.action.suggestionPrev": "Sugerencia anterior",
"keybinding.action.suggestionPrev.desc": "Pasa al candidato anterior.",
"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",
"input.graph.followsEdges": "Enlaces de continuación",
"input.graph.sharesEdges": "Enlaces por términos",
"input.graph.topEdges": "Enlaces más fuertes",
"input.graph.recentNodes": "Nodos recientes",
"input.graph.searchLabel": "Buscar una frase",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Buscar",
"input.graph.neighbors": "Frases conectadas",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "continúa",
"input.graph.kindShares": "comparte términos",
"popup.suggestion.generating": "Generando… ({{seconds}} s)",
"keybinding.ui.auditClear": "No hay conflictos ni ajustes de atajos no válidos.",
"keybinding.ui.auditIssues": "{{count}} problemas de atajos",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} entra en conflicto con {{conflicts}}",
"keybinding.ui.auditUnknown": "Problema desconocido",
"input.flow.title": "Horas de mayor concentración",
"input.flow.empty": "Aún no hay suficientes datos horarios de escritura.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Puntuación {{score}} · {{minutes}} min activos/día · {{friction}} % de ediciones",
"input.friction.title": "Fricción de edición",
"input.friction.value": "{{count}} por 100 caracteres",
"input.friction.band.steady": "Estable",
"input.friction.band.watch": "Vigilar",
"input.friction.band.high": "Alta",
"input.appQuality.title": "Calidad de sugerencias por app",
"input.appQuality.empty": "Aún no hay historial de sugerencias por app.",
"input.appQuality.row": "{{accepted}}/{{total}} aceptadas · {{rate}} % · {{latency}} ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Compartido",
"input.privacy.title": "Recibo de datos locales",
"input.privacy.localOnly": "Estos datos permanecen en este dispositivo.",
"input.privacy.rawKeys": "No se conservan eventos individuales ni códigos de teclas. Las muestras de texto aprendido se muestran por separado abajo.",
"input.privacy.activityRetention": "Retención de actividad",
"input.privacy.suggestionRetention": "Retención de sugerencias",
"input.privacy.learnedRetention": "Retención de texto aprendido",
"input.privacy.activityBuckets": "Bloques de actividad",
"input.privacy.typingSamples": "Muestras de escritura",
"input.privacy.personalPhrases": "Frases personales",
"input.privacy.suggestions": "Registros de sugerencias",
"input.privacy.days": "{{days}} días",
"input.privacy.untilDeleted": "Hasta eliminarse",
"input.exclusion.recommendation": "{{app}} tuvo {{samples}} entradas repetidamente ilegibles ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "repetidamente ilegible",
"input.exclusion.reason.repeated-empty": "repetidamente vacía",
"input.exclusion.addButton": "Añadir {{app}} a exclusiones",
"input.feedback.recommendationSaved": "{{app}} se añadió a exclusiones.",
"popup.suggestion.sourceModel": "Modelo local",
"popup.suggestion.sourceMemory": "Memoria local",
"popup.suggestion.continuations": "continuaciones",
"popup.suggestion.related": "relacionadas",
"popup.suggestion.phrases": "frases",
"popup.suggestion.appPhrases": "frases de la app",
"input.privacy.typingSamplesRetention": "Retención de muestras de texto escrito",
"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."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Saisissez une combinaison (ex : Ctrl+Shift+Q) ou une touche seule (ex : F5)",
"keybinding.ui.recordAgain": "Ressaisir",
"keybinding.ui.saveFailed": "Le raccourci n'a pas pu être enregistré",
"keybinding.ui.globalEnabled": "Activer les raccourcis globaux"
"keybinding.ui.globalEnabled": "Activer les raccourcis globaux",
"input.tab": "Saisie",
"input.consent.title": "Collecte des données de saisie",
"input.consent.description": "Facultatif. Nous comptons les frappes et les clics et mesurons la distance parcourue par la souris pour afficher vos habitudes de saisie. Les touches pressées ne sont jamais enregistrées.",
"input.consent.collect": "Collecter les statistiques clavier et souris",
"input.consent.running": "Collecte en cours.",
"input.consent.stopped": "La collecte est désactivée. Rien n’est enregistré.",
"input.consent.pause": "Suspendre la collecte",
"input.consent.learnText": "Apprendre le texte que je saisis",
"input.consent.learnTextHint": "Lit le champ de saisie actif pour apprendre votre style. Les champs de mot de passe sont toujours ignorés.",
"input.consent.excludedApps": "Applications exclues",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Noms d’exécutables séparés par des virgules. Rien n’est lu ni compté dans ces applications.",
"input.consent.clearAll": "Supprimer les données collectées",
"input.suggestion.title": "Suggestions de phrase suivante",
"input.suggestion.description": "Prédit la phrase que vous allez écrire et l’affiche près du curseur. Acceptez pour l’insérer.",
"input.suggestion.enabled": "Afficher les suggestions pendant la saisie",
"input.suggestion.modelReady": "Modèle local prêt.",
"input.suggestion.modelMissing": "Le modèle local n’est pas démarré — suggestions désactivées.",
"input.suggestion.delay": "Délai (ms)",
"input.suggestion.minPrefix": "Caractères minimum",
"input.suggestion.modelDefault": "Utiliser le modèle par défaut",
"input.suggestion.overlayInteractive": "Autoriser le clic sur la suggestion",
"input.suggestion.overlayVisible": "Suggestion affichée",
"input.suggestion.keyHint": "Accepter, parcourir et fermer sont des raccourcis globaux — modifiez-les dans Réglages > Raccourcis.",
"input.suggestion.requestNow": "Suggérer maintenant",
"input.suggestion.usage": "{{requests}} sur {{budget}} requêtes utilisées aujourd’hui",
"input.suggestion.lastSkip": "Dernier motif d’ignorance : {{reason}}",
"input.insights.title": "{{days}} derniers jours",
"input.insights.keystrokes": "Frappes",
"input.insights.clicks": "Clics",
"input.insights.words": "Mots saisis",
"input.insights.sentences": "Phrases",
"input.insights.mouseDistance": "Trajet de la souris",
"input.insights.perDayMeters": " m/jour",
"input.insights.phrases": "Phrases personnelles",
"input.insights.empty": "Aucune activité collectée pour le moment.",
"input.insights.topApps": "Où vous saisissez le plus",
"input.insights.appRow": "{{keystrokes}} touches · {{clicks}} clics",
"input.insights.privacyNote": "Seuls des compteurs et des distances sont stockés sur cet appareil. Le contenu des touches n’est jamais enregistré.",
"input.phrases.title": "Phrases personnelles",
"input.phrases.description": "Phrases apprises de votre saisie et de votre dictée, utilisées pour imiter votre style.",
"input.phrases.empty": "Rien appris pour l’instant.",
"input.phrases.delete": "Supprimer la phrase",
"input.feedback.excludedSaved": "Exclusions enregistrées.",
"input.feedback.cleared": "Données collectées supprimées.",
"popup.suggestion.hintAccept": "Accepter",
"popup.suggestion.hintNext": "Suivant",
"popup.suggestion.hintDismiss": "Fermer",
"popup.suggestion.loading": "Génération…",
"keybinding.ui.sectionInput": "Suggestions de saisie",
"keybinding.action.suggestionAccept": "Accepter la suggestion",
"keybinding.action.suggestionAccept.desc": "Insère la phrase suggérée dans l’application active.",
"keybinding.action.suggestionNext": "Suggestion suivante",
"keybinding.action.suggestionNext.desc": "Passe au candidat suivant.",
"keybinding.action.suggestionDismiss": "Fermer la suggestion",
"keybinding.action.suggestionDismiss.desc": "Masque la suggestion actuelle.",
"input.suggestion.timeout": "Limite de réponse (ms)",
"input.suggestion.latency": "Dernière réponse : {{ms}} ms",
"input.suggestion.slowWarning": "Plus lent que la frappe — essayez un modèle plus petit.",
"input.diagnostics.title": "Diagnostic en direct",
"input.diagnostics.app": "Application active : {{app}}",
"input.diagnostics.unknownApp": "(inconnue)",
"input.diagnostics.noSnapshot": "Aucun champ lu pour le moment — cliquez dans un champ de texte et tapez.",
"input.diagnostics.readable": "Champ lisible ({{source}}, {{length}} caractères) — les suggestions peuvent fonctionner.",
"input.diagnostics.notReadable": "Cette application n'expose pas son champ de texte : ce qui est saisi n'est pas lisible, donc aucune suggestion.",
"input.diagnostics.password": "Champ de mot de passe — lecture bloquée par conception.",
"input.diagnostics.caretFallback": "Position du curseur inconnue ; fin du document utilisée.",
"input.diagnostics.composing": "Composition IME en cours — la suggestion arrive un peu plus tard.",
"input.suggestion.onScreenLabel": "État de l'overlay",
"input.suggestion.onScreen": "À l’écran",
"input.suggestion.offScreen": "Masqué",
"input.suggestion.generating": "Génération…",
"input.kbView.knowledge": "Base de connaissances",
"input.kbView.insights": "Analyse de saisie",
"input.insights.tabs.overview": "Aperçu",
"input.insights.tabs.keyboard": "Clavier",
"input.insights.tabs.mouse": "Souris",
"input.insights.tabs.apps": "Applications",
"input.insights.tabs.phrases": "Phrases · suggestions",
"input.insights.chars": "Caractères saisis",
"input.insights.wordChars": "Caractères",
"input.insights.backspaces": "Retours arrière",
"input.insights.shortcuts": "Raccourcis",
"input.insights.doubleClicks": "Doubles clics",
"input.insights.scrollTicks": "Défilement",
"input.insights.activeMinutes": "Temps actif",
"input.insights.activeDays": "Jours enregistrés",
"input.insights.streak": "Plus longue série",
"input.insights.peakDay": "Jour le plus actif",
"input.insights.perDayAverage": "Moyenne journalière",
"input.insights.mouseDistanceTotal": "Trajet total de la souris",
"input.insights.hourlyTitle": "Par heure (0-23)",
"input.insights.dailyTitle": "Évolution quotidienne",
"input.insights.wordsDailyTitle": "Mots par jour",
"input.insights.distanceDailyTitle": "Trajet quotidien de la souris",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Suggestions",
"input.insights.suggestionsTotal": "Générées",
"input.insights.suggestionsAccepted": "Acceptées",
"input.insights.acceptRate": "Taux d'acceptation",
"input.insights.avgLatency": "Réponse moyenne",
"input.insights.suggestionHistory": "Suggestions récentes",
"input.insights.accepted": "acceptée",
"input.insights.notAccepted": "non acceptée",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Période",
"input.insights.rangeDays": "{{days}} jours",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Les statistiques détaillées se trouvent dans Base de connaissances > Analyse de saisie.",
"input.chart.max": "max {{value}}",
"input.chart.noData": "Pas encore de données.",
"input.unit.perDay": "/jour",
"input.unit.perDayMeters": "m/jour",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Préparation du modèle…",
"popup.suggestion.hintGenerating": "Génération…",
"keybinding.action.suggestionPrev": "Suggestion précédente",
"keybinding.action.suggestionPrev.desc": "Passe au candidat précédent.",
"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",
"input.graph.followsEdges": "Liens de suite",
"input.graph.sharesEdges": "Liens par termes",
"input.graph.topEdges": "Liens les plus forts",
"input.graph.recentNodes": "Nœuds récents",
"input.graph.searchLabel": "Rechercher une phrase",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Rechercher",
"input.graph.neighbors": "Phrases connectées",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "suite",
"input.graph.kindShares": "termes partagés",
"popup.suggestion.generating": "Génération… ({{seconds}} s)",
"keybinding.ui.auditClear": "Aucun conflit ni réglage de raccourci invalide.",
"keybinding.ui.auditIssues": "{{count}} problèmes de raccourcis",
"keybinding.ui.auditInvalid": "{{action}} : {{reason}}",
"keybinding.ui.auditConflict": "{{action}} est en conflit avec {{conflicts}}",
"keybinding.ui.auditUnknown": "Problème inconnu",
"input.flow.title": "Créneaux de concentration probables",
"input.flow.empty": "Pas encore assez de données de saisie par heure.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Score {{score}} · {{minutes}} min actives/jour · {{friction}} % de modifications",
"input.friction.title": "Friction d’édition",
"input.friction.value": "{{count}} pour 100 caractères",
"input.friction.band.steady": "Stable",
"input.friction.band.watch": "À surveiller",
"input.friction.band.high": "Élevée",
"input.appQuality.title": "Qualité des suggestions par app",
"input.appQuality.empty": "Pas encore d’historique de suggestions par app.",
"input.appQuality.row": "{{accepted}}/{{total}} acceptées · {{rate}} % · {{latency}} ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Partagé",
"input.privacy.title": "Reçu de données locales",
"input.privacy.localOnly": "Ces données restent sur cet appareil.",
"input.privacy.rawKeys": "Les événements individuels et codes de touches bruts ne sont pas conservés. Les échantillons de texte appris sont indiqués séparément ci-dessous.",
"input.privacy.activityRetention": "Conservation de l’activité",
"input.privacy.suggestionRetention": "Conservation des suggestions",
"input.privacy.learnedRetention": "Conservation du texte appris",
"input.privacy.activityBuckets": "Blocs d’activité",
"input.privacy.typingSamples": "Échantillons de saisie",
"input.privacy.personalPhrases": "Phrases personnelles",
"input.privacy.suggestions": "Enregistrements de suggestions",
"input.privacy.days": "{{days}} jours",
"input.privacy.untilDeleted": "Jusqu’à suppression",
"input.exclusion.recommendation": "{{app}} a eu {{samples}} saisies illisibles répétées ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "illisible à répétition",
"input.exclusion.reason.repeated-empty": "vide à répétition",
"input.exclusion.addButton": "Ajouter {{app}} aux exclusions",
"input.feedback.recommendationSaved": "{{app}} a été ajouté aux exclusions.",
"popup.suggestion.sourceModel": "Modèle local",
"popup.suggestion.sourceMemory": "Mémoire locale",
"popup.suggestion.continuations": "suites",
"popup.suggestion.related": "liées",
"popup.suggestion.phrases": "phrases",
"popup.suggestion.appPhrases": "phrases de l’app",
"input.privacy.typingSamplesRetention": "Conservation des échantillons de texte saisi",
"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."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "組み合わせキー(例: Ctrl+Shift+Q)または単一キー(例: F5)を入力してください",
"keybinding.ui.recordAgain": "再入力",
"keybinding.ui.saveFailed": "ショートカットを保存できませんでした",
"keybinding.ui.globalEnabled": "グローバルショートカットを使用"
"keybinding.ui.globalEnabled": "グローバルショートカットを使用",
"input.tab": "入力",
"input.consent.title": "入力データの収集",
"input.consent.description": "任意です。キー入力とクリック回数、マウスの移動距離を数えて入力パターンを表示します。どのキーを押したかは保存しません。",
"input.consent.collect": "キーボードとマウスの統計を収集",
"input.consent.running": "収集中です。",
"input.consent.stopped": "収集はオフです。何も記録しません。",
"input.consent.pause": "収集を一時停止",
"input.consent.learnText": "入力したテキストを学習",
"input.consent.learnTextHint": "フォーカス中の入力欄を読み、言い回しを学習します。パスワード欄は常に除外されます。",
"input.consent.excludedApps": "除外するアプリ",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "カンマ区切りの実行ファイル名です。これらのアプリでは読み取りも計測もしません。",
"input.consent.clearAll": "収集したデータを削除",
"input.suggestion.title": "次の一文の提案",
"input.suggestion.description": "これから書く一文を予測し、カーソルの隣に表示します。承認すると挿入されます。",
"input.suggestion.enabled": "入力中に提案を表示",
"input.suggestion.modelReady": "ローカルモデル準備完了。",
"input.suggestion.modelMissing": "ローカルモデルが起動していません — 提案は無効です。",
"input.suggestion.delay": "遅延 (ms)",
"input.suggestion.minPrefix": "最小文字数",
"input.suggestion.modelDefault": "既定のモデルを使用",
"input.suggestion.overlayInteractive": "提案ボックスのクリックを許可",
"input.suggestion.overlayVisible": "提案を表示中",
"input.suggestion.keyHint": "承認・次候補・閉じるはグローバルキーバインドです(設定 > ショートカット)。",
"input.suggestion.requestNow": "今すぐ提案",
"input.suggestion.usage": "本日 {{requests}}/{{budget}} 回使用",
"input.suggestion.lastSkip": "最後にスキップした理由: {{reason}}",
"input.insights.title": "直近 {{days}} 日",
"input.insights.keystrokes": "キー入力",
"input.insights.clicks": "クリック",
"input.insights.words": "入力した単語",
"input.insights.sentences": "文",
"input.insights.mouseDistance": "マウス移動",
"input.insights.perDayMeters": " m/日",
"input.insights.phrases": "個人フレーズ",
"input.insights.empty": "まだ活動が記録されていません。",
"input.insights.topApps": "よく入力するアプリ",
"input.insights.appRow": "キー {{keystrokes}} · クリック {{clicks}}",
"input.insights.privacyNote": "この端末にはカウンターと距離のみ保存されます。キーの内容は保存されません。",
"input.phrases.title": "個人フレーズ",
"input.phrases.description": "入力と音声入力から学習した文です。文体を合わせるために使います。",
"input.phrases.empty": "まだ学習されたフレーズはありません。",
"input.phrases.delete": "フレーズを削除",
"input.feedback.excludedSaved": "除外リストを保存しました。",
"input.feedback.cleared": "収集したデータを削除しました。",
"popup.suggestion.hintAccept": "承認",
"popup.suggestion.hintNext": "次へ",
"popup.suggestion.hintDismiss": "閉じる",
"popup.suggestion.loading": "生成中…",
"keybinding.ui.sectionInput": "入力サジェスト",
"keybinding.action.suggestionAccept": "サジェストを承認",
"keybinding.action.suggestionAccept.desc": "サジェストされた文を現在のアプリに挿入します。",
"keybinding.action.suggestionNext": "次のサジェスト",
"keybinding.action.suggestionNext.desc": "次の候補に切り替えます。",
"keybinding.action.suggestionDismiss": "サジェストを閉じる",
"keybinding.action.suggestionDismiss.desc": "現在のサジェストを隠します。",
"input.suggestion.timeout": "応答上限 (ms)",
"input.suggestion.latency": "前回の応答: {{ms}}ms",
"input.suggestion.slowWarning": "入力速度には遅いです — より小さいモデルを試してください。",
"input.diagnostics.title": "リアルタイム診断",
"input.diagnostics.app": "フォーカス中のアプリ: {{app}}",
"input.diagnostics.unknownApp": "(不明)",
"input.diagnostics.noSnapshot": "まだ入力欄を読み取っていません — テキスト欄をクリックして入力してください。",
"input.diagnostics.readable": "入力欄を読み取れます ({{source}}, {{length}} 文字) — 提案が動作します。",
"input.diagnostics.notReadable": "このアプリは入力欄のテキストを公開していません — 読み取れないため提案できません。",
"input.diagnostics.password": "パスワード欄 — 設計上読み取りを遮断します。",
"input.diagnostics.caretFallback": "カーソル位置が不明なため、文書の末尾を入力位置とみなします。",
"input.diagnostics.composing": "IME 変換中 — 提案は少し待ってから表示されます。",
"input.suggestion.onScreenLabel": "オーバーレイ状態",
"input.suggestion.onScreen": "表示中",
"input.suggestion.offScreen": "非表示",
"input.suggestion.generating": "生成中…",
"input.kbView.knowledge": "ナレッジベース",
"input.kbView.insights": "入力インサイト",
"input.insights.tabs.overview": "概要",
"input.insights.tabs.keyboard": "キーボード",
"input.insights.tabs.mouse": "マウス",
"input.insights.tabs.apps": "アプリ",
"input.insights.tabs.phrases": "フレーズ・提案",
"input.insights.chars": "入力文字数",
"input.insights.wordChars": "文字",
"input.insights.backspaces": "削除",
"input.insights.shortcuts": "ショートカット",
"input.insights.doubleClicks": "ダブルクリック",
"input.insights.scrollTicks": "スクロール",
"input.insights.activeMinutes": "アクティブ時間",
"input.insights.activeDays": "記録日数",
"input.insights.streak": "最長連続",
"input.insights.peakDay": "最多入力日",
"input.insights.perDayAverage": "1日平均",
"input.insights.mouseDistanceTotal": "移動距離合計",
"input.insights.hourlyTitle": "時間帯別 (0〜23時)",
"input.insights.dailyTitle": "日別推移",
"input.insights.wordsDailyTitle": "日別の単語",
"input.insights.distanceDailyTitle": "日別マウス移動",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "提案",
"input.insights.suggestionsTotal": "生成数",
"input.insights.suggestionsAccepted": "承認",
"input.insights.acceptRate": "承認率",
"input.insights.avgLatency": "平均応答",
"input.insights.suggestionHistory": "最近の提案",
"input.insights.accepted": "承認",
"input.insights.notAccepted": "未承認",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "期間",
"input.insights.rangeDays": "{{days}}日",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "詳細統計はナレッジベース > 入力インサイトにあります。",
"input.chart.max": "最大 {{value}}",
"input.chart.noData": "データがまだありません。",
"input.unit.perDay": "/日",
"input.unit.perDayMeters": "m/日",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "モデルを準備中…",
"popup.suggestion.hintGenerating": "生成中…",
"keybinding.action.suggestionPrev": "前の候補",
"keybinding.action.suggestionPrev.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": "文ノード",
"input.graph.followsEdges": "続きの連結",
"input.graph.sharesEdges": "用語共有の連結",
"input.graph.topEdges": "強い連結",
"input.graph.recentNodes": "最近のノード",
"input.graph.searchLabel": "文を検索",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "検索",
"input.graph.neighbors": "連結した文",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "続き",
"input.graph.kindShares": "用語共有",
"popup.suggestion.generating": "生成中… ({{seconds}}秒)",
"keybinding.ui.auditClear": "ショートカットの競合や無効な設定はありません。",
"keybinding.ui.auditIssues": "ショートカットの問題 {{count}} 件",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} は {{conflicts}} と競合しています",
"keybinding.ui.auditUnknown": "不明な問題",
"input.flow.title": "集中しやすい時間帯",
"input.flow.empty": "時間帯別の入力データがまだ不足しています。",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "スコア {{score}} · 1日 {{minutes}}分アクティブ · 編集 {{friction}}%",
"input.friction.title": "編集の摩擦",
"input.friction.value": "100文字あたり {{count}} 回",
"input.friction.band.steady": "安定",
"input.friction.band.watch": "注意",
"input.friction.band.high": "高い",
"input.appQuality.title": "アプリ別の提案品質",
"input.appQuality.empty": "アプリ別の提案履歴はまだありません。",
"input.appQuality.row": "{{accepted}}/{{total}} 件承認 · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}回 · {{app}}",
"input.phrases.appUnknown": "共通",
"input.privacy.title": "ローカルデータの明細",
"input.privacy.localOnly": "このデータはこの端末だけに保存されます。",
"input.privacy.rawKeys": "個別のキーイベントとキーコードは保持しません。学習用の入力テキスト標本は下で別に表示します。",
"input.privacy.activityRetention": "活動記録の保持",
"input.privacy.suggestionRetention": "提案記録の保持",
"input.privacy.learnedRetention": "学習文の保持",
"input.privacy.activityBuckets": "活動バケット",
"input.privacy.typingSamples": "入力サンプル",
"input.privacy.personalPhrases": "個人フレーズ",
"input.privacy.suggestions": "提案記録",
"input.privacy.days": "{{days}}日",
"input.privacy.untilDeleted": "削除するまで",
"input.exclusion.recommendation": "{{app}} で読めない入力が {{samples}} 回繰り返されました({{reason}})。",
"input.exclusion.reason.repeated-unreadable": "繰り返し読めない",
"input.exclusion.reason.repeated-empty": "繰り返し空",
"input.exclusion.addButton": "{{app}} を除外に追加",
"input.feedback.recommendationSaved": "{{app}} を除外に追加しました。",
"popup.suggestion.sourceModel": "ローカルモデル",
"popup.suggestion.sourceMemory": "ローカル記憶",
"popup.suggestion.continuations": "続き",
"popup.suggestion.related": "関連",
"popup.suggestion.phrases": "フレーズ",
"popup.suggestion.appPhrases": "このアプリのフレーズ",
"input.privacy.typingSamplesRetention": "入力テキスト標本の保持",
"input.privacy.unavailable": "ローカルデータの明細は現在利用できません。件数と保持期間は表示されません。",
"input.feedback.clearFailed": "収集したデータを削除できませんでした。",
"input.feedback.excludedFailed": "除外設定を保存できませんでした。",
"input.feedback.recommendationFailed": "{{app}} を除外に追加できませんでした。"
}

View file

@ -1714,5 +1714,192 @@
"keybinding.ui.recordHint": "조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요",
"keybinding.ui.recordAgain": "다시 입력",
"keybinding.ui.saveFailed": "단축키를 저장하지 못했습니다",
"keybinding.ui.globalEnabled": "전역 단축키 사용"
"keybinding.ui.globalEnabled": "전역 단축키 사용",
"input.tab": "입력",
"input.consent.title": "입력 데이터 수집",
"input.consent.description": "선택 사항입니다. 키 입력과 클릭 횟수, 마우스 이동 거리를 세어 자신의 입력 패턴을 보여니다. 어떤 키를 눌렀는지는 저장하지 않습니다.",
"input.consent.collect": "키보드·마우스 통계 수집",
"input.consent.running": "수집 중입니다.",
"input.consent.stopped": "수집이 꺼져 있습니다. 아무것도 기록하지 않습니다.",
"input.consent.pause": "수집 일시정지",
"input.consent.learnText": "내가 친 텍스트 학습",
"input.consent.learnTextHint": "포커스된 입력창을 읽어 문장 습관을 학습합니다. 비밀번호 필드는 항상 건너뜁니다.",
"input.consent.excludedApps": "제외할 앱",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "쉼표로 구분한 실행 파일명입니다. 이 앱에서는 아무것도 읽거나 세지 않습니다.",
"input.consent.clearAll": "수집된 데이터 삭제",
"input.suggestion.title": "다음 문장 제안",
"input.suggestion.description": "앞으로 나올 문장을 예측해 커서 에 보여니다. 수락하면 그대로 삽입됩니다.",
"input.suggestion.enabled": "타이핑 중 제안 표시",
"input.suggestion.modelReady": "로컬 모델 준비됨.",
"input.suggestion.modelMissing": "로컬 모델이 실행 중이 아닙니다 — 제안이 꺼진 상태로 동작합니다.",
"input.suggestion.delay": "지연 (ms)",
"input.suggestion.minPrefix": "최소 글자 수",
"input.suggestion.modelDefault": "기본 모델 사용",
"input.suggestion.overlayInteractive": "제안 상자 클릭 허용",
"input.suggestion.overlayVisible": "제안이 화면에 표시됨",
"input.suggestion.keyHint": "수락·다음 후보·닫기는 전역 키바인딩입니다 — 설정 > 단축키에서 바 수 있습니다.",
"input.suggestion.requestNow": "지금 제안 받기",
"input.suggestion.usage": "오늘 {{requests}}/{{budget}}회 사용",
"input.suggestion.lastSkip": "마지막 건너 사유: {{reason}}",
"input.insights.title": "최근 {{days}}일",
"input.insights.keystrokes": "키 입력",
"input.insights.clicks": "클릭",
"input.insights.words": "작성한 단어",
"input.insights.sentences": "문장",
"input.insights.mouseDistance": "마우스 이동",
"input.insights.perDayMeters": " m/일",
"input.insights.phrases": "개인 문구",
"input.insights.empty": "아직 수집된 활동이 없습니다.",
"input.insights.topApps": "많이 입력한 앱",
"input.insights.appRow": "키 {{keystrokes}} · 클릭 {{clicks}}",
"input.insights.privacyNote": "이 기기에는 카운터와 거리만 저장됩니다. 키 내용은 저장되지 않습니다.",
"input.phrases.title": "개인 문구",
"input.phrases.description": "타이핑과 받아쓰기에서 학습한 문장입니다. 문체를 맞추는 데 사용합니다.",
"input.phrases.empty": "아직 학습된 문구가 없습니다.",
"input.phrases.delete": "문구 삭제",
"input.feedback.excludedSaved": "제외 목록을 저장했습니다.",
"input.feedback.cleared": "수집된 데이터를 삭제했습니다.",
"popup.suggestion.hintAccept": "수락",
"popup.suggestion.hintNext": "다음",
"popup.suggestion.hintDismiss": "닫기",
"popup.suggestion.loading": "생성 중…",
"keybinding.ui.sectionInput": "입력 제안",
"keybinding.action.suggestionAccept": "제안 수락",
"keybinding.action.suggestionAccept.desc": "제안된 문장을 현재 앱에 삽입합니다.",
"keybinding.action.suggestionNext": "다음 제안",
"keybinding.action.suggestionNext.desc": "다음 제안 후보로 넘깁니다.",
"keybinding.action.suggestionDismiss": "제안 닫기",
"keybinding.action.suggestionDismiss.desc": "현재 제안을 숨깁니다.",
"input.suggestion.timeout": "응답 제한 (ms)",
"input.suggestion.latency": "마지막 응답: {{ms}}ms",
"input.suggestion.slowWarning": "타이 속도에 비해 느립니다 — 더 작은 모델을 쓰거나 후보 수를 줄여보세요.",
"input.diagnostics.title": "실시간 진단",
"input.diagnostics.app": "포커스 앱: {{app}}",
"input.diagnostics.unknownApp": "(알 수 없음)",
"input.diagnostics.noSnapshot": "아직 입력창을 읽지 못했습니다 — 글자 필드를 클릭하고 타이핑해 보세요.",
"input.diagnostics.readable": "입력창 읽기 가능 ({{source}}, {{length}}자) — 제안이 동작할 수 있습니다.",
"input.diagnostics.notReadable": "이 앱은 입력창 텍스트를 노출하지 않습니다 — 여기서 친 내용은 읽을 수 없어 제안이 만들어지지 않습니다.",
"input.diagnostics.password": "비밀번호 필드 — 설계상 읽기를 차단합니다.",
"input.diagnostics.caretFallback": "커서 위치를 알 수 없어 문서 끝을 입력 위치로 가정합니다.",
"input.diagnostics.composing": "IME 조합 중 — 제안이 조금 더 기다렸다가 뜹니다(차단되지 않습니다).",
"input.suggestion.onScreenLabel": "오버레이 상태",
"input.suggestion.onScreen": "표시 중",
"input.suggestion.offScreen": "없음",
"input.suggestion.generating": "생성 중…",
"input.kbView.knowledge": "지식 베이스",
"input.kbView.insights": "입력 인사이트",
"input.insights.tabs.overview": "요약",
"input.insights.tabs.keyboard": "키보드",
"input.insights.tabs.mouse": "마우스",
"input.insights.tabs.apps": "앱",
"input.insights.tabs.phrases": "문구 · 제안",
"input.insights.chars": "입력한 글자",
"input.insights.wordChars": "글자",
"input.insights.backspaces": "지우기",
"input.insights.shortcuts": "단축키",
"input.insights.doubleClicks": "더블클릭",
"input.insights.scrollTicks": "스크롤",
"input.insights.activeMinutes": "활성 시간",
"input.insights.activeDays": "기록한 날",
"input.insights.streak": "최장 연속",
"input.insights.peakDay": "최다 입력일",
"input.insights.perDayAverage": "일 평균",
"input.insights.mouseDistanceTotal": "이동 거리 합",
"input.insights.hourlyTitle": "시간대별 (0~23시)",
"input.insights.dailyTitle": "일별 추이",
"input.insights.wordsDailyTitle": "일별 단어",
"input.insights.distanceDailyTitle": "일별 마우스 이동",
"input.insights.topHoursHint": "가장 활발한 시간대: {{hours}}",
"input.insights.mouseNote": "마우스 이동은 축별 절대값 합(맨해튼)으로 계산합니다 — ActivityWatch와 같은 방식입니다.",
"input.insights.appsHint": "입력량 기준 상위 앱입니다. 막대는 전체 입력 대비 비중입니다.",
"input.insights.suggestions": "제안",
"input.insights.suggestionsTotal": "생성된 제안",
"input.insights.suggestionsAccepted": "수락",
"input.insights.acceptRate": "수락률",
"input.insights.avgLatency": "평균 응답",
"input.insights.suggestionHistory": "최근 제안 이력",
"input.insights.accepted": "수락",
"input.insights.notAccepted": "미수락",
"input.insights.noSuggestions": "아직 제안 이력이 없습니다.",
"input.insights.samplesNote": "학습 표본 {{count}}건.",
"input.insights.collectedAt": "마지막 수집 {{at}}",
"input.insights.rangeLabel": "기간",
"input.insights.rangeDays": "{{days}}일",
"input.insights.headerSummary": "최근 {{days}}일 · 키 입력 {{keys}} · 앱 {{apps}}개",
"input.insights.disabledHint": "입력 수집이 꺼져 있습니다. 설정 > 입력에서 켜면 여기에 통계가 쌓입니다.",
"input.insights.statsMovedHint": "상세 통계는 지식 베이스 > 입력 인사이트에서 볼 수 있습니다.",
"input.chart.max": "최대 {{value}}",
"input.chart.noData": "데이터가 아직 없습니다.",
"input.unit.perDay": "/일",
"input.unit.perDayMeters": "m/일",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "모델 준비 중…",
"popup.suggestion.hintGenerating": "생성 중…",
"keybinding.action.suggestionPrev": "이전 제안",
"keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.",
"input.insights.tabs.graph": "그래프",
"input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.",
"input.graph.nodes": "문장 노드",
"input.graph.followsEdges": "이어쓰기 연결",
"input.graph.sharesEdges": "용어 공유 연결",
"input.graph.topEdges": "강한 연결",
"input.graph.recentNodes": "최근 노드",
"input.graph.searchLabel": "문장 검색",
"input.graph.searchPlaceholder": "예: 회의 일정",
"input.graph.searchAction": "검색",
"input.graph.neighbors": "연결된 문장",
"input.graph.empty": "그래프가 비어 있습니다. 설정 > 입력에서 \"내가 친 텍스트 학습\" 을 켜면 쌓입니다.",
"input.graph.kindFollows": "이어쓰기",
"input.graph.kindShares": "용어 공유",
"popup.suggestion.generating": "생성 중… ({{seconds}}초)",
"keybinding.ui.auditClear": "단축키 충돌과 유효하지 않은 설정이 없습니다.",
"keybinding.ui.auditIssues": "단축키 문제 {{count}}건",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}}: {{conflicts}}와 충돌",
"keybinding.ui.auditUnknown": "알 수 없는 문제",
"input.flow.title": "몰입 가능 시간대",
"input.flow.empty": "아직 시간대별 입력 데이터가 부족합니다.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "점수 {{score}} · 하루 {{minutes}}분 활성 · 편집 {{friction}}%",
"input.friction.title": "편집 마찰",
"input.friction.value": "100자당 {{count}}회",
"input.friction.band.steady": "안정",
"input.friction.band.watch": "관찰",
"input.friction.band.high": "높음",
"input.appQuality.title": "앱별 제안 품질",
"input.appQuality.empty": "아직 앱별 제안 기록이 없습니다.",
"input.appQuality.row": "{{accepted}}/{{total}} 수락 · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}회 · {{app}}",
"input.phrases.appUnknown": "공통",
"input.privacy.title": "로컬 데이터 영수증",
"input.privacy.localOnly": "이 데이터는 이 기기에만 저장됩니다.",
"input.privacy.rawKeys": "개별 키 입력 이벤트와 키 코드는 보존하지 않습니다. 학습용 입력 원문 표본은 아래 수량과 보존 기간으로 따로 표시합니다.",
"input.privacy.activityRetention": "활동 기록 보존",
"input.privacy.suggestionRetention": "제안 기록 보존",
"input.privacy.learnedRetention": "학습 문구 보존",
"input.privacy.activityBuckets": "활동 버킷",
"input.privacy.typingSamples": "입력 샘플",
"input.privacy.personalPhrases": "개인 문구",
"input.privacy.suggestions": "제안 기록",
"input.privacy.days": "{{days}}일",
"input.privacy.untilDeleted": "삭제할 때까지",
"input.exclusion.recommendation": "{{app}}에서 읽을 수 없는 입력이 {{samples}}번 반복되었습니다 ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "반복적으로 읽을 수 없음",
"input.exclusion.reason.repeated-empty": "반복적으로 비어 있음",
"input.exclusion.addButton": "{{app}}을(를) 제외 목록에 추가",
"input.feedback.recommendationSaved": "{{app}}을(를) 제외 목록에 추가했습니다.",
"popup.suggestion.sourceModel": "로컬 모델",
"popup.suggestion.sourceMemory": "로컬 기억",
"popup.suggestion.continuations": "다음 문장",
"popup.suggestion.related": "관련 문장",
"popup.suggestion.phrases": "개인 문구",
"popup.suggestion.appPhrases": "이 앱 문구",
"input.privacy.typingSamplesRetention": "입력 원문 표본 보존",
"input.privacy.unavailable": "지금은 로컬 데이터 영수증을 불러올 수 없습니다. 수량과 보존 기간은 표시하지 않습니다.",
"input.feedback.clearFailed": "수집된 데이터를 삭제하지 못했습니다.",
"input.feedback.excludedFailed": "제외 목록을 저장하지 못했습니다.",
"input.feedback.recommendationFailed": "{{app}}을(를) 제외 목록에 추가하지 못했습니다."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Insira uma combinação (ex: Ctrl+Shift+Q) ou uma tecla única (ex: F5)",
"keybinding.ui.recordAgain": "Inserir novamente",
"keybinding.ui.saveFailed": "Não foi possível salvar o atalho",
"keybinding.ui.globalEnabled": "Usar atalhos globais"
"keybinding.ui.globalEnabled": "Usar atalhos globais",
"input.tab": "Entrada",
"input.consent.title": "Coleta de dados de entrada",
"input.consent.description": "Opcional. Contamos teclas e cliques e medimos a distância do mouse para mostrar seus padrões de digitação. Quais teclas você pressiona nunca é armazenado.",
"input.consent.collect": "Coletar estatísticas de teclado e mouse",
"input.consent.running": "Coletando agora.",
"input.consent.stopped": "A coleta está desligada. Nada é registrado.",
"input.consent.pause": "Pausar a coleta",
"input.consent.learnText": "Aprender o texto que eu digito",
"input.consent.learnTextHint": "Lê o campo de texto em foco para aprender seu jeito de escrever. Campos de senha são sempre ignorados.",
"input.consent.excludedApps": "Aplicativos excluídos",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Nomes de executáveis separados por vírgula. Nesses aplicativos nada é lido nem contado.",
"input.consent.clearAll": "Excluir os dados coletados",
"input.suggestion.title": "Sugestões da próxima frase",
"input.suggestion.description": "Prevê a frase que você vai escrever e mostra ao lado do cursor. Aceite para inserir.",
"input.suggestion.enabled": "Mostrar sugestões ao digitar",
"input.suggestion.modelReady": "Modelo local pronto.",
"input.suggestion.modelMissing": "O modelo local não está em execução — sugestões desativadas.",
"input.suggestion.delay": "Atraso (ms)",
"input.suggestion.minPrefix": "Caracteres mínimos",
"input.suggestion.modelDefault": "Usar o modelo padrão",
"input.suggestion.overlayInteractive": "Permitir clique na caixa de sugestão",
"input.suggestion.overlayVisible": "Sugestão na tela",
"input.suggestion.keyHint": "Aceitar, alternar e descartar são atalhos globais — altere em Configurações > Atalhos.",
"input.suggestion.requestNow": "Sugerir agora",
"input.suggestion.usage": "{{requests}} de {{budget}} solicitações usadas hoje",
"input.suggestion.lastSkip": "Último descarte: {{reason}}",
"input.insights.title": "Últimos {{days}} dias",
"input.insights.keystrokes": "Teclas",
"input.insights.clicks": "Cliques",
"input.insights.words": "Palavras digitadas",
"input.insights.sentences": "Frases",
"input.insights.mouseDistance": "Distância do mouse",
"input.insights.perDayMeters": " m/dia",
"input.insights.phrases": "Frases pessoais",
"input.insights.empty": "Nenhuma atividade coletada ainda.",
"input.insights.topApps": "Onde você mais digita",
"input.insights.appRow": "{{keystrokes}} teclas · {{clicks}} cliques",
"input.insights.privacyNote": "Neste dispositivo são guardados apenas contadores e distâncias. O conteúdo das teclas nunca é salvo.",
"input.phrases.title": "Frases pessoais",
"input.phrases.description": "Frases aprendidas da sua digitação e ditado, usadas para acompanhar seu estilo.",
"input.phrases.empty": "Nada aprendido ainda.",
"input.phrases.delete": "Excluir frase",
"input.feedback.excludedSaved": "Exclusões salvas.",
"input.feedback.cleared": "Dados coletados excluídos.",
"popup.suggestion.hintAccept": "Aceitar",
"popup.suggestion.hintNext": "Próxima",
"popup.suggestion.hintDismiss": "Descartar",
"popup.suggestion.loading": "Gerando…",
"keybinding.ui.sectionInput": "Sugestões de entrada",
"keybinding.action.suggestionAccept": "Aceitar sugestão",
"keybinding.action.suggestionAccept.desc": "Insere a frase sugerida no aplicativo em foco.",
"keybinding.action.suggestionNext": "Próxima sugestão",
"keybinding.action.suggestionNext.desc": "Passa para o próximo candidato.",
"keybinding.action.suggestionDismiss": "Descartar sugestão",
"keybinding.action.suggestionDismiss.desc": "Oculta a sugestão atual.",
"input.suggestion.timeout": "Limite de resposta (ms)",
"input.suggestion.latency": "Última resposta: {{ms}} ms",
"input.suggestion.slowWarning": "Mais lento que digitar — tente um modelo menor.",
"input.diagnostics.title": "Diagnóstico ao vivo",
"input.diagnostics.app": "Aplicativo em foco: {{app}}",
"input.diagnostics.unknownApp": "(desconhecido)",
"input.diagnostics.noSnapshot": "Nenhum campo lido ainda — clique em um campo de texto e digite.",
"input.diagnostics.readable": "Campo legível ({{source}}, {{length}} caracteres) — sugestões podem funcionar.",
"input.diagnostics.notReadable": "Este aplicativo não expõe o campo de texto — o que foi digitado não pode ser lido, então não há sugestão.",
"input.diagnostics.password": "Campo de senha — leitura bloqueada por design.",
"input.diagnostics.caretFallback": "Posição do cursor indisponível; usa o fim do documento.",
"input.diagnostics.composing": "Composição IME em andamento — a sugestão aparece um pouco depois.",
"input.suggestion.onScreenLabel": "Status do overlay",
"input.suggestion.onScreen": "Na tela",
"input.suggestion.offScreen": "Oculto",
"input.suggestion.generating": "Gerando…",
"input.kbView.knowledge": "Base de conhecimento",
"input.kbView.insights": "Análise de entrada",
"input.insights.tabs.overview": "Resumo",
"input.insights.tabs.keyboard": "Teclado",
"input.insights.tabs.mouse": "Mouse",
"input.insights.tabs.apps": "Aplicativos",
"input.insights.tabs.phrases": "Frases · sugestões",
"input.insights.chars": "Caracteres digitados",
"input.insights.wordChars": "Caracteres",
"input.insights.backspaces": "Apagamentos",
"input.insights.shortcuts": "Atalhos",
"input.insights.doubleClicks": "Cliques duplos",
"input.insights.scrollTicks": "Rolagem",
"input.insights.activeMinutes": "Tempo ativo",
"input.insights.activeDays": "Dias registrados",
"input.insights.streak": "Maior sequência",
"input.insights.peakDay": "Dia mais ativo",
"input.insights.perDayAverage": "Média diária",
"input.insights.mouseDistanceTotal": "Distância total do mouse",
"input.insights.hourlyTitle": "Por hora (0-23)",
"input.insights.dailyTitle": "Tendência diária",
"input.insights.wordsDailyTitle": "Palavras por dia",
"input.insights.distanceDailyTitle": "Distância diária do mouse",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Sugestões",
"input.insights.suggestionsTotal": "Geradas",
"input.insights.suggestionsAccepted": "Aceitas",
"input.insights.acceptRate": "Taxa de aceite",
"input.insights.avgLatency": "Resposta média",
"input.insights.suggestionHistory": "Sugestões recentes",
"input.insights.accepted": "aceita",
"input.insights.notAccepted": "não aceita",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Período",
"input.insights.rangeDays": "{{days}} dias",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "As estatísticas detalhadas ficam em Base de conhecimento > Análise de entrada.",
"input.chart.max": "máx. {{value}}",
"input.chart.noData": "Ainda sem dados.",
"input.unit.perDay": "/dia",
"input.unit.perDayMeters": "m/dia",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Preparando o modelo…",
"popup.suggestion.hintGenerating": "Gerando…",
"keybinding.action.suggestionPrev": "Sugestão anterior",
"keybinding.action.suggestionPrev.desc": "Vai para o candidato anterior.",
"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",
"input.graph.followsEdges": "Ligações de continuação",
"input.graph.sharesEdges": "Ligações por termos",
"input.graph.topEdges": "Ligações mais fortes",
"input.graph.recentNodes": "Nós recentes",
"input.graph.searchLabel": "Buscar uma frase",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Buscar",
"input.graph.neighbors": "Frases conectadas",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "continua",
"input.graph.kindShares": "compartilha termos",
"popup.suggestion.generating": "Gerando… ({{seconds}} s)",
"keybinding.ui.auditClear": "Não há conflitos ou atalhos inválidos.",
"keybinding.ui.auditIssues": "{{count}} problemas de atalho",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} conflita com {{conflicts}}",
"keybinding.ui.auditUnknown": "Problema desconhecido",
"input.flow.title": "Horários prováveis de foco",
"input.flow.empty": "Ainda não há dados horários de digitação suficientes.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Pontuação {{score}} · {{minutes}} min ativos/dia · {{friction}}% de edições",
"input.friction.title": "Atrito de edição",
"input.friction.value": "{{count}} por 100 caracteres",
"input.friction.band.steady": "Estável",
"input.friction.band.watch": "Observar",
"input.friction.band.high": "Alto",
"input.appQuality.title": "Qualidade das sugestões por app",
"input.appQuality.empty": "Ainda não há histórico de sugestões por app.",
"input.appQuality.row": "{{accepted}}/{{total}} aceitas · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Compartilhado",
"input.privacy.title": "Recibo de dados locais",
"input.privacy.localOnly": "Estes dados ficam neste dispositivo.",
"input.privacy.rawKeys": "Eventos individuais e códigos de teclas brutos não são mantidos. As amostras de texto aprendido aparecem separadamente abaixo.",
"input.privacy.activityRetention": "Retenção de atividade",
"input.privacy.suggestionRetention": "Retenção de sugestões",
"input.privacy.learnedRetention": "Retenção de texto aprendido",
"input.privacy.activityBuckets": "Blocos de atividade",
"input.privacy.typingSamples": "Amostras de digitação",
"input.privacy.personalPhrases": "Frases pessoais",
"input.privacy.suggestions": "Registros de sugestões",
"input.privacy.days": "{{days}} dias",
"input.privacy.untilDeleted": "Até excluir",
"input.exclusion.recommendation": "{{app}} teve {{samples}} entradas ilegíveis repetidas ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "repetidamente ilegível",
"input.exclusion.reason.repeated-empty": "repetidamente vazia",
"input.exclusion.addButton": "Adicionar {{app}} às exclusões",
"input.feedback.recommendationSaved": "{{app}} foi adicionado às exclusões.",
"popup.suggestion.sourceModel": "Modelo local",
"popup.suggestion.sourceMemory": "Memória local",
"popup.suggestion.continuations": "continuações",
"popup.suggestion.related": "relacionadas",
"popup.suggestion.phrases": "frases",
"popup.suggestion.appPhrases": "frases do app",
"input.privacy.typingSamplesRetention": "Retenção de amostras de texto digitado",
"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."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Введите сочетание клавиш (например: Ctrl+Shift+Q) или одну клавишу (например: F5)",
"keybinding.ui.recordAgain": "Ввести снова",
"keybinding.ui.saveFailed": "Не удалось сохранить сочетание клавиш",
"keybinding.ui.globalEnabled": "Использовать глобальные горячие клавиши"
"keybinding.ui.globalEnabled": "Использовать глобальные горячие клавиши",
"input.tab": "Ввод",
"input.consent.title": "Сбор данных о вводе",
"input.consent.description": "Необязательно. Мы считаем нажатия клавиш и щелчки и измеряем путь мыши, чтобы показать ваши привычки ввода. Какие именно клавиши вы нажимаете, не сохраняется.",
"input.consent.collect": "Собирать статистику клавиатуры и мыши",
"input.consent.running": "Идёт сбор.",
"input.consent.stopped": "Сбор отключён. Ничего не записывается.",
"input.consent.pause": "Приостановить сбор",
"input.consent.learnText": "Изучать текст, который я набираю",
"input.consent.learnTextHint": "Читает активное поле ввода, чтобы изучить ваши формулировки. Поля паролей всегда пропускаются.",
"input.consent.excludedApps": "Исключённые приложения",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Имена исполняемых файлов через запятую. В этих приложениях ничего не читается и не считается.",
"input.consent.clearAll": "Удалить собранные данные",
"input.suggestion.title": "Подсказки следующего предложения",
"input.suggestion.description": "Предсказывает предложение, которое вы собираетесь написать, и показывает его рядом с курсором. Примите, чтобы вставить.",
"input.suggestion.enabled": "Показывать подсказки при вводе",
"input.suggestion.modelReady": "Локальная модель готова.",
"input.suggestion.modelMissing": "Локальная модель не запущена — подсказки отключены.",
"input.suggestion.delay": "Задержка (мс)",
"input.suggestion.minPrefix": "Минимум символов",
"input.suggestion.modelDefault": "Использовать модель по умолчанию",
"input.suggestion.overlayInteractive": "Разрешить клик по окну подсказки",
"input.suggestion.overlayVisible": "Подсказка на экране",
"input.suggestion.keyHint": "Принять, переключить и закрыть — глобальные сочетания клавиш; измените их в Настройки > Сочетания.",
"input.suggestion.requestNow": "Подсказать сейчас",
"input.suggestion.usage": "Сегодня использовано {{requests}} из {{budget}} запросов",
"input.suggestion.lastSkip": "Последняя причина пропуска: {{reason}}",
"input.insights.title": "Последние {{days}} дней",
"input.insights.keystrokes": "Нажатия",
"input.insights.clicks": "Щелчки",
"input.insights.words": "Набрано слов",
"input.insights.sentences": "Предложения",
"input.insights.mouseDistance": "Путь мыши",
"input.insights.perDayMeters": " м/день",
"input.insights.phrases": "Личные фразы",
"input.insights.empty": "Активность пока не собрана.",
"input.insights.topApps": "Где вы печатаете больше всего",
"input.insights.appRow": "{{keystrokes}} клавиш · {{clicks}} щелчков",
"input.insights.privacyNote": "На этом устройстве хранятся только счётчики и расстояния. Содержимое нажатий никогда не сохраняется.",
"input.phrases.title": "Личные фразы",
"input.phrases.description": "Фразы, изученные из вашего набора текста и диктовки, используются для подстройки под стиль.",
"input.phrases.empty": "Пока ничего не изучено.",
"input.phrases.delete": "Удалить фразу",
"input.feedback.excludedSaved": "Исключения сохранены.",
"input.feedback.cleared": "Собранные данные удалены.",
"popup.suggestion.hintAccept": "Принять",
"popup.suggestion.hintNext": "Далее",
"popup.suggestion.hintDismiss": "Закрыть",
"popup.suggestion.loading": "Генерация…",
"keybinding.ui.sectionInput": "Подсказки ввода",
"keybinding.action.suggestionAccept": "Принять подсказку",
"keybinding.action.suggestionAccept.desc": "Вставляет предложенную фразу в активное приложение.",
"keybinding.action.suggestionNext": "Следующая подсказка",
"keybinding.action.suggestionNext.desc": "Переходит к следующему варианту.",
"keybinding.action.suggestionDismiss": "Закрыть подсказку",
"keybinding.action.suggestionDismiss.desc": "Скрывает текущую подсказку.",
"input.suggestion.timeout": "Лимит ответа (мс)",
"input.suggestion.latency": "Последний ответ: {{ms}} мс",
"input.suggestion.slowWarning": "Медленнее набора — попробуйте меньшую модель.",
"input.diagnostics.title": "Диагностика",
"input.diagnostics.app": "Активное приложение: {{app}}",
"input.diagnostics.unknownApp": "(неизвестно)",
"input.diagnostics.noSnapshot": "Поле ещё не прочитано — щёлкните по текстовому полю и наберите текст.",
"input.diagnostics.readable": "Поле читается ({{source}}, {{length}} символов) — подсказки возможны.",
"input.diagnostics.notReadable": "Приложение не отдаёт текст поля — прочитать нельзя, подсказка не создаётся.",
"input.diagnostics.password": "Поле пароля — чтение блокируется по замыслу.",
"input.diagnostics.caretFallback": "Позиция курсора недоступна; используется конец документа.",
"input.diagnostics.composing": "Идёт композиция IME — подсказка появится чуть позже.",
"input.suggestion.onScreenLabel": "Состояние оверлея",
"input.suggestion.onScreen": "На экране",
"input.suggestion.offScreen": "Скрыт",
"input.suggestion.generating": "Генерация…",
"input.kbView.knowledge": "База знаний",
"input.kbView.insights": "Аналитика ввода",
"input.insights.tabs.overview": "Обзор",
"input.insights.tabs.keyboard": "Клавиатура",
"input.insights.tabs.mouse": "Мышь",
"input.insights.tabs.apps": "Приложения",
"input.insights.tabs.phrases": "Фразы · подсказки",
"input.insights.chars": "Введено символов",
"input.insights.wordChars": "Символы",
"input.insights.backspaces": "Удаления",
"input.insights.shortcuts": "Горячие клавиши",
"input.insights.doubleClicks": "Двойные клики",
"input.insights.scrollTicks": "Прокрутка",
"input.insights.activeMinutes": "Активное время",
"input.insights.activeDays": "Дней записано",
"input.insights.streak": "Макс. серия",
"input.insights.peakDay": "Самый активный день",
"input.insights.perDayAverage": "В среднем за день",
"input.insights.mouseDistanceTotal": "Общий путь мыши",
"input.insights.hourlyTitle": "По часам (0-23)",
"input.insights.dailyTitle": "Динамика по дням",
"input.insights.wordsDailyTitle": "Слов в день",
"input.insights.distanceDailyTitle": "Путь мыши за день",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Подсказки",
"input.insights.suggestionsTotal": "Создано",
"input.insights.suggestionsAccepted": "Принято",
"input.insights.acceptRate": "Доля принятых",
"input.insights.avgLatency": "Средний отклик",
"input.insights.suggestionHistory": "Последние подсказки",
"input.insights.accepted": "принята",
"input.insights.notAccepted": "не принята",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Период",
"input.insights.rangeDays": "{{days}} дн.",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Подробная статистика: База знаний > Аналитика ввода.",
"input.chart.max": "макс. {{value}}",
"input.chart.noData": "Данных пока нет.",
"input.unit.perDay": "/день",
"input.unit.perDayMeters": "м/день",
"input.unit.meters": "м",
"input.unit.ms": "мс",
"input.unit.percent": "%",
"popup.suggestion.warming": "Подготовка модели…",
"popup.suggestion.hintGenerating": "Генерация…",
"keybinding.action.suggestionPrev": "Предыдущая подсказка",
"keybinding.action.suggestionPrev.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": "Узлы-предложения",
"input.graph.followsEdges": "Связи «продолжение»",
"input.graph.sharesEdges": "Связи по терминам",
"input.graph.topEdges": "Сильнейшие связи",
"input.graph.recentNodes": "Недавние узлы",
"input.graph.searchLabel": "Поиск предложения",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Найти",
"input.graph.neighbors": "Связанные предложения",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "продолжение",
"input.graph.kindShares": "общие термины",
"popup.suggestion.generating": "Генерация… ({{seconds}} с)",
"keybinding.ui.auditClear": "Нет конфликтов или недопустимых сочетаний клавиш.",
"keybinding.ui.auditIssues": "Проблем с сочетаниями: {{count}}",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} конфликтует с {{conflicts}}",
"keybinding.ui.auditUnknown": "Неизвестная проблема",
"input.flow.title": "Вероятные часы концентрации",
"input.flow.empty": "Пока недостаточно почасовых данных ввода.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Оценка {{score}} · {{minutes}} активных мин/день · {{friction}}% правок",
"input.friction.title": "Трение редактирования",
"input.friction.value": "{{count}} на 100 символов",
"input.friction.band.steady": "Стабильно",
"input.friction.band.watch": "Наблюдать",
"input.friction.band.high": "Высокое",
"input.appQuality.title": "Качество подсказок по приложениям",
"input.appQuality.empty": "Пока нет истории подсказок по приложениям.",
"input.appQuality.row": "Принято {{accepted}}/{{total}} · {{rate}}% · {{latency}} мс",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Общее",
"input.privacy.title": "Квитанция локальных данных",
"input.privacy.localOnly": "Эти данные остаются на этом устройстве.",
"input.privacy.rawKeys": "Отдельные события и коды клавиш не сохраняются. Образцы изученного введённого текста указаны ниже отдельно.",
"input.privacy.activityRetention": "Хранение активности",
"input.privacy.suggestionRetention": "Хранение подсказок",
"input.privacy.learnedRetention": "Хранение изученного текста",
"input.privacy.activityBuckets": "Блоки активности",
"input.privacy.typingSamples": "Образцы ввода",
"input.privacy.personalPhrases": "Личные фразы",
"input.privacy.suggestions": "Записи подсказок",
"input.privacy.days": "{{days}} дней",
"input.privacy.untilDeleted": "До удаления",
"input.exclusion.recommendation": "В {{app}} было {{samples}} повторных нечитаемых вводов ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "повторно нечитаемо",
"input.exclusion.reason.repeated-empty": "повторно пусто",
"input.exclusion.addButton": "Добавить {{app}} в исключения",
"input.feedback.recommendationSaved": "{{app}} добавлено в исключения.",
"popup.suggestion.sourceModel": "Локальная модель",
"popup.suggestion.sourceMemory": "Локальная память",
"popup.suggestion.continuations": "продолжения",
"popup.suggestion.related": "связанные",
"popup.suggestion.phrases": "фразы",
"popup.suggestion.appPhrases": "фразы приложения",
"input.privacy.typingSamplesRetention": "Хранение образцов введённого текста",
"input.privacy.unavailable": "Квитанция локальных данных сейчас недоступна. Количества и сроки хранения не показаны.",
"input.feedback.clearFailed": "Не удалось удалить собранные данные.",
"input.feedback.excludedFailed": "Не удалось сохранить исключения.",
"input.feedback.recommendationFailed": "Не удалось добавить {{app}} в исключения."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "กรอกปุ่มลัด (เช่น: Ctrl+Shift+Q) หรือปุ่มเดี่ยว (เช่น: F5)",
"keybinding.ui.recordAgain": "กรอกใหม่",
"keybinding.ui.saveFailed": "บันทึกปุ่มลัดไม่สำเร็จ",
"keybinding.ui.globalEnabled": "ใช้ปุ่มลัดส่วนกลาง"
"keybinding.ui.globalEnabled": "ใช้ปุ่มลัดส่วนกลาง",
"input.tab": "การป้อนข้อมูล",
"input.consent.title": "การเก็บข้อมูลการป้อน",
"input.consent.description": "เป็นตัวเลือก เรานับจำนวนการกดปุ่มและการคลิก และวัดระยะทางที่เมาส์เคลื่อนที่ เพื่อแสดงรูปแบบการพิมพ์ของคุณ เราไม่เก็บว่าคุณกดปุ่มใด",
"input.consent.collect": "เก็บสถิติคีย์บอร์ดและเมาส์",
"input.consent.running": "กำลังเก็บข้อมูล",
"input.consent.stopped": "ปิดการเก็บข้อมูลอยู่ ไม่มีการบันทึกใด ๆ",
"input.consent.pause": "หยุดเก็บชั่วคราว",
"input.consent.learnText": "เรียนรู้ข้อความที่ฉันพิมพ์",
"input.consent.learnTextHint": "อ่านช่องข้อความที่โฟกัสอยู่เพื่อเรียนสำนวนของคุณ ช่องรหัสผ่านจะถูกข้ามเสมอ",
"input.consent.excludedApps": "แอปที่ยกเว้น",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "ชื่อไฟล์เรียกทำงาน คั่นด้วยจุลภาค ในแอปเหล่านี้จะไม่อ่านและไม่นับสิ่งใด",
"input.consent.clearAll": "ลบข้อมูลที่เก็บไว้",
"input.suggestion.title": "คำแนะนำประโยคถัดไป",
"input.suggestion.description": "คาดการณ์ประโยคที่คุณกำลังจะพิมพ์และแสดงข้างเคอร์เซอร์ กดยอมรับเพื่อแทรก",
"input.suggestion.enabled": "แสดงคำแนะนำขณะพิมพ์",
"input.suggestion.modelReady": "โมเดลท้องถิ่นพร้อมใช้งาน",
"input.suggestion.modelMissing": "โมเดลท้องถิ่นไม่ได้ทำงาน — ปิดคำแนะนำอยู่",
"input.suggestion.delay": "ความหน่วง (ms)",
"input.suggestion.minPrefix": "จำนวนตัวอักษรขั้นต่ำ",
"input.suggestion.modelDefault": "ใช้โมเดลเริ่มต้น",
"input.suggestion.overlayInteractive": "อนุญาตให้คลิกกล่องคำแนะนำ",
"input.suggestion.overlayVisible": "คำแนะนำแสดงอยู่",
"input.suggestion.keyHint": "ยอมรับ เปลี่ยน และปิด เป็นคีย์ลัดส่วนกลาง — แก้ไขได้ที่ ตั้งค่า > คีย์ลัด",
"input.suggestion.requestNow": "ขอคำแนะนำตอนนี้",
"input.suggestion.usage": "วันนี้ใช้ไป {{requests}}/{{budget}} ครั้ง",
"input.suggestion.lastSkip": "เหตุผลที่ข้ามล่าสุด: {{reason}}",
"input.insights.title": "{{days}} วันล่าสุด",
"input.insights.keystrokes": "การกดปุ่ม",
"input.insights.clicks": "การคลิก",
"input.insights.words": "คำที่พิมพ์",
"input.insights.sentences": "ประโยค",
"input.insights.mouseDistance": "ระยะทางเมาส์",
"input.insights.perDayMeters": " ม./วัน",
"input.insights.phrases": "วลีส่วนตัว",
"input.insights.empty": "ยังไม่มีกิจกรรมที่เก็บไว้",
"input.insights.topApps": "แอปที่คุณพิมพ์มากที่สุด",
"input.insights.appRow": "{{keystrokes}} ปุ่ม · {{clicks}} คลิก",
"input.insights.privacyNote": "อุปกรณ์นี้เก็บเฉพาะตัวนับและระยะทาง ไม่มีการบันทึกเนื้อหาปุ่ม",
"input.phrases.title": "วลีส่วนตัว",
"input.phrases.description": "ประโยคที่เรียนรู้จากการพิมพ์และการถอดเสียง ใช้เพื่อให้ตรงกับสำนวนของคุณ",
"input.phrases.empty": "ยังไม่มีสิ่งที่เรียนรู้",
"input.phrases.delete": "ลบวลี",
"input.feedback.excludedSaved": "บันทึกรายการยกเว้นแล้ว",
"input.feedback.cleared": "ลบข้อมูลที่เก็บไว้แล้ว",
"popup.suggestion.hintAccept": "ยอมรับ",
"popup.suggestion.hintNext": "ถัดไป",
"popup.suggestion.hintDismiss": "ปิด",
"popup.suggestion.loading": "กำลังสร้าง…",
"keybinding.ui.sectionInput": "คำแนะนำการป้อน",
"keybinding.action.suggestionAccept": "ยอมรับคำแนะนำ",
"keybinding.action.suggestionAccept.desc": "แทรกประโยคที่แนะนำลงในแอปที่โฟกัสอยู่",
"keybinding.action.suggestionNext": "คำแนะนำถัดไป",
"keybinding.action.suggestionNext.desc": "เปลี่ยนไปยังตัวเลือกถัดไป",
"keybinding.action.suggestionDismiss": "ปิดคำแนะนำ",
"keybinding.action.suggestionDismiss.desc": "ซ่อนคำแนะนำปัจจุบัน",
"input.suggestion.timeout": "ขีดจำกัดการตอบ (ms)",
"input.suggestion.latency": "การตอบครั้งก่อน: {{ms}} มิลลิวินาที",
"input.suggestion.slowWarning": "ช้ากว่าการพิมพ์ — ลองโมเดลเล็กลง",
"input.diagnostics.title": "ตรวจสอบสด",
"input.diagnostics.app": "แอปที่โฟกัส: {{app}}",
"input.diagnostics.unknownApp": "(ไม่ทราบ)",
"input.diagnostics.noSnapshot": "ยังไม่ได้อ่านช่องใด ๆ — คลิกช่องข้อความแล้วพิมพ์",
"input.diagnostics.readable": "อ่านช่องได้ ({{source}}, {{length}} ตัวอักษร) — คำแนะนำทำงานได้",
"input.diagnostics.notReadable": "แอปนี้ไม่เปิดเผยช่องข้อความ — อ่านไม่ได้ จึงไม่สร้างคำแนะนำ",
"input.diagnostics.password": "ช่องรหัสผ่าน — บล็อกการอ่านตามการออกแบบ",
"input.diagnostics.caretFallback": "ไม่ทราบตำแหน่งเคอร์เซอร์ ใช้ปลายเอกสารแทน",
"input.diagnostics.composing": "กำลังประกอบอักษร IME — คำแนะนำจะปรากฏช้าลงเล็กน้อย",
"input.suggestion.onScreenLabel": "สถานะเลเยอร์",
"input.suggestion.onScreen": "แสดงอยู่",
"input.suggestion.offScreen": "ซ่อนอยู่",
"input.suggestion.generating": "กำลังสร้าง…",
"input.kbView.knowledge": "ฐานความรู้",
"input.kbView.insights": "ข้อมูลเชิงลึกการพิมพ์",
"input.insights.tabs.overview": "ภาพรวม",
"input.insights.tabs.keyboard": "คีย์บอร์ด",
"input.insights.tabs.mouse": "เมาส์",
"input.insights.tabs.apps": "แอป",
"input.insights.tabs.phrases": "วลี · คำแนะนำ",
"input.insights.chars": "ตัวอักษรที่พิมพ์",
"input.insights.wordChars": "ตัวอักษร",
"input.insights.backspaces": "ลบถอยหลัง",
"input.insights.shortcuts": "คีย์ลัด",
"input.insights.doubleClicks": "ดับเบิลคลิก",
"input.insights.scrollTicks": "การเลื่อน",
"input.insights.activeMinutes": "เวลาที่ใช้งาน",
"input.insights.activeDays": "จำนวนวันที่บันทึก",
"input.insights.streak": "ต่อเนื่องสูงสุด",
"input.insights.peakDay": "วันที่พิมพ์มากสุด",
"input.insights.perDayAverage": "เฉลี่ยต่อวัน",
"input.insights.mouseDistanceTotal": "ระยะเมาส์รวม",
"input.insights.hourlyTitle": "ตามชั่วโมง (0-23)",
"input.insights.dailyTitle": "แนวโน้มรายวัน",
"input.insights.wordsDailyTitle": "คำต่อวัน",
"input.insights.distanceDailyTitle": "ระยะเมาส์ต่อวัน",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "คำแนะนำ",
"input.insights.suggestionsTotal": "สร้างแล้ว",
"input.insights.suggestionsAccepted": "ยอมรับ",
"input.insights.acceptRate": "อัตราการยอมรับ",
"input.insights.avgLatency": "การตอบสนองเฉลี่ย",
"input.insights.suggestionHistory": "คำแนะนำล่าสุด",
"input.insights.accepted": "ยอมรับ",
"input.insights.notAccepted": "ไม่ยอมรับ",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "ช่วง",
"input.insights.rangeDays": "{{days}} วัน",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "สถิติโดยละเอียดอยู่ที่ ฐานความรู้ > ข้อมูลเชิงลึกการพิมพ์",
"input.chart.max": "สูงสุด {{value}}",
"input.chart.noData": "ยังไม่มีข้อมูล",
"input.unit.perDay": "/วัน",
"input.unit.perDayMeters": "ม./วัน",
"input.unit.meters": "ม.",
"input.unit.ms": "มิลลิวินาที",
"input.unit.percent": "%",
"popup.suggestion.warming": "กำลังเตรียมโมเดล…",
"popup.suggestion.hintGenerating": "กำลังสร้าง…",
"keybinding.action.suggestionPrev": "คำแนะนำก่อนหน้า",
"keybinding.action.suggestionPrev.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": "โหนดประโยค",
"input.graph.followsEdges": "ความเชื่อมต่อต่อเนื่อง",
"input.graph.sharesEdges": "ความเชื่อมต่อตามคำ",
"input.graph.topEdges": "ความเชื่อมต่อที่แข็งแรง",
"input.graph.recentNodes": "โหนดล่าสุด",
"input.graph.searchLabel": "ค้นหาประโยค",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "ค้นหา",
"input.graph.neighbors": "ประโยคที่เชื่อมกัน",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "ต่อเนื่อง",
"input.graph.kindShares": "ใช้คำร่วม",
"popup.suggestion.generating": "กำลังสร้าง… ({{seconds}} วินาที)",
"keybinding.ui.auditClear": "ไม่มีทางลัดที่ขัดกันหรือการตั้งค่าที่ไม่ถูกต้อง",
"keybinding.ui.auditIssues": "ปัญหาทางลัด {{count}} รายการ",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} ขัดกันกับ {{conflicts}}",
"keybinding.ui.auditUnknown": "ปัญหาที่ไม่ทราบสาเหตุ",
"input.flow.title": "ช่วงเวลาที่น่าจะมีสมาธิ",
"input.flow.empty": "ยังมีข้อมูลการพิมพ์รายชั่วโมงไม่เพียงพอ",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "คะแนน {{score}} · ใช้งาน {{minutes}} นาที/วัน · แก้ไข {{friction}}%",
"input.friction.title": "ความฝืดในการแก้ไข",
"input.friction.value": "{{count}} ต่อ 100 อักขระ",
"input.friction.band.steady": "คงที่",
"input.friction.band.watch": "เฝ้าดู",
"input.friction.band.high": "สูง",
"input.appQuality.title": "คุณภาพคำแนะนำตามแอป",
"input.appQuality.empty": "ยังไม่มีประวัติคำแนะนำตามแอป",
"input.appQuality.row": "ยอมรับ {{accepted}}/{{total}} · {{rate}}% · {{latency}} มิลลิวินาที",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "ร่วมกัน",
"input.privacy.title": "ใบรับข้อมูลในเครื่อง",
"input.privacy.localOnly": "ข้อมูลนี้อยู่บนอุปกรณ์นี้เท่านั้น",
"input.privacy.rawKeys": "จะไม่เก็บเหตุการณ์คีย์แต่ละรายการหรือรหัสคีย์ดิบ ตัวอย่างข้อความที่เรียนรู้จะแสดงแยกด้านล่าง",
"input.privacy.activityRetention": "การเก็บข้อมูลกิจกรรม",
"input.privacy.suggestionRetention": "การเก็บคำแนะนำ",
"input.privacy.learnedRetention": "การเก็บข้อความที่เรียนรู้",
"input.privacy.activityBuckets": "กลุ่มกิจกรรม",
"input.privacy.typingSamples": "ตัวอย่างการพิมพ์",
"input.privacy.personalPhrases": "วลีส่วนตัว",
"input.privacy.suggestions": "บันทึกคำแนะนำ",
"input.privacy.days": "{{days}} วัน",
"input.privacy.untilDeleted": "จนกว่าจะลบ",
"input.exclusion.recommendation": "{{app}} มีการป้อนข้อมูลที่อ่านไม่ได้ซ้ำ {{samples}} ครั้ง ({{reason}})",
"input.exclusion.reason.repeated-unreadable": "อ่านไม่ได้ซ้ำ",
"input.exclusion.reason.repeated-empty": "ว่างซ้ำ",
"input.exclusion.addButton": "เพิ่ม {{app}} ในรายการยกเว้น",
"input.feedback.recommendationSaved": "เพิ่ม {{app}} ในรายการยกเว้นแล้ว",
"popup.suggestion.sourceModel": "โมเดลในเครื่อง",
"popup.suggestion.sourceMemory": "ความจำในเครื่อง",
"popup.suggestion.continuations": "ข้อความต่อ",
"popup.suggestion.related": "ที่เกี่ยวข้อง",
"popup.suggestion.phrases": "วลี",
"popup.suggestion.appPhrases": "วลีของแอป",
"input.privacy.typingSamplesRetention": "การเก็บตัวอย่างข้อความที่พิมพ์",
"input.privacy.unavailable": "ใบรับข้อมูลในเครื่องไม่พร้อมใช้งานขณะนี้ จึงไม่แสดงจำนวนและระยะเวลาเก็บข้อมูล",
"input.feedback.clearFailed": "ไม่สามารถลบข้อมูลที่เก็บรวบรวมได้",
"input.feedback.excludedFailed": "ไม่สามารถบันทึกรายการยกเว้นได้",
"input.feedback.recommendationFailed": "ไม่สามารถเพิ่ม {{app}} ในรายการยกเว้นได้"
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "Nhập tổ hợp phím (ví dụ: Ctrl+Shift+Q) hoặc phím đơn (ví dụ: F5)",
"keybinding.ui.recordAgain": "Nhập lại",
"keybinding.ui.saveFailed": "Không thể lưu phím tắt",
"keybinding.ui.globalEnabled": "Bật phím tắt toàn cục"
"keybinding.ui.globalEnabled": "Bật phím tắt toàn cục",
"input.tab": "Nhập liệu",
"input.consent.title": "Thu thập dữ liệu nhập liệu",
"input.consent.description": "Tùy chọn. Chúng tôi đếm số lần gõ phím và nhấp chuột, đo quãng đường di chuyển của chuột để hiển thị thói quen nhập liệu của bạn. Nội dung phím bạn gõ không bao giờ được lưu.",
"input.consent.collect": "Thu thập thống kê bàn phím và chuột",
"input.consent.running": "Đang thu thập.",
"input.consent.stopped": "Tính năng thu thập đang tắt. Không có gì được ghi lại.",
"input.consent.pause": "Tạm dừng thu thập",
"input.consent.learnText": "Học văn bản tôi gõ",
"input.consent.learnTextHint": "Đọc ô nhập liệu đang được chọn để học cách diễn đạt của bạn. Ô mật khẩu luôn bị bỏ qua.",
"input.consent.excludedApps": "Ứng dụng loại trừ",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "Tên tệp thực thi, cách nhau bằng dấu phẩy. Không đọc và không đếm gì trong các ứng dụng này.",
"input.consent.clearAll": "Xóa dữ liệu đã thu thập",
"input.suggestion.title": "Gợi ý câu tiếp theo",
"input.suggestion.description": "Dự đoán câu bạn sắp viết và hiển thị bên cạnh con trỏ. Chấp nhận để chèn.",
"input.suggestion.enabled": "Hiện gợi ý khi đang gõ",
"input.suggestion.modelReady": "Mô hình cục bộ đã sẵn sàng.",
"input.suggestion.modelMissing": "Mô hình cục bộ chưa chạy — gợi ý tạm tắt.",
"input.suggestion.delay": "Độ trễ (ms)",
"input.suggestion.minPrefix": "Số ký tự tối thiểu",
"input.suggestion.modelDefault": "Dùng mô hình mặc định",
"input.suggestion.overlayInteractive": "Cho phép nhấp vào hộp gợi ý",
"input.suggestion.overlayVisible": "Gợi ý đang hiển thị",
"input.suggestion.keyHint": "Chấp nhận, chuyển và đóng là phím tắt toàn cục — đổi trong Cài đặt > Phím tắt.",
"input.suggestion.requestNow": "Gợi ý ngay",
"input.suggestion.usage": "Hôm nay đã dùng {{requests}}/{{budget}} lượt",
"input.suggestion.lastSkip": "Lý do bỏ qua gần nhất: {{reason}}",
"input.insights.title": "{{days}} ngày gần đây",
"input.insights.keystrokes": "Lần gõ phím",
"input.insights.clicks": "Lần nhấp",
"input.insights.words": "Từ đã gõ",
"input.insights.sentences": "Câu",
"input.insights.mouseDistance": "Quãng đường chuột",
"input.insights.perDayMeters": " m/ngày",
"input.insights.phrases": "Cụm từ cá nhân",
"input.insights.empty": "Chưa thu thập hoạt động nào.",
"input.insights.topApps": "Nơi bạn gõ nhiều nhất",
"input.insights.appRow": "{{keystrokes}} phím · {{clicks}} nhấp",
"input.insights.privacyNote": "Thiết bị này chỉ lưu số đếm và khoảng cách. Nội dung phím không bao giờ được lưu.",
"input.phrases.title": "Cụm từ cá nhân",
"input.phrases.description": "Các câu học từ việc gõ và đọc chính tả của bạn, dùng để khớp với văn phong.",
"input.phrases.empty": "Chưa học được gì.",
"input.phrases.delete": "Xóa cụm từ",
"input.feedback.excludedSaved": "Đã lưu danh sách loại trừ.",
"input.feedback.cleared": "Đã xóa dữ liệu thu thập.",
"popup.suggestion.hintAccept": "Chấp nhận",
"popup.suggestion.hintNext": "Tiếp",
"popup.suggestion.hintDismiss": "Đóng",
"popup.suggestion.loading": "Đang tạo…",
"keybinding.ui.sectionInput": "Gợi ý nhập liệu",
"keybinding.action.suggestionAccept": "Chấp nhận gợi ý",
"keybinding.action.suggestionAccept.desc": "Chèn câu gợi ý vào ứng dụng đang được chọn.",
"keybinding.action.suggestionNext": "Gợi ý tiếp theo",
"keybinding.action.suggestionNext.desc": "Chuyển sang ứng viên tiếp theo.",
"keybinding.action.suggestionDismiss": "Đóng gi ý",
"keybinding.action.suggestionDismiss.desc": "Ẩn gợi ý hiện tại.",
"input.suggestion.timeout": "Giới hạn phản hồi (ms)",
"input.suggestion.latency": "Phản hồi trước: {{ms}} ms",
"input.suggestion.slowWarning": "Chậm hơn tốc độ gõ — thử mô hình nhỏ hơn.",
"input.diagnostics.title": "Chẩn đoán trực tiếp",
"input.diagnostics.app": "Ứng dụng đang chọn: {{app}}",
"input.diagnostics.unknownApp": "(không rõ)",
"input.diagnostics.noSnapshot": "Chưa đọc được ô nhập nào — hãy nhấp vào ô văn bản và gõ.",
"input.diagnostics.readable": "Đọc được ô nhập ({{source}}, {{length}} ký tự) — gợi ý có thể chạy.",
"input.diagnostics.notReadable": "Ứng dụng này không công bố ô nhập — không đọc được nên không tạo gợi ý.",
"input.diagnostics.password": "Ô mật khẩu — chặn đọc theo thiết kế.",
"input.diagnostics.caretFallback": "Không có vị trí con trỏ; dùng cuối tài liệu.",
"input.diagnostics.composing": "Đang tổ hợp IME — gợi ý sẽ xuất hiện muộn hơn một chút.",
"input.suggestion.onScreenLabel": "Trạng thái lớp phủ",
"input.suggestion.onScreen": "Đang hiện",
"input.suggestion.offScreen": "Đang ẩn",
"input.suggestion.generating": "Đang tạo…",
"input.kbView.knowledge": "Kho tri thức",
"input.kbView.insights": "Phân tích nhập liệu",
"input.insights.tabs.overview": "Tổng quan",
"input.insights.tabs.keyboard": "Bàn phím",
"input.insights.tabs.mouse": "Chuột",
"input.insights.tabs.apps": "Ứng dụng",
"input.insights.tabs.phrases": "Cụm từ · gợi ý",
"input.insights.chars": "Ký tự đã gõ",
"input.insights.wordChars": "Ký tự",
"input.insights.backspaces": "Xóa lùi",
"input.insights.shortcuts": "Phím tắt",
"input.insights.doubleClicks": "Nháy đúp",
"input.insights.scrollTicks": "Cuộn",
"input.insights.activeMinutes": "Thời gian hoạt động",
"input.insights.activeDays": "Số ngày ghi nhận",
"input.insights.streak": "Chuỗi dài nhất",
"input.insights.peakDay": "Ngày nhiều nhất",
"input.insights.perDayAverage": "Trung bình mỗi ngày",
"input.insights.mouseDistanceTotal": "Tổng quãng đường chuột",
"input.insights.hourlyTitle": "Theo giờ (0-23)",
"input.insights.dailyTitle": "Xu hướng theo ngày",
"input.insights.wordsDailyTitle": "Từ mỗi ngày",
"input.insights.distanceDailyTitle": "Quãng đường chuột mỗi ngày",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "Gợi ý",
"input.insights.suggestionsTotal": "Đã tạo",
"input.insights.suggestionsAccepted": "Đã chấp nhận",
"input.insights.acceptRate": "Tỷ lệ chấp nhận",
"input.insights.avgLatency": "Phản hồi trung bình",
"input.insights.suggestionHistory": "Gợi ý gần đây",
"input.insights.accepted": "đã chấp nhận",
"input.insights.notAccepted": "chưa chấp nhận",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "Khoảng",
"input.insights.rangeDays": "{{days}} ngày",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "Thống kê chi tiết nằm ở Kho tri thức > Phân tích nhập liệu.",
"input.chart.max": "tối đa {{value}}",
"input.chart.noData": "Chưa có dữ liệu.",
"input.unit.perDay": "/ngày",
"input.unit.perDayMeters": "m/ngày",
"input.unit.meters": "m",
"input.unit.ms": "ms",
"input.unit.percent": "%",
"popup.suggestion.warming": "Đang chuẩn bị mô hình…",
"popup.suggestion.hintGenerating": "Đang tạo…",
"keybinding.action.suggestionPrev": "Gợi ý trước",
"keybinding.action.suggestionPrev.desc": "Chuyển về ứng viên 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",
"input.graph.followsEdges": "Liên kết tiếp nối",
"input.graph.sharesEdges": "Liên kết theo từ",
"input.graph.topEdges": "Liên kết mạnh nhất",
"input.graph.recentNodes": "Nút gần đây",
"input.graph.searchLabel": "Tìm câu",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "Tìm",
"input.graph.neighbors": "Câu liên kết",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "tiếp nối",
"input.graph.kindShares": "chia sẻ từ",
"popup.suggestion.generating": "Đang tạo… ({{seconds}} giây)",
"keybinding.ui.auditClear": "Không có xung đột hay cài đặt phím tắt không hợp lệ.",
"keybinding.ui.auditIssues": "{{count}} vấn đề về phím tắt",
"keybinding.ui.auditInvalid": "{{action}}: {{reason}}",
"keybinding.ui.auditConflict": "{{action}} xung đột với {{conflicts}}",
"keybinding.ui.auditUnknown": "Vấn đề không xác định",
"input.flow.title": "Khung giờ dễ tập trung",
"input.flow.empty": "Chưa đủ dữ liệu nhập theo giờ.",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "Điểm {{score}} · {{minutes}} phút hoạt động/ngày · {{friction}}% chỉnh sửa",
"input.friction.title": "Ma sát chỉnh sửa",
"input.friction.value": "{{count}} trên 100 ký tự",
"input.friction.band.steady": "Ổn định",
"input.friction.band.watch": "Theo dõi",
"input.friction.band.high": "Cao",
"input.appQuality.title": "Chất lượng gợi ý theo ứng dụng",
"input.appQuality.empty": "Chưa có lịch sử gợi ý theo ứng dụng.",
"input.appQuality.row": "Đã nhận {{accepted}}/{{total}} · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}× · {{app}}",
"input.phrases.appUnknown": "Dùng chung",
"input.privacy.title": "Biên nhận dữ liệu cục bộ",
"input.privacy.localOnly": "Dữ liệu này chỉ ở trên thiết bị này.",
"input.privacy.rawKeys": "Các sự kiện phím riêng lẻ và mã phím thô không được lưu giữ. Mẫu văn bản đã học được liệt kê riêng bên dưới.",
"input.privacy.activityRetention": "Lưu giữ hoạt động",
"input.privacy.suggestionRetention": "Lưu giữ gợi ý",
"input.privacy.learnedRetention": "Lưu giữ văn bản đã học",
"input.privacy.activityBuckets": "Nhóm hoạt động",
"input.privacy.typingSamples": "Mẫu nhập",
"input.privacy.personalPhrases": "Cụm từ cá nhân",
"input.privacy.suggestions": "Bản ghi gợi ý",
"input.privacy.days": "{{days}} ngày",
"input.privacy.untilDeleted": "Đến khi xóa",
"input.exclusion.recommendation": "{{app}} có {{samples}} lần nhập không đọc được lặp lại ({{reason}}).",
"input.exclusion.reason.repeated-unreadable": "không đọc được lặp lại",
"input.exclusion.reason.repeated-empty": "trống lặp lại",
"input.exclusion.addButton": "Thêm {{app}} vào loại trừ",
"input.feedback.recommendationSaved": "Đã thêm {{app}} vào loại trừ.",
"popup.suggestion.sourceModel": "Mô hình cục bộ",
"popup.suggestion.sourceMemory": "Bộ nhớ cục bộ",
"popup.suggestion.continuations": "tiếp nối",
"popup.suggestion.related": "liên quan",
"popup.suggestion.phrases": "cụm từ",
"popup.suggestion.appPhrases": "cụm từ ứng dụng",
"input.privacy.typingSamplesRetention": "Lưu giữ mẫu văn bản đã nhập",
"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ừ."
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "請輸入組合鍵(例如:Ctrl+Shift+Q)或單一鍵(例如:F5)",
"keybinding.ui.recordAgain": "重新輸入",
"keybinding.ui.saveFailed": "無法儲存快速鍵",
"keybinding.ui.globalEnabled": "啟用全域快速鍵"
"keybinding.ui.globalEnabled": "啟用全域快速鍵",
"input.tab": "輸入",
"input.consent.title": "輸入資料收集",
"input.consent.description": "可選功能。我們統計按鍵與點擊次數並測量滑鼠移動距離,用來顯示你的輸入模式。我們從不儲存你按下了哪些鍵。",
"input.consent.collect": "收集鍵盤與滑鼠統計",
"input.consent.running": "正在收集。",
"input.consent.stopped": "收集已關閉,不會記錄任何內容。",
"input.consent.pause": "暫停收集",
"input.consent.learnText": "學習我輸入的文字",
"input.consent.learnTextHint": "讀取目前聚焦的輸入框以學習你的用語。密碼欄位一律略過。",
"input.consent.excludedApps": "排除的應用程式",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "以逗號分隔的執行檔名稱。在這些應用程式中不讀取也不統計。",
"input.consent.clearAll": "刪除已收集的資料",
"input.suggestion.title": "下一句建議",
"input.suggestion.description": "預測你即將寫下的句子並顯示在游標旁。接受後即插入。",
"input.suggestion.enabled": "輸入時顯示建議",
"input.suggestion.modelReady": "本機模型已就緒。",
"input.suggestion.modelMissing": "本機模型未執行 — 建議保持關閉。",
"input.suggestion.delay": "延遲 (ms)",
"input.suggestion.minPrefix": "最少字元數",
"input.suggestion.modelDefault": "使用預設模型",
"input.suggestion.overlayInteractive": "允許點擊建議框",
"input.suggestion.overlayVisible": "建議正在顯示",
"input.suggestion.keyHint": "接受、切換與關閉都是全域快捷鍵 — 可在設定 > 快捷鍵中修改。",
"input.suggestion.requestNow": "立即建議",
"input.suggestion.usage": "今天已使用 {{requests}}/{{budget}} 次",
"input.suggestion.lastSkip": "上次略過原因:{{reason}}",
"input.insights.title": "最近 {{days}} 天",
"input.insights.keystrokes": "按鍵",
"input.insights.clicks": "點擊",
"input.insights.words": "輸入單字",
"input.insights.sentences": "句子",
"input.insights.mouseDistance": "滑鼠移動",
"input.insights.perDayMeters": " 公尺/天",
"input.insights.phrases": "個人短語",
"input.insights.empty": "尚無收集到的活動。",
"input.insights.topApps": "輸入最多的應用程式",
"input.insights.appRow": "按鍵 {{keystrokes}} · 點擊 {{clicks}}",
"input.insights.privacyNote": "本裝置僅儲存計數與距離。按鍵內容永不儲存。",
"input.phrases.title": "個人短語",
"input.phrases.description": "從你的輸入與聽寫中學習的句子,用於貼近你的表達習慣。",
"input.phrases.empty": "尚未學習任何內容。",
"input.phrases.delete": "刪除短語",
"input.feedback.excludedSaved": "已儲存排除清單。",
"input.feedback.cleared": "已刪除收集的資料。",
"popup.suggestion.hintAccept": "接受",
"popup.suggestion.hintNext": "下一個",
"popup.suggestion.hintDismiss": "關閉",
"popup.suggestion.loading": "產生中…",
"keybinding.ui.sectionInput": "輸入建議",
"keybinding.action.suggestionAccept": "接受建議",
"keybinding.action.suggestionAccept.desc": "將建議句插入目前應用程式。",
"keybinding.action.suggestionNext": "下一則建議",
"keybinding.action.suggestionNext.desc": "切換到下一個候選。",
"keybinding.action.suggestionDismiss": "關閉建議",
"keybinding.action.suggestionDismiss.desc": "隱藏目前的建議。",
"input.suggestion.timeout": "回應上限 (ms)",
"input.suggestion.latency": "上次回應:{{ms}} 毫秒",
"input.suggestion.slowWarning": "比輸入速度慢 — 試試更小的模型。",
"input.diagnostics.title": "即時診斷",
"input.diagnostics.app": "目前應用程式:{{app}}",
"input.diagnostics.unknownApp": "(未知)",
"input.diagnostics.noSnapshot": "尚未讀取輸入框 — 請點擊文字欄並輸入。",
"input.diagnostics.readable": "輸入框可讀取 ({{source}}, {{length}} 字元) — 建議可用。",
"input.diagnostics.notReadable": "此應用程式不公開輸入框文字 — 無法讀取,因此不產生建議。",
"input.diagnostics.password": "密碼欄位 — 依設計阻擋讀取。",
"input.diagnostics.caretFallback": "無法取得游標位置,改用文件結尾作為輸入位置。",
"input.diagnostics.composing": "IME 組合中 — 建議會稍後出現,不會被封鎖。",
"input.suggestion.onScreenLabel": "浮層狀態",
"input.suggestion.onScreen": "顯示中",
"input.suggestion.offScreen": "未顯示",
"input.suggestion.generating": "產生中…",
"input.kbView.knowledge": "知識庫",
"input.kbView.insights": "輸入洞察",
"input.insights.tabs.overview": "總覽",
"input.insights.tabs.keyboard": "鍵盤",
"input.insights.tabs.mouse": "滑鼠",
"input.insights.tabs.apps": "應用程式",
"input.insights.tabs.phrases": "短語 · 建議",
"input.insights.chars": "輸入字元",
"input.insights.wordChars": "字元",
"input.insights.backspaces": "退格",
"input.insights.shortcuts": "快速鍵",
"input.insights.doubleClicks": "雙擊",
"input.insights.scrollTicks": "捲動",
"input.insights.activeMinutes": "活躍時間",
"input.insights.activeDays": "記錄天數",
"input.insights.streak": "最長連續",
"input.insights.peakDay": "最多輸入日",
"input.insights.perDayAverage": "每日平均",
"input.insights.mouseDistanceTotal": "移動距離合計",
"input.insights.hourlyTitle": "依小時 (0–23)",
"input.insights.dailyTitle": "每日趨勢",
"input.insights.wordsDailyTitle": "每日單字",
"input.insights.distanceDailyTitle": "每日滑鼠移動",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "建議",
"input.insights.suggestionsTotal": "已產生",
"input.insights.suggestionsAccepted": "已接受",
"input.insights.acceptRate": "接受率",
"input.insights.avgLatency": "平均回應",
"input.insights.suggestionHistory": "最近建議",
"input.insights.accepted": "已接受",
"input.insights.notAccepted": "未接受",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "區間",
"input.insights.rangeDays": "{{days}} 天",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "詳細統計在 知識庫 > 輸入洞察。",
"input.chart.max": "最大 {{value}}",
"input.chart.noData": "尚無資料。",
"input.unit.perDay": "/天",
"input.unit.perDayMeters": "公尺/天",
"input.unit.meters": "公尺",
"input.unit.ms": "毫秒",
"input.unit.percent": "%",
"popup.suggestion.warming": "正在準備模型…",
"popup.suggestion.hintGenerating": "產生中…",
"keybinding.action.suggestionPrev": "上一則建議",
"keybinding.action.suggestionPrev.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": "句子節點",
"input.graph.followsEdges": "接續關係",
"input.graph.sharesEdges": "術語共享關係",
"input.graph.topEdges": "最強關聯",
"input.graph.recentNodes": "最近節點",
"input.graph.searchLabel": "搜尋句子",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "搜尋",
"input.graph.neighbors": "關聯句子",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "接續",
"input.graph.kindShares": "術語共享",
"popup.suggestion.generating": "產生中… ({{seconds}} 秒)",
"keybinding.ui.auditClear": "沒有快捷鍵衝突或無效設定。",
"keybinding.ui.auditIssues": "{{count}} 個快捷鍵問題",
"keybinding.ui.auditInvalid": "{{action}}:{{reason}}",
"keybinding.ui.auditConflict": "{{action}} 與 {{conflicts}} 衝突",
"keybinding.ui.auditUnknown": "未知問題",
"input.flow.title": "可能的專注時段",
"input.flow.empty": "按小時的輸入資料還不夠。",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "分數 {{score}} · 每天活躍 {{minutes}} 分鐘 · 編輯 {{friction}}%",
"input.friction.title": "編輯阻力",
"input.friction.value": "每 100 個字元 {{count}} 次",
"input.friction.band.steady": "穩定",
"input.friction.band.watch": "留意",
"input.friction.band.high": "高",
"input.appQuality.title": "依應用程式的建議品質",
"input.appQuality.empty": "還沒有依應用程式的建議記錄。",
"input.appQuality.row": "已接受 {{accepted}}/{{total}} · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}次 · {{app}}",
"input.phrases.appUnknown": "共用",
"input.privacy.title": "本機資料憑據",
"input.privacy.localOnly": "這些資料只保存在此裝置上。",
"input.privacy.rawKeys": "不會保留個別按鍵事件和原始鍵碼。學習文字樣本會在下方另外列出。",
"input.privacy.activityRetention": "活動保留期",
"input.privacy.suggestionRetention": "建議保留期",
"input.privacy.learnedRetention": "學習文字保留期",
"input.privacy.activityBuckets": "活動分桶",
"input.privacy.typingSamples": "輸入樣本",
"input.privacy.personalPhrases": "個人片語",
"input.privacy.suggestions": "建議記錄",
"input.privacy.days": "{{days}} 天",
"input.privacy.untilDeleted": "直到刪除",
"input.exclusion.recommendation": "{{app}} 出現了 {{samples}} 次重複不可讀輸入({{reason}})。",
"input.exclusion.reason.repeated-unreadable": "反覆不可讀",
"input.exclusion.reason.repeated-empty": "反覆為空",
"input.exclusion.addButton": "將 {{app}} 加入排除清單",
"input.feedback.recommendationSaved": "已將 {{app}} 加入排除清單。",
"popup.suggestion.sourceModel": "本機模型",
"popup.suggestion.sourceMemory": "本機記憶",
"popup.suggestion.continuations": "續寫",
"popup.suggestion.related": "相關",
"popup.suggestion.phrases": "片語",
"popup.suggestion.appPhrases": "此應用程式片語",
"input.privacy.typingSamplesRetention": "輸入文字樣本保留期",
"input.privacy.unavailable": "本機資料憑據目前無法使用,因此不顯示數量和保留期。",
"input.feedback.clearFailed": "無法刪除已收集的資料。",
"input.feedback.excludedFailed": "無法儲存排除清單。",
"input.feedback.recommendationFailed": "無法將 {{app}} 加入排除清單。"
}

View file

@ -325,5 +325,192 @@
"keybinding.ui.recordHint": "请输入组合键(例如:Ctrl+Shift+Q)或单个键(例如:F5)",
"keybinding.ui.recordAgain": "重新输入",
"keybinding.ui.saveFailed": "无法保存快捷键",
"keybinding.ui.globalEnabled": "启用全局快捷键"
"keybinding.ui.globalEnabled": "启用全局快捷键",
"input.tab": "输入",
"input.consent.title": "输入数据收集",
"input.consent.description": "可选功能。我们统计按键与点击次数并测量鼠标移动距离,用于展示你的输入模式。我们从不保存你按下了哪些键。",
"input.consent.collect": "收集键盘与鼠标统计",
"input.consent.running": "正在收集。",
"input.consent.stopped": "收集已关闭,不会记录任何内容。",
"input.consent.pause": "暂停收集",
"input.consent.learnText": "学习我输入的文本",
"input.consent.learnTextHint": "读取当前聚焦的输入框以学习你的措辞。密码框始终跳过。",
"input.consent.excludedApps": "排除的应用",
"input.consent.excludedAppsPlaceholder": "keepassxc.exe, 1password.exe",
"input.consent.excludedAppsHint": "以逗号分隔的可执行文件名。在这些应用中不读取也不统计。",
"input.consent.clearAll": "删除已收集的数据",
"input.suggestion.title": "下一句建议",
"input.suggestion.description": "预测你即将写下的句子并显示在光标旁。接受后即插入。",
"input.suggestion.enabled": "输入时显示建议",
"input.suggestion.modelReady": "本地模型已就绪。",
"input.suggestion.modelMissing": "本地模型未运行 — 建议保持关闭。",
"input.suggestion.delay": "延迟 (ms)",
"input.suggestion.minPrefix": "最少字符数",
"input.suggestion.modelDefault": "使用默认模型",
"input.suggestion.overlayInteractive": "允许点击建议框",
"input.suggestion.overlayVisible": "建议正在显示",
"input.suggestion.keyHint": "接受、切换与关闭都是全局快捷键 — 可在设置 > 快捷键中修改。",
"input.suggestion.requestNow": "立即建议",
"input.suggestion.usage": "今天已使用 {{requests}}/{{budget}} 次",
"input.suggestion.lastSkip": "上次跳过原因:{{reason}}",
"input.insights.title": "最近 {{days}} 天",
"input.insights.keystrokes": "按键",
"input.insights.clicks": "点击",
"input.insights.words": "输入单词",
"input.insights.sentences": "句子",
"input.insights.mouseDistance": "鼠标移动",
"input.insights.perDayMeters": " 米/天",
"input.insights.phrases": "个人短语",
"input.insights.empty": "尚无收集到的活动。",
"input.insights.topApps": "输入最多的应用",
"input.insights.appRow": "按键 {{keystrokes}} · 点击 {{clicks}}",
"input.insights.privacyNote": "本设备仅保存计数与距离。按键内容永不保存。",
"input.phrases.title": "个人短语",
"input.phrases.description": "从你的输入与听写中学习的句子,用于贴合你的表达习惯。",
"input.phrases.empty": "尚未学习任何内容。",
"input.phrases.delete": "删除短语",
"input.feedback.excludedSaved": "已保存排除列表。",
"input.feedback.cleared": "已删除收集的数据。",
"popup.suggestion.hintAccept": "接受",
"popup.suggestion.hintNext": "下一个",
"popup.suggestion.hintDismiss": "关闭",
"popup.suggestion.loading": "生成中…",
"keybinding.ui.sectionInput": "输入建议",
"keybinding.action.suggestionAccept": "接受建议",
"keybinding.action.suggestionAccept.desc": "将建议句插入当前应用。",
"keybinding.action.suggestionNext": "下一条建议",
"keybinding.action.suggestionNext.desc": "切换到下一个候选。",
"keybinding.action.suggestionDismiss": "关闭建议",
"keybinding.action.suggestionDismiss.desc": "隐藏当前建议。",
"input.suggestion.timeout": "响应上限 (ms)",
"input.suggestion.latency": "上次响应:{{ms}} 毫秒",
"input.suggestion.slowWarning": "比输入速度慢 — 试试更小的模型。",
"input.diagnostics.title": "实时诊断",
"input.diagnostics.app": "当前应用:{{app}}",
"input.diagnostics.unknownApp": "(未知)",
"input.diagnostics.noSnapshot": "尚未读取输入框 — 请点击文本栏并输入。",
"input.diagnostics.readable": "输入框可读 ({{source}}, {{length}} 字符) — 建议可用。",
"input.diagnostics.notReadable": "此应用不公开输入框文本 — 无法读取,因此不生成建议。",
"input.diagnostics.password": "密码字段 — 按设计阻止读取。",
"input.diagnostics.caretFallback": "无法获取光标位置,改用文档末尾作为输入位置。",
"input.diagnostics.composing": "IME 组合中 — 建议会稍后出现,不会被阻止。",
"input.suggestion.onScreenLabel": "浮层状态",
"input.suggestion.onScreen": "显示中",
"input.suggestion.offScreen": "未显示",
"input.suggestion.generating": "生成中…",
"input.kbView.knowledge": "知识库",
"input.kbView.insights": "输入洞察",
"input.insights.tabs.overview": "概览",
"input.insights.tabs.keyboard": "键盘",
"input.insights.tabs.mouse": "鼠标",
"input.insights.tabs.apps": "应用",
"input.insights.tabs.phrases": "短语 · 建议",
"input.insights.chars": "输入字符",
"input.insights.wordChars": "字符",
"input.insights.backspaces": "退格",
"input.insights.shortcuts": "快捷键",
"input.insights.doubleClicks": "双击",
"input.insights.scrollTicks": "滚动",
"input.insights.activeMinutes": "活跃时间",
"input.insights.activeDays": "记录天数",
"input.insights.streak": "最长连续",
"input.insights.peakDay": "最高输入日",
"input.insights.perDayAverage": "日均",
"input.insights.mouseDistanceTotal": "移动距离合计",
"input.insights.hourlyTitle": "按小时 (0-23)",
"input.insights.dailyTitle": "每日趋势",
"input.insights.wordsDailyTitle": "每日单词",
"input.insights.distanceDailyTitle": "每日鼠标移动",
"input.insights.topHoursHint": "Busiest hours: {{hours}}",
"input.insights.mouseNote": "Mouse travel is the sum of per-axis absolute deltas (Manhattan), the same measure ActivityWatch uses.",
"input.insights.appsHint": "Top apps by typing volume. The bar is that app’s share of all keystrokes.",
"input.insights.suggestions": "建议",
"input.insights.suggestionsTotal": "已生成",
"input.insights.suggestionsAccepted": "已接受",
"input.insights.acceptRate": "接受率",
"input.insights.avgLatency": "平均响应",
"input.insights.suggestionHistory": "最近建议",
"input.insights.accepted": "已接受",
"input.insights.notAccepted": "未接受",
"input.insights.noSuggestions": "No suggestions recorded yet.",
"input.insights.samplesNote": "{{count}} learned samples.",
"input.insights.collectedAt": "Last capture {{at}}",
"input.insights.rangeLabel": "区间",
"input.insights.rangeDays": "{{days}} 天",
"input.insights.headerSummary": "Last {{days}} days · {{keys}} keystrokes · {{apps}} apps",
"input.insights.disabledHint": "Input collection is off. Turn it on in Settings > Input and statistics will collect here.",
"input.insights.statsMovedHint": "详细统计在 知识库 > 输入洞察 中查看。",
"input.chart.max": "最大 {{value}}",
"input.chart.noData": "尚无数据。",
"input.unit.perDay": "/天",
"input.unit.perDayMeters": "米/天",
"input.unit.meters": "米",
"input.unit.ms": "毫秒",
"input.unit.percent": "%",
"popup.suggestion.warming": "正在准备模型…",
"popup.suggestion.hintGenerating": "生成中…",
"keybinding.action.suggestionPrev": "上一条建议",
"keybinding.action.suggestionPrev.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": "句子节点",
"input.graph.followsEdges": "接续关系",
"input.graph.sharesEdges": "术语共享关系",
"input.graph.topEdges": "最强关联",
"input.graph.recentNodes": "最近节点",
"input.graph.searchLabel": "搜索句子",
"input.graph.searchPlaceholder": "e.g. meeting schedule",
"input.graph.searchAction": "搜索",
"input.graph.neighbors": "关联句子",
"input.graph.empty": "The graph is empty. Enable \"Learn the text I type\" in Settings > Input and it fills up.",
"input.graph.kindFollows": "接续",
"input.graph.kindShares": "术语共享",
"popup.suggestion.generating": "生成中… ({{seconds}} 秒)",
"keybinding.ui.auditClear": "没有快捷键冲突或无效设置。",
"keybinding.ui.auditIssues": "{{count}} 个快捷键问题",
"keybinding.ui.auditInvalid": "{{action}}:{{reason}}",
"keybinding.ui.auditConflict": "{{action}} 与 {{conflicts}} 冲突",
"keybinding.ui.auditUnknown": "未知问题",
"input.flow.title": "可能的专注时段",
"input.flow.empty": "按小时的输入数据还不够。",
"input.flow.hour": "{{hour}}:00",
"input.flow.window": "分数 {{score}} · 每天活跃 {{minutes}} 分钟 · 编辑 {{friction}}%",
"input.friction.title": "编辑阻力",
"input.friction.value": "每 100 个字符 {{count}} 次",
"input.friction.band.steady": "稳定",
"input.friction.band.watch": "留意",
"input.friction.band.high": "高",
"input.appQuality.title": "按应用划分的建议质量",
"input.appQuality.empty": "还没有按应用划分的建议记录。",
"input.appQuality.row": "已接受 {{accepted}}/{{total}} · {{rate}}% · {{latency}}ms",
"input.phrases.metadata": "{{source}} · {{count}}次 · {{app}}",
"input.phrases.appUnknown": "通用",
"input.privacy.title": "本地数据凭据",
"input.privacy.localOnly": "这些数据仅保存在此设备上。",
"input.privacy.rawKeys": "不会保留单个按键事件和原始键码。学习文本样本会在下方单独列出。",
"input.privacy.activityRetention": "活动保留期",
"input.privacy.suggestionRetention": "建议保留期",
"input.privacy.learnedRetention": "学习文本保留期",
"input.privacy.activityBuckets": "活动分桶",
"input.privacy.typingSamples": "输入样本",
"input.privacy.personalPhrases": "个人短语",
"input.privacy.suggestions": "建议记录",
"input.privacy.days": "{{days}} 天",
"input.privacy.untilDeleted": "直到删除",
"input.exclusion.recommendation": "{{app}} 出现了 {{samples}} 次重复不可读输入({{reason}})。",
"input.exclusion.reason.repeated-unreadable": "反复不可读",
"input.exclusion.reason.repeated-empty": "反复为空",
"input.exclusion.addButton": "将 {{app}} 加入排除列表",
"input.feedback.recommendationSaved": "已将 {{app}} 加入排除列表。",
"popup.suggestion.sourceModel": "本地模型",
"popup.suggestion.sourceMemory": "本地记忆",
"popup.suggestion.continuations": "续写",
"popup.suggestion.related": "相关",
"popup.suggestion.phrases": "短语",
"popup.suggestion.appPhrases": "应用短语",
"input.privacy.typingSamplesRetention": "输入文本样本保留期",
"input.privacy.unavailable": "本地数据凭据暂不可用,因此不显示数量和保留期。",
"input.feedback.clearFailed": "无法删除已收集的数据。",
"input.feedback.excludedFailed": "无法保存排除列表。",
"input.feedback.recommendationFailed": "无法将 {{app}} 添加到排除列表。"
}

View file

@ -1,6 +1,6 @@
{
"name": "@d3ro/ui-native",
"version": "1.4.0",
"version": "1.5.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.4.0",
"version": "1.5.0",
"private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT",

Some files were not shown because too many files have changed in this diff Show more