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