release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept and Esc to close; old default bindings migrate and the panel guide follows the live bindings. The overlay is redesigned, stays put while candidates stream and sits outside the input box when no caret is reported. The personal phrase memory stops learning from terminals, code editors and the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders, and prunes existing entries that break those rules. Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0 speech engine without the focus endpoint, Ollama runner windows flashing while typing, the speech engine starting twice, and cold-model timeouts. Live captions can be dragged to a remembered position and show a waiting notice until the first line arrives. Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
parent
856e375f3e
commit
2fe20fa7b5
71 changed files with 2469 additions and 366 deletions
|
|
@ -2,12 +2,16 @@
|
|||
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
|
||||
import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
|
||||
import {
|
||||
bindingsEqual,
|
||||
createDefaultBindingMap,
|
||||
detectBindingConflicts,
|
||||
findActionSpec,
|
||||
kb,
|
||||
normalizeBinding,
|
||||
parseBindingMap
|
||||
parseBindingMap,
|
||||
VK
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
|
@ -15,7 +19,7 @@ import { getLogger } from './LoggerService'
|
|||
const logger = getLogger('ConfigService')
|
||||
|
||||
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
|
||||
const SUGGESTION_TUNING_REVISION = 4
|
||||
const SUGGESTION_TUNING_REVISION = 5
|
||||
|
||||
const INITIAL_SUGGESTION_TUNING = {
|
||||
suggestionTriggerDelayMs: 300,
|
||||
|
|
@ -55,12 +59,62 @@ function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
|
|||
activeStore.set('suggestionMaxRequestsPerMinute', 6)
|
||||
}
|
||||
|
||||
if (current < 5) migrateSuggestionOverlayBindings(activeStore)
|
||||
|
||||
activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION)
|
||||
logger.info(
|
||||
`Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}`
|
||||
)
|
||||
}
|
||||
|
||||
/** revision 2 가 심어 둔 옛 기본값 — 사용자가 정확히 이 값 그대로일 때만 옮긴다. */
|
||||
const OLD_SUGGESTION_ACCEPT_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowRight, { ctrl: true, alt: true })]
|
||||
const OLD_SUGGESTION_DISMISS_DEFAULT: readonly KeyBinding[] = [kb(VK.ArrowLeft, { ctrl: true, alt: true })]
|
||||
|
||||
function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]): boolean {
|
||||
return a.length === b.length && a.every((binding, index) => bindingsEqual(binding, b[index]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다.
|
||||
*
|
||||
* - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다.
|
||||
* - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다).
|
||||
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
|
||||
* 절대 건드리지 않는다(설계 요구사항).
|
||||
* - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른
|
||||
* 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다.
|
||||
*/
|
||||
function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void {
|
||||
const raw = activeStore.store as unknown as Record<string, unknown>
|
||||
const bindings = parseBindingMap(raw.keyBindings)
|
||||
|
||||
const migrateIfOldDefault = (actionId: KeyBindingActionId, oldDefault: readonly KeyBinding[]): void => {
|
||||
const existing = bindings[actionId] ?? []
|
||||
if (!bindingListEquals(existing, oldDefault)) return
|
||||
const spec = findActionSpec(actionId)
|
||||
if (spec) bindings[actionId] = spec.defaultBindings.map((binding) => ({ ...binding }))
|
||||
}
|
||||
|
||||
migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT)
|
||||
migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT)
|
||||
|
||||
for (const actionId of ['suggestion-page-next', 'suggestion-page-prev'] as const) {
|
||||
const candidate = bindings[actionId] ?? []
|
||||
const hasConflict = candidate.some(
|
||||
(binding) => detectBindingConflicts(actionId, binding, bindings).length > 0
|
||||
)
|
||||
if (hasConflict) {
|
||||
logger.warn(
|
||||
`${actionId} 기본 바인딩이 다른 액션과 충돌해 바인딩 없이 둔다 (사용자가 설정에서 직접 지정할 수 있다)`
|
||||
)
|
||||
bindings[actionId] = []
|
||||
}
|
||||
}
|
||||
|
||||
activeStore.set('keyBindings', bindings)
|
||||
}
|
||||
|
||||
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
||||
interface ElectronStore<T> {
|
||||
get<K extends keyof T>(key: K): T[K]
|
||||
|
|
@ -131,6 +185,7 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
activeInstructionId: '',
|
||||
activeChainId: null,
|
||||
captionAudioSource: 'mic',
|
||||
captionOverlayPosition: null,
|
||||
updateChannel: 'latest',
|
||||
updateDeviceId: '',
|
||||
skippedUpdateVersion: null,
|
||||
|
|
@ -148,7 +203,7 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
suggestionDailyBudget: 500,
|
||||
suggestionOverlayInteractive: true,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
suggestionTuningRevision: 4,
|
||||
suggestionTuningRevision: 5,
|
||||
}
|
||||
|
||||
let store: ElectronStore<AppConfig> | null = null
|
||||
|
|
|
|||
|
|
@ -18,22 +18,28 @@ import { EventEmitter } from 'events'
|
|||
import { screen } from 'electron'
|
||||
import { uIOhook } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent, UiohookMouseEvent, UiohookWheelEvent } from 'uiohook-napi'
|
||||
import { sql, gte, eq, desc } from 'drizzle-orm'
|
||||
import { sql, gte, eq, desc, inArray } from 'drizzle-orm'
|
||||
import {
|
||||
INPUT_TELEMETRY_DEFAULTS,
|
||||
SUGGESTION_CONTEXT_MAX_CHARS,
|
||||
SUGGESTION_DEFAULTS,
|
||||
calculateFrictionInsight,
|
||||
classifyKeyStroke,
|
||||
computeTypedDelta,
|
||||
countWords,
|
||||
emptyActivityBucket,
|
||||
extractPhrases,
|
||||
isAppExcluded,
|
||||
isLearnablePhrase,
|
||||
LEARNING_EXCLUDED_APPS,
|
||||
manhattanDistance,
|
||||
mergeActivityBucket,
|
||||
rankFlowWindows,
|
||||
recommendAppExclusion,
|
||||
summarizeActivity,
|
||||
textBeforeCaret,
|
||||
withoutPlaceholderText,
|
||||
type AnchorKind,
|
||||
type FocusSnapshot,
|
||||
type InputActivityBucket,
|
||||
type InputAppStat,
|
||||
|
|
@ -45,6 +51,7 @@ import {
|
|||
type InputTelemetryState,
|
||||
type InputSnapshotSummary,
|
||||
type AppReadabilityEvidence,
|
||||
type KeyStrokeClass,
|
||||
type PersonalPhrase,
|
||||
type PhraseSource,
|
||||
type UiRect
|
||||
|
|
@ -68,6 +75,8 @@ export interface TypingContext {
|
|||
fullText: string
|
||||
caretOffset: number | null
|
||||
anchor: UiRect | null
|
||||
/** anchor 가 케어렛인지 포커스 요소 전체인지 — 오버레이 배치 전략을 결정한다 */
|
||||
anchorKind: AnchorKind
|
||||
isPassword: boolean
|
||||
isEditable: boolean
|
||||
isComposing: boolean
|
||||
|
|
@ -77,6 +86,10 @@ export interface TypingContext {
|
|||
windowTitle: string | null
|
||||
idleMs: number
|
||||
capturedAt: number
|
||||
/** 포커스가 바뀐 뒤 이 필드에서 실제로 편집이 있었는가 (마우스로 들어오기만 한 경우 false) */
|
||||
editedSinceFocus: boolean
|
||||
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
|
||||
typedRecently: boolean
|
||||
}
|
||||
|
||||
interface InputTelemetryEvents {
|
||||
|
|
@ -95,6 +108,22 @@ const MAX_SAMPLE_CHARS = 500
|
|||
const RETENTION_PRUNE_INTERVAL_MS = 6 * 60 * 60 * 1000
|
||||
const MAX_READABILITY_APPS = 64
|
||||
|
||||
/**
|
||||
* "실제로 타이핑했다" 로 볼 키 종류.
|
||||
*
|
||||
* 화살표/단축키/기능키/엔터/탭 등은 제외한다 — 필드 이동이나 명령일 뿐 텍스트를
|
||||
* 치는 게 아니다. IME 조합은 포함한다(한/일 입력은 조합 중이 곧 타이핑이다).
|
||||
*/
|
||||
const TYPING_KEY_CLASSES = new Set<KeyStrokeClass>([
|
||||
'letter',
|
||||
'digit',
|
||||
'symbol',
|
||||
'space',
|
||||
'backspace',
|
||||
'delete',
|
||||
'ime'
|
||||
])
|
||||
|
||||
class InputTelemetryService extends EventEmitter {
|
||||
private _running = false
|
||||
private _releaseHook: (() => void) | null = null
|
||||
|
|
@ -124,6 +153,18 @@ class InputTelemetryService extends EventEmitter {
|
|||
* 필드에 이미 있던 텍스트로 제안이 만들어졌다(실측 신고).
|
||||
*/
|
||||
private _lastKeyAt = 0
|
||||
/**
|
||||
* 마지막 "실제 타이핑" 시각 (TYPING_KEY_CLASSES 에 속하는 키만).
|
||||
*
|
||||
* `_lastKeyAt` 은 화살표/단축키를 포함한 모든 키를 기록해 필드 이동만으로도
|
||||
* 갱신된다. 제안 게이트는 진짜 타이핑만 기준으로 삼아야 한다(실측: 필드에
|
||||
* 이미 있던 텍스트를 마우스로 클릭만 했는데 제안이 뜸).
|
||||
*/
|
||||
private _lastTypedAt = 0
|
||||
/** 현재 포커스된 (창, 컨트롤 종류, 위치)를 식별하는 키 — 바뀌면 새 필드로 본다. */
|
||||
private _lastFocusKey = ''
|
||||
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
||||
private _textAtFocus = ''
|
||||
private _lastActiveTickAt = 0
|
||||
private _lastMouse: { x: number; y: number } | null = null
|
||||
private _lastClickAt = 0
|
||||
|
|
@ -338,6 +379,7 @@ class InputTelemetryService extends EventEmitter {
|
|||
if (keyClass === 'backspace') bucket.backspaces += 1
|
||||
|
||||
this._lastKeyAt = Date.now()
|
||||
if (TYPING_KEY_CLASSES.has(keyClass)) this._lastTypedAt = this._lastKeyAt
|
||||
|
||||
this._scheduleTextSnapshot()
|
||||
}
|
||||
|
|
@ -518,16 +560,19 @@ class InputTelemetryService extends EventEmitter {
|
|||
if (!this._running) return null
|
||||
|
||||
const uia = getUiaContextService()
|
||||
const snapshot = await uia.getSnapshot()
|
||||
const snapshot = withoutPlaceholderText(await uia.getSnapshot())
|
||||
const now = Date.now()
|
||||
// 유휴 시간은 키보드 기준이다 (마우스 이동/클릭으로는 제안하지 않는다).
|
||||
const idleMs = this._lastKeyAt > 0 ? now - this._lastKeyAt : Number.MAX_SAFE_INTEGER
|
||||
const typedRecently =
|
||||
this._lastTypedAt > 0 && now - this._lastTypedAt <= SUGGESTION_DEFAULTS.recentTypingWindowMs
|
||||
|
||||
const context: TypingContext = {
|
||||
prefix: '',
|
||||
fullText: '',
|
||||
caretOffset: null,
|
||||
anchor: null,
|
||||
anchorKind: null,
|
||||
isPassword: snapshot.isPassword,
|
||||
isEditable: snapshot.isEditable,
|
||||
isComposing: snapshot.isComposing,
|
||||
|
|
@ -536,11 +581,15 @@ class InputTelemetryService extends EventEmitter {
|
|||
appName: this._foreground.appName,
|
||||
windowTitle: snapshot.windowTitle ?? this._foreground.windowTitle,
|
||||
idleMs,
|
||||
capturedAt: snapshot.capturedAt
|
||||
capturedAt: snapshot.capturedAt,
|
||||
editedSinceFocus: false,
|
||||
typedRecently
|
||||
}
|
||||
|
||||
if (snapshot.available) {
|
||||
const usesCaretRect = snapshot.caretRect !== null
|
||||
context.anchor = snapshot.caretRect ?? snapshot.elementRect
|
||||
context.anchorKind = usesCaretRect ? 'caret' : snapshot.elementRect ? 'element' : null
|
||||
context.caretOffset = snapshot.caretOffset
|
||||
context.fullText = snapshot.text
|
||||
// 케어렛 오프셋을 못 얻는 제공자가 많다(실측: Notepad — src=value, caret=null).
|
||||
|
|
@ -558,6 +607,18 @@ class InputTelemetryService extends EventEmitter {
|
|||
: tail
|
||||
this._caretFallback = !caretKnown
|
||||
|
||||
// 포커스 식별: 바뀌었으면 그 시점 텍스트를 기준선으로 저장한다(비밀번호는 비운다).
|
||||
// 마우스로 필드에 들어오기만 하고 아무것도 안 쳤으면, 이 기준선과 현재 텍스트가
|
||||
// 같아 editedSinceFocus 가 false 로 남는다(실측: YouTube 검색창 클릭만 했는데
|
||||
// 옛 검색어로 제안이 뜸).
|
||||
const focusKey = computeFocusKey(this._foreground.hwnd, snapshot.controlType ?? null, snapshot.elementRect)
|
||||
const textForFocus = snapshot.isPassword ? '' : snapshot.text
|
||||
if (focusKey !== this._lastFocusKey) {
|
||||
this._lastFocusKey = focusKey
|
||||
this._textAtFocus = textForFocus
|
||||
}
|
||||
context.editedSinceFocus = textForFocus !== this._textAtFocus
|
||||
|
||||
if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) {
|
||||
this._applyTypedDelta(snapshot)
|
||||
}
|
||||
|
|
@ -670,7 +731,15 @@ class InputTelemetryService extends EventEmitter {
|
|||
raw: string,
|
||||
meta: { appName: string | null; windowTitle: string | null; source: PhraseSource }
|
||||
): void {
|
||||
const text = sanitizeSample(raw)
|
||||
if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) {
|
||||
return
|
||||
}
|
||||
const text = sanitizeSample(
|
||||
raw
|
||||
.split(/\r?\n/u)
|
||||
.filter((line) => isLearnablePhrase(line))
|
||||
.join('\n')
|
||||
)
|
||||
if (!text) return
|
||||
|
||||
try {
|
||||
|
|
@ -699,7 +768,7 @@ class InputTelemetryService extends EventEmitter {
|
|||
at: now
|
||||
})
|
||||
|
||||
for (const phrase of extractPhrases(text)) {
|
||||
for (const phrase of extractPhrases(text).filter(isLearnablePhrase)) {
|
||||
db.insert(personalPhrases)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -1197,11 +1266,74 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지금의 학습 규칙(제외 앱·문장 판정)에 맞지 않는 기존 코퍼스를 걷어낸다.
|
||||
*
|
||||
* 규칙이 생기기 전에 쌓인 터미널 상태줄·개발 지시가 그래프를 개발 쪽으로 끌고
|
||||
* 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다.
|
||||
*/
|
||||
private _pruneUnlearnableCorpus(): void {
|
||||
const excluded = (source: string, appName: string | null): boolean =>
|
||||
source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
||||
const phraseIds = db
|
||||
.select({
|
||||
id: personalPhrases.id,
|
||||
phrase: personalPhrases.phrase,
|
||||
source: personalPhrases.source,
|
||||
appName: personalPhrases.appName
|
||||
})
|
||||
.from(personalPhrases)
|
||||
.all()
|
||||
.filter((row) => excluded(row.source, row.appName) || !isLearnablePhrase(row.phrase))
|
||||
.map((row) => row.id)
|
||||
|
||||
const sampleIds = db
|
||||
.select({
|
||||
id: typingSamples.id,
|
||||
text: typingSamples.text,
|
||||
source: typingSamples.source,
|
||||
appName: typingSamples.appName
|
||||
})
|
||||
.from(typingSamples)
|
||||
.all()
|
||||
.filter(
|
||||
(row) =>
|
||||
excluded(row.source, row.appName) ||
|
||||
!row.text.split(/\r?\n/u).some((line) => isLearnablePhrase(line))
|
||||
)
|
||||
.map((row) => row.id)
|
||||
|
||||
for (let i = 0; i < phraseIds.length; i += 200) {
|
||||
db.delete(personalPhrases).where(inArray(personalPhrases.id, phraseIds.slice(i, i + 200))).run()
|
||||
}
|
||||
for (let i = 0; i < sampleIds.length; i += 200) {
|
||||
db.delete(typingSamples).where(inArray(typingSamples.id, sampleIds.slice(i, i + 200))).run()
|
||||
}
|
||||
db.run(
|
||||
sql`DELETE FROM phrase_edges
|
||||
WHERE from_phrase NOT IN (SELECT phrase FROM personal_phrases)
|
||||
OR to_phrase NOT IN (SELECT phrase FROM personal_phrases)`
|
||||
)
|
||||
|
||||
if (phraseIds.length > 0 || sampleIds.length > 0) {
|
||||
logger.info(`학습 규칙에 맞지 않는 코퍼스 정리: 문구 ${phraseIds.length}개, 표본 ${sampleIds.length}개`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`코퍼스 정리 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 보존 기간 초과 데이터 정리 (ActivityWatch 와 동일한 로컬 보존 정책). */
|
||||
private _pruneOldData(now = Date.now()): void {
|
||||
if (now - this._prunedAt < RETENTION_PRUNE_INTERVAL_MS) return
|
||||
this._prunedAt = now
|
||||
|
||||
this._pruneUnlearnableCorpus()
|
||||
|
||||
// 그래프 유지보수도 같은 주기에 돌린다 (용어 공유 엣지 백필).
|
||||
getPersonalGraphService().runMaintenance()
|
||||
|
||||
|
|
@ -1252,6 +1384,19 @@ function lastLineOf(text: string): string {
|
|||
return text.slice(-SUGGESTION_CONTEXT_MAX_CHARS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 포커스된 컨트롤 식별 키 — 창(hwnd) + 컨트롤 종류 + 위치.
|
||||
*
|
||||
* 높이는 뺀다 — 여러 줄 입력창은 타이핑에 따라 높이가 자라, 높이를 포함하면
|
||||
* 같은 필드인데도 칠 때마다 "새 포커스" 로 오인돼 기준선이 계속 리셋된다.
|
||||
*/
|
||||
function computeFocusKey(hwnd: number | null, controlType: string | null, elementRect: UiRect | null): string {
|
||||
const rect = elementRect
|
||||
? `${Math.round(elementRect.x)},${Math.round(elementRect.y)},${Math.round(elementRect.width)}`
|
||||
: 'none'
|
||||
return `${hwnd ?? ''}|${controlType ?? ''}|${rect}`
|
||||
}
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
|
|
|
|||
|
|
@ -208,11 +208,20 @@ class LocalLLMService extends EventEmitter {
|
|||
logger.info(`Ollama not running, auto-starting from ${binaryPath}`)
|
||||
|
||||
try {
|
||||
const child = spawn(binaryPath, ['serve'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
// Windows에서 serve를 detached(DETACHED_PROCESS)로 띄우면 serve에 콘솔이 없어,
|
||||
// ollama가 모델을 올릴 때마다 띄우는 runner(llama-server)가 새 콘솔 창을 받는다
|
||||
// (실측: 타이핑 → 제안 → 모델 로드마다 터미널 창이 떠 포커스를 뺏음).
|
||||
// 공식 트레이 앱이 있으면 그것으로 띄우고, 없으면 detached 없이 숨김 콘솔을 물려준다.
|
||||
const trayApp =
|
||||
process.platform === 'win32' ? path.join(path.dirname(binaryPath), 'ollama app.exe') : null
|
||||
const useTray = trayApp !== null && trayApp !== binaryPath && fs.existsSync(trayApp)
|
||||
const child = useTray
|
||||
? spawn(trayApp, [], { detached: true, stdio: 'ignore', windowsHide: true })
|
||||
: spawn(binaryPath, ['serve'], {
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
logger.info('Ollama serve spawned (detached) — polling will detect readiness')
|
||||
return 'starting'
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
private _state: STTState = STTState.Uninitialized
|
||||
private _sidecarProcess: ChildProcess | null = null
|
||||
private _sidecarStarting: Promise<void> | null = null
|
||||
private _port: number = SIDECAR_PORT
|
||||
private _currentModelId: string | null = null
|
||||
private _restartCount: number = 0
|
||||
|
|
@ -590,12 +591,26 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
// ── Sidecar 관리 ──
|
||||
|
||||
/** sidecar가 실행 중이 아니면 spawn + 헬스체크 대기 */
|
||||
private async _ensureSidecarRunning(): Promise<void> {
|
||||
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||||
await this._spawnSidecar()
|
||||
await this._waitForHealth()
|
||||
/**
|
||||
* sidecar가 실행 중이 아니면 spawn + 헬스체크 대기.
|
||||
*
|
||||
* 동시 호출은 하나의 기동 작업을 공유한다. STT 예열과 UIA 스냅샷이 같은 순간에
|
||||
* (특히 런타임 다운로드를 함께 기다린 뒤) 각각 spawn 해 두 번째가 포트 충돌로
|
||||
* 죽고 crash 재시작까지 돌던 문제(실측 Errno 10048)를 막는다.
|
||||
*/
|
||||
private _ensureSidecarRunning(): Promise<void> {
|
||||
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (!this._sidecarStarting) {
|
||||
this._sidecarStarting = (async () => {
|
||||
await this._spawnSidecar()
|
||||
await this._waitForHealth()
|
||||
})().finally(() => {
|
||||
this._sidecarStarting = null
|
||||
})
|
||||
}
|
||||
return this._sidecarStarting
|
||||
}
|
||||
|
||||
private _emitDownloadProgress(
|
||||
|
|
@ -769,11 +784,12 @@ class LocalSTTService extends EventEmitter {
|
|||
if (!needsInstall) throw err
|
||||
}
|
||||
|
||||
logger.info('로컬 음성 엔진이 없습니다 — 자동 다운로드를 시작합니다')
|
||||
await getRuntimeProvisioner().ensure('sidecar')
|
||||
const launch = getSidecarCommand()
|
||||
logger.info(`런타임 설치 후 사이드카 경로: ${launch.command} (${launch.source})`)
|
||||
return launch
|
||||
// ensure()가 설치 여부와 버전(앱 업데이트로 낡아졌는지)을 함께 판단한다 —
|
||||
// 이미 최신이면 바로 기존 경로를 돌려주고, 없거나 낡았으면 새로 받는다.
|
||||
logger.info('로컬 음성 엔진을 확인합니다 (없거나 낡았으면 새로 받습니다)')
|
||||
const binaryPath = await getRuntimeProvisioner().ensure('sidecar')
|
||||
logger.info(`사이드카 경로 확정: ${binaryPath} (provisioned)`)
|
||||
return { command: binaryPath, args: [], source: 'provisioned' }
|
||||
}
|
||||
|
||||
/** sidecar 기동 실패 원인을 사용자가 조치할 수 있는 문구로 바꾼다. */
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
type PhraseSource,
|
||||
type RelatedCandidate
|
||||
} from '@d3ro/core/personal-graph'
|
||||
import { prefixTail } from '@d3ro/core/input-intelligence'
|
||||
import { isLearnablePhrase, prefixTail } from '@d3ro/core/input-intelligence'
|
||||
import { and, desc, eq, gte, inArray, sql } from 'drizzle-orm'
|
||||
import { getDatabase } from '../db'
|
||||
import { personalPhrases, phraseEdges } from '../db/schema'
|
||||
|
|
@ -51,7 +51,7 @@ class PersonalGraphService {
|
|||
text: string,
|
||||
meta: { source: PhraseSource; appName?: string | null; at?: number }
|
||||
): void {
|
||||
const sentences = splitSentences(text)
|
||||
const sentences = splitSentences(text).filter(isLearnablePhrase)
|
||||
if (sentences.length === 0) return
|
||||
|
||||
const at = meta.at ?? Date.now()
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { EventEmitter, once } from 'events'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'
|
||||
import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { mkdir, rm, stat } from 'node:fs/promises'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
|
|
@ -23,6 +23,7 @@ import * as tar from 'tar'
|
|||
import { getLogger } from './LoggerService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { RUNTIME_FEED_URL } from '../update-feed'
|
||||
import { compareVersions } from '../update-policy'
|
||||
|
||||
const logger = getLogger('RuntimeProvisioner')
|
||||
|
||||
|
|
@ -68,6 +69,17 @@ export interface RuntimeStatus {
|
|||
}
|
||||
|
||||
const RUNTIME_DIR_NAME = 'runtime'
|
||||
/** 설치된 런타임의 버전(runtime.json version)을 남기는 마커 */
|
||||
const RUNTIME_VERSION_FILE = '.runtime-version'
|
||||
|
||||
/**
|
||||
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다.
|
||||
* 1.5.0 — UIA 브리지(`/uia/focus`)가 처음 들어간 사이드카. ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
|
||||
*/
|
||||
const RUNTIME_MIN_VERSION: Record<RuntimeComponent, string | null> = {
|
||||
sidecar: '1.5.0',
|
||||
ffmpeg: null
|
||||
}
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000
|
||||
/** 부품 다운로드 재시도 횟수 — 전송 중 잘림/일시적 네트워크 오류 대비 */
|
||||
const PART_DOWNLOAD_ATTEMPTS = 3
|
||||
|
|
@ -106,6 +118,31 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치된 런타임이 이 앱이 요구하는 최소 버전 이상인지.
|
||||
*
|
||||
* 사이드카는 설치본에 넣지 않고 별도로 내려받는다(위 주석) — 그래서 앱이 업데이트돼
|
||||
* 새 엔드포인트를 요구해도(예: v1.5.0의 `/uia/focus`), 예전에 내려받은 사이드카가
|
||||
* 남아 있으면 새 앱은 그 구버전 엔진과 계속 통신한다. 마커가 없거나 최소 버전보다
|
||||
* 낮으면 다시 받는다. 앱 버전과 "같음" 으로 비교하지 않는다 — 그러면 엔진이 그대로인
|
||||
* 릴리스마다 모든 사용자가 100MB를 다시 받는다.
|
||||
*/
|
||||
private _isCurrentVersion(component: RuntimeComponent): boolean {
|
||||
const minimum = RUNTIME_MIN_VERSION[component]
|
||||
if (minimum === null) return true
|
||||
try {
|
||||
const installed = readFileSync(this._versionMarkerPath(component), 'utf8').trim()
|
||||
const order = compareVersions(installed, minimum)
|
||||
return order !== null && order >= 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private _versionMarkerPath(component: RuntimeComponent): string {
|
||||
return join(this.componentDir(component), RUNTIME_VERSION_FILE)
|
||||
}
|
||||
|
||||
getStatus(): RuntimeStatus[] {
|
||||
return RUNTIME_COMPONENTS.map((component) => {
|
||||
const binary = this.binaryPath(component)
|
||||
|
|
@ -129,7 +166,7 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
* 동시 호출은 같은 작업을 공유한다.
|
||||
*/
|
||||
async ensure(component: RuntimeComponent): Promise<string> {
|
||||
if (this.isInstalled(component)) {
|
||||
if (this.isInstalled(component) && this._isCurrentVersion(component)) {
|
||||
return this.binaryPath(component)
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +227,8 @@ class RuntimeProvisioner extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
writeFileSync(this._versionMarkerPath(component), index.version, 'utf8')
|
||||
|
||||
this._emitProgress(component, 'done', 100, entry.totalSize, entry.totalSize, 0)
|
||||
logger.info(
|
||||
`런타임 설치 완료: ${component} (${(entry.totalSize / 1048576).toFixed(1)}MiB, ${Date.now() - started}ms)`,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,12 @@ import {
|
|||
decideSuggestion,
|
||||
decideSuggestionRefresh,
|
||||
isAppExcluded,
|
||||
matchesSessionPrefix,
|
||||
parseSuggestionCandidates,
|
||||
sanitizeSuggestionLine,
|
||||
selectPhraseHints,
|
||||
TERMINAL_APPS,
|
||||
type AnchorKind,
|
||||
type SuggestionCandidate,
|
||||
type SuggestionProvenance,
|
||||
type SuggestionSkipReason,
|
||||
|
|
@ -76,18 +79,24 @@ const MAX_RAW_OUTPUT_CHARS = 1200
|
|||
/**
|
||||
* 모델을 메모리에 유지하는 시간.
|
||||
*
|
||||
* 자동 입력 제안은 반복 호출 경로이므로, 짧은 유휴 구간만 모델을 유지한다.
|
||||
* 콜드 리로드 실측(11.4s)이 요청 타임아웃(8s)보다 길어, 짧은 유휴 유지 시간은
|
||||
* 유휴 후 첫 요청을 항상 타임아웃시켰다 — 충분히 길게 유지한다.
|
||||
*/
|
||||
const SUGGESTION_KEEP_ALIVE = '2m'
|
||||
const SUGGESTION_KEEP_ALIVE = '10m'
|
||||
const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000
|
||||
|
||||
class SuggestionService extends EventEmitter {
|
||||
private _candidates: SuggestionCandidate[] = []
|
||||
private _activeIndex = 0
|
||||
private _anchor: UiRect | null = null
|
||||
/** anchor 가 케어렛인지 요소 전체인지 — 오버레이가 배치 전략을 고르는 근거 */
|
||||
private _anchorKind: AnchorKind = null
|
||||
private _appName: string | null = null
|
||||
private _windowTitle: string | null = null
|
||||
/** 후보를 만든 시점의 접두 — 접두가 달라지면 stale */
|
||||
private _generatedForPrefix = ''
|
||||
/** 이 세션이 채우려는 후보 총량 (모델 세션 12 / 로컬 기억 세션은 현재 개수) */
|
||||
private _targetTotal = 0
|
||||
private _lastSkipReason: SuggestionSkipReason | null = null
|
||||
private _lastDecisionSignature = ''
|
||||
/** 생성 중 — 후보 도착 전에도 오버레이를 띄운다 */
|
||||
|
|
@ -122,6 +131,10 @@ class SuggestionService extends EventEmitter {
|
|||
private _provenance: SuggestionProvenance | null = null
|
||||
|
||||
private _lastRequestAt = 0
|
||||
/** 취소된 요청의 부담을 되돌리기 위해 직전 값을 기억한다. */
|
||||
private _prevRequestAt = 0
|
||||
/** 모델이 메모리에 남아 있다고 볼 수 있는 시각 (ms). 이 시각 이후에는 콜드 리로드로 본다. */
|
||||
private _modelWarmUntil = 0
|
||||
private _minuteWindowStart = 0
|
||||
private _minuteCount = 0
|
||||
private _dayKey = ''
|
||||
|
|
@ -129,6 +142,9 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
private _inFlight = false
|
||||
private _abort: AbortController | null = null
|
||||
/** 채우기 루프(2번째 이후 후보) 진행 중 — _inFlight 와 별개다 (예산/타임아웃 카운트 제외). */
|
||||
private _filling = false
|
||||
private _fillAbort: AbortController | null = null
|
||||
private _timeoutTimer: NodeJS.Timeout | null = null
|
||||
private _lastLatencyMs: number | null = null
|
||||
private _warmUpPromise: Promise<void> | null = null
|
||||
|
|
@ -182,7 +198,9 @@ class SuggestionService extends EventEmitter {
|
|||
partialText: this._partialText || null,
|
||||
candidates: [...this._candidates],
|
||||
activeIndex: this._activeIndex,
|
||||
targetTotal: this._targetTotal,
|
||||
anchor: this._anchor,
|
||||
anchorKind: this._anchorKind,
|
||||
appName: this._appName,
|
||||
updatedAt: now,
|
||||
lastSkipReason: this._lastSkipReason,
|
||||
|
|
@ -263,6 +281,7 @@ class SuggestionService extends EventEmitter {
|
|||
})
|
||||
for await (const chunk of stream) void chunk
|
||||
if (abort.signal.aborted) return
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
|
|
@ -275,6 +294,10 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
} finally {
|
||||
this._warmingUp = false
|
||||
// 'state-changed' 뿐 아니라 'updated' 도 보내야 한다 — 오버레이는 'updated' 를
|
||||
// 듣고 표시 여부를 판단하는데, 이게 빠지면 타이핑이 멈춘 사이 워밍업이 끝나도
|
||||
// "준비 중" 오버레이가 20초 TTL 까지 그대로 남는다.
|
||||
this.emit('updated', this.getState())
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
}
|
||||
|
|
@ -322,6 +345,16 @@ class SuggestionService extends EventEmitter {
|
|||
this._logDecisionInputs(context)
|
||||
this._abortStaleGeneration(context.prefix)
|
||||
|
||||
// 세션(페이지 넘기며 보는 고정 목록)이 후보를 보여주는 중이면, 다음 문장이
|
||||
// 시작된 순간 — 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외 —
|
||||
// 즉시 세션을 끝낸다(사용자 요청 사양: 계속 자라는 접두를 따라가지 않는다).
|
||||
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션이 시작된다.
|
||||
if (this.isVisible && !matchesSessionPrefix(this._generatedForPrefix, context.prefix)) {
|
||||
this.dismiss('stale')
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
// 연속 실패 쿨다운. 모델이 다른 작업으로 바쁘면(로컬 에이전트 동시 사용 등)
|
||||
// 요청이 계속 타임아웃되어 스피너만 깜빡인다 — 잠시 요청을 멈춘다.
|
||||
if (Date.now() < this._cooldownUntil) {
|
||||
|
|
@ -352,17 +385,19 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
const decision = decideSuggestion({
|
||||
enabled: this.isEnabled(),
|
||||
// 접두가 충분히 자랐으면 "이미 떠 있음" 으로 막지 않는다 (지속 갱신).
|
||||
// (decideSuggestion 의 overlayVisible 인자는 아래에서 계산한다)
|
||||
modelAvailable: getLocalLLMService().isAvailable(),
|
||||
// 생성 중에도 "이미 진행 중" 으로 취급해 중복 요청과 깜빡임을 막는다.
|
||||
overlayVisible: this.isPresentationActive && !this._shouldRegenerate(context.prefix),
|
||||
// 생성 중이거나 세션이 떠 있으면 "이미 진행 중" 으로 취급해 중복 요청과
|
||||
// 깜빡임을 막는다. 접두가 어긋난 세션은 위에서 이미 dismiss 하고 return 했으므로
|
||||
// 여기 도달했다는 것 자체가 "그대로 유효한 세션" 이라는 뜻이다.
|
||||
overlayVisible: this.isPresentationActive,
|
||||
composing: context.isComposing,
|
||||
hasSelection: context.hasSelection,
|
||||
isPassword: context.isPassword,
|
||||
isEditable: context.isEditable,
|
||||
appName: context.appName,
|
||||
excludedApps: config.excludedApps,
|
||||
editedSinceFocus: context.editedSinceFocus,
|
||||
typedRecently: context.typedRecently,
|
||||
prefix: context.prefix,
|
||||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
|
|
@ -383,13 +418,6 @@ class SuggestionService extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
// 이미 보여준 제안이 현재 접두와 어긋나면 즉시 지운다 (tab-completion 표준 동작).
|
||||
if (this.isVisible && !this._matchesGeneratedPrefix(context.prefix)) {
|
||||
this.dismiss('stale')
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
if (decision.action === 'skip') {
|
||||
logger.debug(`제안 건너: ${decision.reason}`)
|
||||
this._lastSkipReason = decision.reason
|
||||
|
|
@ -420,6 +448,7 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
this._warmingUp = true
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
// 워밍업 안내도 수명이 있다 — 모델이 뜬 뒤 갱신이 없으면 스스로 사라진다.
|
||||
this._armVisibleTtl()
|
||||
|
|
@ -430,6 +459,32 @@ class SuggestionService extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
// 모델이 유휴로 내려갔을 것으로 보이면 생성 대신 워밍업부터 한다.
|
||||
// 콜드 리로드(실측 11.4s)는 요청 타임아웃(8s)보다 길어 그대로 요청하면 항상 시간 초과한다.
|
||||
if (now >= this._modelWarmUntil) {
|
||||
if (this._canUseLocalMemory(context, config, now)) {
|
||||
const shown = this._publishLocalMemory(
|
||||
this._currentPrefix(context),
|
||||
context,
|
||||
config.maxCandidates,
|
||||
config.maxChars,
|
||||
0,
|
||||
null
|
||||
)
|
||||
if (shown) return
|
||||
}
|
||||
void this.warmUp()
|
||||
this._warmingUp = true
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._lastSkipReason = 'model-unavailable'
|
||||
this.emit('state-changed', this.getState())
|
||||
return
|
||||
}
|
||||
|
||||
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
|
||||
void this._generate(decision.prefix, context, config.maxCandidates, config.maxChars).catch(
|
||||
(error: unknown) => {
|
||||
|
|
@ -457,8 +512,12 @@ class SuggestionService extends EventEmitter {
|
|||
const prefix = context.prefix.replace(/\s+$/u, '')
|
||||
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
|
||||
|
||||
// 명시적 사용자 액션(단축키/설정)이다 — 텔레메트리가 판단한 "최근에 타이핑했는가" 에
|
||||
// 좌우되지 않아야 한다. 그대로 넘기면 _generate 실패 경로의 _canUseLocalMemory 가
|
||||
// (클릭만 한 뒤 수동 요청 같은 경우) not-typing 으로 막아 버린다.
|
||||
const explicitContext: TypingContext = { ...context, editedSinceFocus: true, typedRecently: true }
|
||||
const config = this.readPolicyConfig()
|
||||
await this._generate(prefix, context, config.maxCandidates, config.maxChars).catch((error: unknown) => {
|
||||
await this._generate(prefix, explicitContext, config.maxCandidates, config.maxChars).catch((error: unknown) => {
|
||||
logger.warn(`수동 제안 생성 예외: ${error instanceof Error ? error.message : String(error)}`)
|
||||
this._releaseGeneration()
|
||||
})
|
||||
|
|
@ -480,6 +539,8 @@ class SuggestionService extends EventEmitter {
|
|||
isEditable: context.isEditable,
|
||||
appName: context.appName,
|
||||
excludedApps: config.excludedApps,
|
||||
editedSinceFocus: context.editedSinceFocus,
|
||||
typedRecently: context.typedRecently,
|
||||
prefix: context.prefix,
|
||||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
|
|
@ -560,9 +621,11 @@ class SuggestionService extends EventEmitter {
|
|||
this._candidates = candidates.map((text, index) => ({ text, rank: index }))
|
||||
this._activeIndex = 0
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._generatedForPrefix = trimmedPrefix
|
||||
this._targetTotal = candidates.length
|
||||
this._lastSkipReason = null
|
||||
this._lastLatencyMs = latencyMs
|
||||
this._provenance = {
|
||||
|
|
@ -638,6 +701,7 @@ class SuggestionService extends EventEmitter {
|
|||
this._lastRequestedPrefix = prefix.replace(/\s+$/u, '')
|
||||
this._warmingUp = false
|
||||
this._provenance = null
|
||||
this._prevRequestAt = this._lastRequestAt
|
||||
this._lastRequestAt = Date.now()
|
||||
this._minuteCount += 1
|
||||
this._dayCount += 1
|
||||
|
|
@ -647,6 +711,7 @@ class SuggestionService extends EventEmitter {
|
|||
this._candidates = []
|
||||
this._activeIndex = 0
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._armVisibleTtl()
|
||||
|
|
@ -671,13 +736,15 @@ class SuggestionService extends EventEmitter {
|
|||
const hints = [...memory.relatedHints, ...memory.phraseHints]
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
.slice(0, 5)
|
||||
// 세션의 첫 요청은 딱 1개만 청한다 — 한꺼번에 여러 개를 요청하면 느리다(사용자
|
||||
// 요청). 첫 후보를 보여준 뒤 나머지는 채우기 루프가 하나씩 순차로 더 만든다.
|
||||
const { systemPrompt, text } = buildSuggestionPrompt({
|
||||
prefix: trimmedPrefix,
|
||||
appName: context.appName,
|
||||
windowTitle: context.windowTitle,
|
||||
phraseHints: hints,
|
||||
continuationHints,
|
||||
candidates: maxCandidates,
|
||||
candidates: 1,
|
||||
maxChars
|
||||
})
|
||||
|
||||
|
|
@ -697,6 +764,8 @@ class SuggestionService extends EventEmitter {
|
|||
for await (const chunk of stream) {
|
||||
if (abort.signal.aborted) break
|
||||
raw += chunk
|
||||
// 최소 한 청크라도 도착했으면 모델이 메모리에 올라온 것 — 유지 시각을 갱신한다.
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
|
||||
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
|
||||
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
|
||||
|
|
@ -783,7 +852,7 @@ class SuggestionService extends EventEmitter {
|
|||
return
|
||||
}
|
||||
|
||||
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, maxCandidates, maxChars)
|
||||
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
|
||||
if (candidates.length === 0) {
|
||||
this._lastSkipReason = 'generation-failed'
|
||||
logger.info('제안 후보가 비어 있음 (모델 출력 정제 후)')
|
||||
|
|
@ -809,14 +878,15 @@ class SuggestionService extends EventEmitter {
|
|||
this._lastLatencyMs = latencyMs
|
||||
this._consecutiveFailures = 0
|
||||
this._cooldownUntil = 0
|
||||
this._generating = false
|
||||
this._partialText = ''
|
||||
this._candidates = candidates.map((candidate, index) => ({ text: candidate, rank: index }))
|
||||
this._activeIndex = 0
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
this._appName = context.appName
|
||||
this._windowTitle = context.windowTitle
|
||||
this._generatedForPrefix = trimmedPrefix
|
||||
this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal
|
||||
this._lastSkipReason = null
|
||||
this._provenance = {
|
||||
mode: 'local-model',
|
||||
|
|
@ -827,9 +897,18 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
|
||||
this._record({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
|
||||
logger.info(`제안 ${candidates.length}개 생성 (${latencyMs}ms, model=${model})`)
|
||||
logger.info(`제안 첫 후보 생성 (${latencyMs}ms, model=${model}) — 최대 ${SUGGESTION_DEFAULTS.maxCandidatesTotal}개까지 순차로 채운다`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
|
||||
// generating 은 계속 true 로 남는다 — 채우기 루프가 백그라운드에서 나머지를
|
||||
// 하나씩 청한다("더 온다" 를 UI 에 알린다). _releaseGeneration() 이 이를
|
||||
// 지우지 않도록 _filling 을 먼저 켠다.
|
||||
this._filling = true
|
||||
void this._runFillLoop(token, context, maxChars).catch((error: unknown) => {
|
||||
logger.warn(`제안 채우기 루프 예외: ${error instanceof Error ? error.message : String(error)}`)
|
||||
this._filling = false
|
||||
})
|
||||
} finally {
|
||||
// 어떤 경로로 끝나든 진행 플래그를 해제한다 —
|
||||
// 예외가 어디로 새든 다음 제안이 조용히 막히지 않는다.
|
||||
|
|
@ -837,6 +916,126 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 채우기 루프 (2번째 이후 후보, 최대 12개) ────────────
|
||||
|
||||
/**
|
||||
* 첫 후보 공개 뒤 나머지를 하나씩 순차로 채운다.
|
||||
*
|
||||
* 종료 조건: 12개 도달, 연속 2번 새 후보 없음, 세대 토큰이 바뀜(세션 종료).
|
||||
* `_generate` 의 예산/타임아웃/실패 카운트와 무관하다 — 채우기는 그 어느 것도
|
||||
* 소비하지 않는다(설계). 시간 초과된 채우기 요청은 조용히 다음으로 넘어간다.
|
||||
*/
|
||||
private async _runFillLoop(token: number, context: TypingContext, maxChars: number): Promise<void> {
|
||||
let consecutiveEmpty = 0
|
||||
while (
|
||||
token === this._generationToken &&
|
||||
this._candidates.length < SUGGESTION_DEFAULTS.maxCandidatesTotal &&
|
||||
consecutiveEmpty < 2
|
||||
) {
|
||||
const appended = await this._fillOne(token, context, maxChars)
|
||||
if (token !== this._generationToken) break
|
||||
consecutiveEmpty = appended ? 0 : consecutiveEmpty + 1
|
||||
}
|
||||
if (token === this._generationToken) {
|
||||
this._filling = false
|
||||
this._generating = false
|
||||
this.emit('updated', this.getState())
|
||||
}
|
||||
}
|
||||
|
||||
/** 채우기 루프 한 스텝 — 후보 1개를 요청해 고유하면 덧붙인다. */
|
||||
private async _fillOne(token: number, context: TypingContext, maxChars: number): Promise<boolean> {
|
||||
const model = this.resolveModel()
|
||||
if (!model) return false
|
||||
|
||||
const abort = new AbortController()
|
||||
this._fillAbort = abort
|
||||
const timeoutMs = this.readTimeoutMs()
|
||||
const timer = setTimeout(() => {
|
||||
if (!abort.signal.aborted) abort.abort()
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
|
||||
try {
|
||||
// 세션이 고정한 접두를 쓴다 — context.prefix 는 그사이 바뀌었을 수 있지만,
|
||||
// 바뀌었다면 이미 위(handleTypingContext)에서 세션이 dismiss 되어 토큰이
|
||||
// 달라져 있으므로 이 루프는 다음 체크에서 멈춘다.
|
||||
const prefix = this._generatedForPrefix
|
||||
const memory = this._collectMemoryHints(prefix, context.appName)
|
||||
const hints = [...memory.relatedHints, ...memory.phraseHints]
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
.slice(0, 5)
|
||||
const avoidCandidates = this._candidates.map((candidate) => candidate.text)
|
||||
const { systemPrompt, text } = buildSuggestionPrompt({
|
||||
prefix,
|
||||
appName: context.appName,
|
||||
windowTitle: context.windowTitle,
|
||||
phraseHints: hints,
|
||||
continuationHints: memory.continuationHints,
|
||||
avoidCandidates,
|
||||
candidates: 1,
|
||||
maxChars
|
||||
})
|
||||
|
||||
let raw = ''
|
||||
const stream = getLocalLLMService().streamGenerate(text, {
|
||||
model,
|
||||
systemPrompt,
|
||||
temperature: SUGGESTION_DEFAULTS.temperature,
|
||||
maxTokens: SUGGESTION_DEFAULTS.maxOutputTokens,
|
||||
signal: abort.signal,
|
||||
keepAlive: SUGGESTION_KEEP_ALIVE
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
if (abort.signal.aborted) break
|
||||
raw += chunk
|
||||
this._modelWarmUntil = Date.now() + SUGGESTION_KEEP_ALIVE_MS - 30_000
|
||||
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
|
||||
}
|
||||
if (abort.signal.aborted || token !== this._generationToken) return false
|
||||
|
||||
const parsed = parseSuggestionCandidates(raw, prefix, 1, maxChars)
|
||||
if (parsed.length === 0) return false
|
||||
const candidateText = parsed[0]
|
||||
if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false
|
||||
|
||||
this._candidates.push({ text: candidateText, rank: this._candidates.length })
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._record({
|
||||
prefix,
|
||||
text: candidateText,
|
||||
model,
|
||||
latencyMs: 0,
|
||||
candidateCount: this._candidates.length
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
logger.debug(
|
||||
`제안 채우기 요청 실패 — 조용히 다음으로 넘어간다: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (this._fillAbort === abort) this._fillAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 정규화 후 완전 중복이거나 기존 후보의 접두/확장이면 중복으로 본다. */
|
||||
private _isDuplicateCandidate(candidate: string): boolean {
|
||||
const normalized = candidate.replace(/\s+/gu, ' ').trim().toLowerCase()
|
||||
return this._candidates.some((existing) => {
|
||||
const existingNormalized = existing.text.replace(/\s+/gu, ' ').trim().toLowerCase()
|
||||
return (
|
||||
existingNormalized === normalized ||
|
||||
existingNormalized.startsWith(normalized) ||
|
||||
normalized.startsWith(existingNormalized)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 생성 플래그를 무조건 해제한다 (누수 감시 포함).
|
||||
*
|
||||
|
|
@ -844,7 +1043,9 @@ class SuggestionService extends EventEmitter {
|
|||
*/
|
||||
private _releaseGeneration(): void {
|
||||
this._inFlight = false
|
||||
this._generating = false
|
||||
// 채우기 루프가 막 시작됐으면 generating 을 그대로 둔다 — 아직 더 올 게 있다.
|
||||
// (루프 자신이 끝날 때 스스로 false 로 내린다)
|
||||
if (!this._filling) this._generating = false
|
||||
this._partialText = ''
|
||||
}
|
||||
|
||||
|
|
@ -923,10 +1124,6 @@ class SuggestionService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private _shouldRegenerate(currentPrefix: string): boolean {
|
||||
return decideSuggestionRefresh(this._generatedForPrefix, currentPrefix) === 'regenerate'
|
||||
}
|
||||
|
||||
private _abortStaleGeneration(currentPrefix: string): void {
|
||||
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
|
||||
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
|
||||
|
|
@ -934,6 +1131,15 @@ class SuggestionService extends EventEmitter {
|
|||
|
||||
this._generationToken += 1
|
||||
this._abort.abort()
|
||||
|
||||
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
|
||||
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
|
||||
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
|
||||
this._lastRequestAt = this._prevRequestAt
|
||||
this._minuteCount = Math.max(0, this._minuteCount - 1)
|
||||
this._dayCount = Math.max(0, this._dayCount - 1)
|
||||
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
|
||||
if (this._candidates.length === 0) this.dismiss('stale')
|
||||
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
|
||||
}
|
||||
|
||||
|
|
@ -960,32 +1166,46 @@ class SuggestionService extends EventEmitter {
|
|||
this.emit('updated', this.getState())
|
||||
}
|
||||
|
||||
private _matchesGeneratedPrefix(currentPrefix: string): boolean {
|
||||
const current = currentPrefix.replace(/\s+$/u, '')
|
||||
if (!this._generatedForPrefix) return false
|
||||
return current === this._generatedForPrefix || current.startsWith(this._generatedForPrefix)
|
||||
}
|
||||
|
||||
// ── 사용자 동작 ───────────────────────────────────────
|
||||
|
||||
/** 이전 후보로 순환. */
|
||||
/** 이전 후보로 순환 (전체 후보를 가로질러, 페이지 무관). */
|
||||
previous(): SuggestionState {
|
||||
if (this._candidates.length > 1) {
|
||||
this._activeIndex =
|
||||
(this._activeIndex - 1 + this._candidates.length) % this._candidates.length
|
||||
this.emit('updated', this.getState())
|
||||
this._moveActive((this._activeIndex - 1 + this._candidates.length) % this._candidates.length)
|
||||
}
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
next(): SuggestionState {
|
||||
if (this._candidates.length > 1) {
|
||||
this._activeIndex = (this._activeIndex + 1) % this._candidates.length
|
||||
this.emit('updated', this.getState())
|
||||
this._moveActive((this._activeIndex + 1) % this._candidates.length)
|
||||
}
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/** 다음 페이지의 첫 항목으로 — 그 페이지에 후보가 없으면 그대로 둔다. */
|
||||
pageNext(): SuggestionState {
|
||||
const pageSize = SUGGESTION_DEFAULTS.pageSize
|
||||
const totalPages = Math.ceil(this._candidates.length / pageSize)
|
||||
const currentPage = Math.floor(this._activeIndex / pageSize)
|
||||
if (currentPage < totalPages - 1) this._moveActive((currentPage + 1) * pageSize)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/** 이전 페이지의 첫 항목으로 — 이미 첫 페이지면 그대로 둔다. */
|
||||
pagePrev(): SuggestionState {
|
||||
const currentPage = Math.floor(this._activeIndex / SUGGESTION_DEFAULTS.pageSize)
|
||||
if (currentPage > 0) this._moveActive((currentPage - 1) * SUGGESTION_DEFAULTS.pageSize)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/** 사용자가 후보를 훑는 중에는 표시 수명이 끝나 창이 닫히면 안 된다. */
|
||||
private _moveActive(index: number): void {
|
||||
this._activeIndex = index
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
}
|
||||
|
||||
dismiss(reason: SuggestionSkipReason = 'dismissed'): void {
|
||||
if (reason === 'dismissed') {
|
||||
// 명시적 닫기: 잠깐 조용히 있고, 진행 중 생성은 무효화한다.
|
||||
|
|
@ -1002,16 +1222,24 @@ class SuggestionService extends EventEmitter {
|
|||
this._partialText = ''
|
||||
this._activeIndex = 0
|
||||
this._anchor = null
|
||||
this._anchorKind = null
|
||||
this._generatedForPrefix = ''
|
||||
this._targetTotal = 0
|
||||
this._provenance = null
|
||||
this._lastSkipReason = reason
|
||||
this._abort?.abort()
|
||||
this._abort = null
|
||||
// 채우기 루프도 함께 끝낸다 — accept/dismiss/stale 은 전부 세션 종료다(설계).
|
||||
this._filling = false
|
||||
this._fillAbort?.abort()
|
||||
this._fillAbort = null
|
||||
if (this._timeoutTimer) {
|
||||
clearTimeout(this._timeoutTimer)
|
||||
this._timeoutTimer = null
|
||||
}
|
||||
this._cancelWarmUp()
|
||||
// 워밍업은 오버레이 표시와 독립된 요청이다 — 포커스 전환/stale 등으로 오버레이를
|
||||
// 지울 때마다 취소하면 모델이 영영 안 뜬다. 설정에서 기능을 끌 때만 취소한다.
|
||||
if (reason === 'disabled') this._cancelWarmUp()
|
||||
if (wasVisible || reason === 'dismissed') {
|
||||
this.emit('cleared', { reason })
|
||||
}
|
||||
|
|
@ -1100,7 +1328,8 @@ class SuggestionService extends EventEmitter {
|
|||
dailyBudget: configGet('suggestionDailyBudget') || SUGGESTION_DEFAULTS.dailyBudget,
|
||||
maxCandidates: SUGGESTION_DEFAULTS.maxCandidates,
|
||||
maxChars: SUGGESTION_MAX_OUTPUT_CHARS,
|
||||
excludedApps: [...configGet('inputExcludedApps')]
|
||||
// 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다.
|
||||
excludedApps: [...configGet('inputExcludedApps'), ...TERMINAL_APPS]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -186,12 +186,14 @@ class VoiceModeService extends EventEmitter {
|
|||
// 토글이므로 press만 처리하고 release는 버린다.
|
||||
if (payload.type === 'pressed') this._toggleCaption()
|
||||
return
|
||||
case 'history-popup':
|
||||
case 'command-popup':
|
||||
// 팝업 액션은 bootstrap이 직접 구독한다.
|
||||
return
|
||||
default:
|
||||
case 'dictation':
|
||||
case 'hands-free':
|
||||
case 'command':
|
||||
break
|
||||
default:
|
||||
// 음성 세션 액션만 여기서 처리한다. 팝업·제안 액션은 bootstrap이 직접 구독한다
|
||||
// (허용 목록이 아니면 새 액션이 생길 때마다 받아쓰기로 오인돼 녹음이 켜진다).
|
||||
return
|
||||
}
|
||||
|
||||
const holdMode = this._resolveHoldMode(payload.actionId, payload.holdMode)
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ export const SUGGESTION_NO_THINK_PREFIX = '/no_think'
|
|||
const SUGGESTION_SYSTEM_PROMPT = `사용자가 지금 입력창에 글을 쓰는 중입니다. 사용자가 마지막으로 쓴 글 뒤에 이어질 다음 문장을 제안하세요.
|
||||
|
||||
규칙:
|
||||
- 당신은 사용자와 대화하는 비서가 아닙니다. 사용자 본인이 다음에 직접 칠 문장만, 사용자 자신의 목소리로 쓰세요.
|
||||
- 사용자에게 질문하지 말고, 도와주겠다고 제안하지 말고, 사용자를 부르듯 말하지 마세요. ("궁금한 점이 있으신가요?", "도와드릴까요?" 같은 응답 금지)
|
||||
- 입력이 검색어나 폼 입력처럼 보이면(문장이 아니라 키워드 나열 등), 같은 종류의 텍스트로 짧게 이어 쓰세요.
|
||||
- 사용자가 언어와 같은 언어로 쓰세요.
|
||||
- 사용자의 말투와 문체를 유지하세요. 존댓말/반말, 격식/비격식을 바꾸지 마세요.
|
||||
- 이미 나온 단어를 되풀이하지 말고 이어지는 내용만 쓰세요.
|
||||
|
|
@ -186,6 +189,11 @@ export interface SuggestionPromptInput {
|
|||
* 자주 쓰는 표현보다 훨씬 강한 문맥 신호다 — 사용자 자신의 실제 이어쓰기다.
|
||||
*/
|
||||
continuationHints?: readonly string[]
|
||||
/**
|
||||
* 이미 이 세션에서 제안한 문장들 — 채우기 루프가 중복을 피하려고 넘긴다.
|
||||
* 지시문이 아니라 데이터 섹션으로만 들어간다(기존 continuationHints 와 같은 방식).
|
||||
*/
|
||||
avoidCandidates?: readonly string[]
|
||||
/** 후보 개수 */
|
||||
candidates?: number
|
||||
/** 후보당 최대 길이 */
|
||||
|
|
@ -227,6 +235,13 @@ export function buildSuggestionPrompt(input: SuggestionPromptInput): {
|
|||
sections.push('위 내용은 문체와 맥락 참고용입니다. 그대로 복사하지 말고 이어질 문장을 새로 쓰세요.')
|
||||
}
|
||||
|
||||
if (input.avoidCandidates && input.avoidCandidates.length > 0) {
|
||||
sections.push('', '[이미 제안한 문장 — 아래와 겹치지 않는 다른 문장을 쓰세요]')
|
||||
for (const avoid of input.avoidCandidates.slice(0, 12)) {
|
||||
sections.push(`- ${avoid}`)
|
||||
}
|
||||
}
|
||||
|
||||
sections.push('', `이어질 다음 문장 ${candidates}개를 한 줄씩 출력하세요.`)
|
||||
|
||||
const systemPrompt = `${SUGGESTION_NO_THINK_PREFIX}\n${SUGGESTION_SYSTEM_PROMPT.replace(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue