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

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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