release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
This commit is contained in:
parent
99f06c253c
commit
5c11ee2fde
104 changed files with 14410 additions and 174 deletions
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<title>Suggestion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="panel" class="suggestion-panel">
|
||||
<div class="header">
|
||||
<div id="status" class="status" hidden>
|
||||
<span id="spinner" class="spinner" aria-hidden="true"></span>
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</div>
|
||||
<button id="close" class="close" type="button" title="Close">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
||||
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="candidates" class="candidates"></div>
|
||||
<div id="provenance" class="provenance" aria-live="polite"></div>
|
||||
<div id="hints" class="hints"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
200
apps/desktop/src/renderer/popups/suggestion-overlay/script.js
Normal file
200
apps/desktop/src/renderer/popups/suggestion-overlay/script.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Suggestion Overlay 팝업 스크립트
|
||||
// 입력 인텔리전스: 다음 문장 제안(ghost text)을 커서 옆에 보여준다.
|
||||
//
|
||||
// 상태 세 가지를 화면에 드러낸다:
|
||||
// warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중")
|
||||
// generating - 토큰이 만들어지는 중 (스피너 + 도착한 부분 텍스트)
|
||||
// 후보 도착 - 최대 5개, 목록은 스크롤 가능
|
||||
//
|
||||
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/닫기)은 전역 키바인딩이
|
||||
// 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다.
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
var candidatesContainer = document.getElementById('candidates')
|
||||
var provenanceContainer = document.getElementById('provenance')
|
||||
var hintsContainer = document.getElementById('hints')
|
||||
var statusRow = document.getElementById('status')
|
||||
var statusText = document.getElementById('statusText')
|
||||
var panel = document.getElementById('panel')
|
||||
var closeButton = document.getElementById('close')
|
||||
|
||||
/** @type {string[]} */
|
||||
var candidates = []
|
||||
var activeIndex = 0
|
||||
var generating = false
|
||||
var warmingUp = false
|
||||
var partialText = null
|
||||
var provenance = null
|
||||
var i18nStrings = {}
|
||||
var generatingSince = 0
|
||||
var tickTimer = null
|
||||
|
||||
function render() {
|
||||
candidatesContainer.textContent = ''
|
||||
|
||||
var busy = warmingUp || generating
|
||||
if (statusRow) statusRow.hidden = !busy
|
||||
if (statusText) {
|
||||
if (warmingUp) {
|
||||
statusText.textContent = i18nStrings.suggestionWarming || '...'
|
||||
} else if (generating) {
|
||||
// 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다.
|
||||
var seconds = generatingSince
|
||||
? Math.max(1, Math.round((Date.now() - generatingSince) / 1000))
|
||||
: 1
|
||||
var template = i18nStrings.suggestionGenerating || i18nStrings.suggestionLoading || '...'
|
||||
statusText.textContent = template.replace('{{seconds}}', String(seconds))
|
||||
} else {
|
||||
statusText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌).
|
||||
if (partialText) {
|
||||
var streaming = document.createElement('div')
|
||||
streaming.className = 'suggestion-item streaming'
|
||||
streaming.textContent = partialText
|
||||
candidatesContainer.appendChild(streaming)
|
||||
}
|
||||
renderHints()
|
||||
renderProvenance()
|
||||
return
|
||||
}
|
||||
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var item = document.createElement('button')
|
||||
item.type = 'button'
|
||||
item.className = i === activeIndex ? 'suggestion-item active' : 'suggestion-item'
|
||||
item.setAttribute('data-index', String(i))
|
||||
item.textContent = candidates[i]
|
||||
item.addEventListener('click', onItemClick)
|
||||
candidatesContainer.appendChild(item)
|
||||
}
|
||||
|
||||
renderHints()
|
||||
renderProvenance()
|
||||
}
|
||||
|
||||
function renderProvenance() {
|
||||
if (!provenanceContainer) return
|
||||
provenanceContainer.textContent = ''
|
||||
if (!provenance) return
|
||||
|
||||
var parts = []
|
||||
var sourceLabel = provenance.mode === 'local-memory'
|
||||
? i18nStrings.suggestionSourceMemory
|
||||
: i18nStrings.suggestionSourceModel
|
||||
if (sourceLabel) parts.push(sourceLabel)
|
||||
var counts = [
|
||||
['continuationCount', 'suggestionContinuations'],
|
||||
['relatedCount', 'suggestionRelated'],
|
||||
['phraseCount', 'suggestionPhrases'],
|
||||
['appPhraseCount', 'suggestionAppPhrases']
|
||||
]
|
||||
for (var i = 0; i < counts.length; i++) {
|
||||
var count = provenance[counts[i][0]] || 0
|
||||
var countLabel = i18nStrings[counts[i][1]]
|
||||
if (count > 0 && countLabel) parts.push(countLabel + ' ' + count)
|
||||
}
|
||||
provenanceContainer.textContent = parts.join(' · ')
|
||||
}
|
||||
|
||||
function renderHints() {
|
||||
hintsContainer.textContent = ''
|
||||
if (candidates.length <= 1 && !generating) return
|
||||
|
||||
var hint = document.createElement('span')
|
||||
hint.className = 'hint'
|
||||
if (candidates.length > 0) {
|
||||
var nextLabel = i18nStrings.suggestionHintNext || 'Next'
|
||||
hint.textContent = nextLabel + ' ' + (activeIndex + 1) + '/' + candidates.length
|
||||
} else {
|
||||
hint.textContent = i18nStrings.suggestionHintGenerating || '…'
|
||||
}
|
||||
hintsContainer.appendChild(hint)
|
||||
}
|
||||
|
||||
function onItemClick(event) {
|
||||
var target = event.currentTarget
|
||||
var index = Number(target.getAttribute('data-index'))
|
||||
if (!window.popupAPI) return
|
||||
window.popupAPI.send('suggestionPopup:accept', index)
|
||||
}
|
||||
|
||||
function applyPayload(payload) {
|
||||
if (!payload) return
|
||||
if (payload._i18n) i18nStrings = payload._i18n
|
||||
candidates = (payload.candidates || []).map(function (candidate) {
|
||||
return candidate && candidate.text ? candidate.text : String(candidate)
|
||||
})
|
||||
activeIndex = payload.activeIndex || 0
|
||||
if (payload.generating !== undefined) generating = payload.generating === true
|
||||
if (payload.warmingUp !== undefined) warmingUp = payload.warmingUp === true
|
||||
if (payload.partialText !== undefined) partialText = payload.partialText || null
|
||||
if (payload.provenance !== undefined) provenance = payload.provenance || null
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
if (tickTimer) return
|
||||
tickTimer = setInterval(function () {
|
||||
if (!generating && !warmingUp) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
generatingSince = 0
|
||||
return
|
||||
}
|
||||
render()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handleShow(payload) {
|
||||
applyPayload(payload)
|
||||
if (payload && payload.generating) generatingSince = Date.now()
|
||||
startTick()
|
||||
if (panel) panel.classList.add('visible')
|
||||
render()
|
||||
}
|
||||
|
||||
function handleUpdate(payload) {
|
||||
var wasGenerating = generating
|
||||
applyPayload(payload)
|
||||
if (generating && !wasGenerating) generatingSince = Date.now()
|
||||
startTick()
|
||||
render()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (panel) panel.classList.remove('visible')
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
}
|
||||
generatingSince = 0
|
||||
generating = false
|
||||
warmingUp = false
|
||||
partialText = null
|
||||
provenance = null
|
||||
candidates = []
|
||||
activeIndex = 0
|
||||
candidatesContainer.textContent = ''
|
||||
if (provenanceContainer) provenanceContainer.textContent = ''
|
||||
hintsContainer.textContent = ''
|
||||
}
|
||||
|
||||
if (closeButton) {
|
||||
// 마우스로도 닫을 수 있어야 한다 (키보드만 있으면 불편하다는 피드백).
|
||||
closeButton.addEventListener('click', function () {
|
||||
handleHide()
|
||||
if (window.popupAPI) window.popupAPI.send('suggestionPopup:dismiss')
|
||||
})
|
||||
}
|
||||
|
||||
if (window.popupAPI) {
|
||||
window.popupAPI.on('suggestionPopup:show', handleShow)
|
||||
window.popupAPI.on('suggestionPopup:update', handleUpdate)
|
||||
window.popupAPI.on('suggestionPopup:hide', handleHide)
|
||||
}
|
||||
})()
|
||||
208
apps/desktop/src/renderer/popups/suggestion-overlay/style.css
Normal file
208
apps/desktop/src/renderer/popups/suggestion-overlay/style.css
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* Suggestion Overlay — 입력 인텔리전스 ghost text
|
||||
*
|
||||
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
|
||||
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--d3-bg-result: #111a30;
|
||||
--d3-border-result: rgba(148, 180, 255, 0.12);
|
||||
--d3-text-result: #eef2fb;
|
||||
--d3-text-secondary: #93a4c8;
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-accent-dim: rgba(59, 130, 246, 0.14);
|
||||
--d3-shadow-popup: 0 8px 32px rgba(3, 7, 18, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.suggestion-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--d3-bg-result);
|
||||
border: 1px solid var(--d3-border-result);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 90ms ease-out, transform 90ms ease-out;
|
||||
}
|
||||
|
||||
.suggestion-panel.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.close {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--d3-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 90ms ease-out, color 90ms ease-out;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-result);
|
||||
}
|
||||
|
||||
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--d3-accent-dim);
|
||||
border-top-color: var(--d3-accent-main);
|
||||
animation: d3-spin 720ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes d3-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
|
||||
.candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 176px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar-thumb {
|
||||
background: var(--d3-accent-dim);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--d3-text-result);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.suggestion-item.active {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-result);
|
||||
}
|
||||
|
||||
.suggestion-item.loading {
|
||||
color: var(--d3-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
|
||||
.suggestion-item.streaming {
|
||||
color: var(--d3-text-result);
|
||||
opacity: 0.85;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.suggestion-item.streaming::after {
|
||||
content: '\258C';
|
||||
animation: d3-caret-blink 1s steps(1) infinite;
|
||||
color: var(--d3-accent-main);
|
||||
}
|
||||
|
||||
.provenance {
|
||||
min-height: 15px;
|
||||
padding: 0 8px;
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes d3-caret-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px 2px;
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue