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

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

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

View file

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

View file

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

View file

@ -0,0 +1,208 @@
/* Suggestion Overlay — 입력 인텔리전스 ghost text
*
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*/
:root {
--d3-bg-result: #111a30;
--d3-border-result: rgba(148, 180, 255, 0.12);
--d3-text-result: #eef2fb;
--d3-text-secondary: #93a4c8;
--d3-accent-main: #3b82f6;
--d3-accent-dim: rgba(59, 130, 246, 0.14);
--d3-shadow-popup: 0 8px 32px rgba(3, 7, 18, 0.5);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: transparent;
overflow: hidden;
user-select: none;
-webkit-app-region: no-drag;
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
#root {
width: 100%;
height: 100%;
padding: 4px;
}
.suggestion-panel {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 10px;
background: var(--d3-bg-result);
border: 1px solid var(--d3-border-result);
box-shadow: var(--d3-shadow-popup);
opacity: 0;
transform: translateY(-2px);
transition: opacity 90ms ease-out, transform 90ms ease-out;
}
.suggestion-panel.visible {
opacity: 1;
transform: translateY(0);
}
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
.header {
display: flex;
align-items: center;
gap: 8px;
min-height: 18px;
}
.close {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d3-text-secondary);
cursor: pointer;
transition: background 90ms ease-out, color 90ms ease-out;
}
.close:hover {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
}
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
}
.spinner {
width: 11px;
height: 11px;
flex: 0 0 auto;
border-radius: 50%;
border: 2px solid var(--d3-accent-dim);
border-top-color: var(--d3-accent-main);
animation: d3-spin 720ms linear infinite;
}
@keyframes d3-spin {
to {
transform: rotate(360deg);
}
}
.status-text {
color: var(--d3-text-secondary);
font-size: 12px;
font-style: italic;
}
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
.candidates {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 176px;
overflow-y: auto;
overscroll-behavior: contain;
}
.candidates::-webkit-scrollbar {
width: 8px;
}
.candidates::-webkit-scrollbar-thumb {
background: var(--d3-accent-dim);
border-radius: 4px;
}
.candidates::-webkit-scrollbar-track {
background: transparent;
}
.suggestion-item {
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--d3-text-result);
font: inherit;
font-size: 14px;
line-height: 1.45;
text-align: left;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.suggestion-item.active {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
}
.suggestion-item.loading {
color: var(--d3-text-secondary);
font-style: italic;
}
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.streaming {
color: var(--d3-text-result);
opacity: 0.85;
white-space: normal;
}
.suggestion-item.streaming::after {
content: '\258C';
animation: d3-caret-blink 1s steps(1) infinite;
color: var(--d3-accent-main);
}
.provenance {
min-height: 15px;
padding: 0 8px;
color: var(--d3-text-secondary);
font-size: 11px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes d3-caret-blink {
50% {
opacity: 0;
}
}
.hints {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 2px;
color: var(--d3-text-secondary);
font-size: 11px;
}
.hint {
white-space: nowrap;
}