1450 lines
56 KiB
TypeScript
1450 lines
56 KiB
TypeScript
// src/main/services/SuggestionService.ts
|
|
//
|
|
// 다음 문장 제안(ghost text) — 입력 맥락을 받아 gemma(Ollama)로 후보를 만들고,
|
|
// 수락 시 대상 앱에 삽입한다.
|
|
//
|
|
// 설계 근거 (2025~2026 인라인 컴플리션 실측):
|
|
// - 디바운스는 타이핑 정지 후 600ms(기본)로, 모델 호출 빈도를 보수적으로 제한한다.
|
|
// - 출력 토큰은 극단적으로 작게(기본 64, Tabby 64 / KeyType 4~16) —
|
|
// 인라인 제안은 스트리밍으로 빨리 보여주고 짧게 끊는 것이 정석이다.
|
|
// - 문맥이 stale 이 된 진행 중 생성만 호출자 시그널로 취소하고, 다음 입력 문맥에서 재평가한다.
|
|
// - 후보가 나온 접두와 현재 접두가 달라지면 그 즉시 오버레이를 지운다 (stale).
|
|
|
|
import { EventEmitter } from 'events'
|
|
import {
|
|
SUGGESTION_DEFAULTS,
|
|
SUGGESTION_MAX_OUTPUT_CHARS,
|
|
buildLocalSuggestionCandidates,
|
|
decideSuggestion,
|
|
decideSuggestionRefresh,
|
|
decideSuggestionStep,
|
|
isAppExcluded,
|
|
normalizeRequestPrefix,
|
|
normalizeSessionPrefix,
|
|
parseSuggestionCandidates,
|
|
sanitizeSuggestionLine,
|
|
selectPhraseHints,
|
|
isTerminalApp,
|
|
extractTerminalPromptPrefix,
|
|
type AnchorKind,
|
|
type SuggestionCandidate,
|
|
type SuggestionPolicyInput,
|
|
type SuggestionProvenance,
|
|
type SuggestionSkipReason,
|
|
type SuggestionState,
|
|
type SuggestionStep,
|
|
type UiRect
|
|
} from '@d3ro/core/input-intelligence'
|
|
import { configGet, configSet } from './ConfigService'
|
|
import { getLogger } from './LoggerService'
|
|
import { getLocalLLMService } from './LocalLLMService'
|
|
import { buildSuggestionPrompt } from './llm-prompts'
|
|
import { getInputTelemetryService, type TypingContext } from './InputTelemetryService'
|
|
import { getPersonalGraphService } from './PersonalGraphService'
|
|
import { getTextInsertService } from './TextInsertService'
|
|
import { waitForModifiersReleased } from './modifier-state'
|
|
import {
|
|
createSqliteSuggestionRepository,
|
|
type SuggestionHistoryEntry,
|
|
type SuggestionRecordInput,
|
|
type SuggestionRepository
|
|
} from './SuggestionRepository'
|
|
import { SuggestionBudget } from './suggestion/SuggestionBudget'
|
|
import { ModelWarmTracker } from './suggestion/ModelWarmTracker'
|
|
import { getForegroundWindowInfo } from '../utils/win32-foreground'
|
|
|
|
const logger = getLogger('SuggestionService')
|
|
|
|
export type { SuggestionHistoryEntry }
|
|
|
|
/** 지금 포그라운드 창을 알려 주는 포트 — 수락 직전 대상 창 확인에 쓴다. */
|
|
export interface SuggestionForegroundProbe {
|
|
currentWindowHandle(): number | null
|
|
}
|
|
|
|
/** 수락한 텍스트를 학습 쪽에 알리는 포트. */
|
|
export interface SuggestionLearningPort {
|
|
/** 곧 프로그램이 삽입할 텍스트 — 다음 스냅샷 diff 가 "타이핑" 으로 다시 학습하지 않게 한다. 취소 함수를 돌려준다. */
|
|
expectProgrammaticInsert(text: string): () => void
|
|
/** 수락한 문장을 사용자 문체 표본으로 기록한다 (학습 동의 시에만 저장됨). */
|
|
recordAccepted(text: string, meta: { appName: string | null; windowTitle: string | null }): void
|
|
}
|
|
|
|
/** 서비스 협력자 — 기본값은 getSuggestionService() 가 연결한다 (DIP). */
|
|
export interface SuggestionServiceDeps {
|
|
repository: SuggestionRepository
|
|
budget: SuggestionBudget
|
|
warmth: ModelWarmTracker
|
|
foreground: SuggestionForegroundProbe
|
|
learning: SuggestionLearningPort
|
|
}
|
|
|
|
export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps {
|
|
return {
|
|
repository: createSqliteSuggestionRepository(),
|
|
budget: new SuggestionBudget(),
|
|
warmth: new ModelWarmTracker(),
|
|
foreground: {
|
|
currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null
|
|
},
|
|
learning: {
|
|
expectProgrammaticInsert: (text) => getInputTelemetryService().expectProgrammaticInsert(text),
|
|
recordAccepted: (text, meta) =>
|
|
getInputTelemetryService().recordExternalText(text, { ...meta, source: 'suggestion' })
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 설정에서 읽은 정책 값 (SSOT 기본값과 상한 적용 후). */
|
|
interface PolicyConfig {
|
|
triggerDelayMs: number
|
|
minPrefixChars: number
|
|
maxRequestsPerMinute: number
|
|
dailyBudget: number
|
|
maxCandidates: number
|
|
maxChars: number
|
|
excludedApps: string[]
|
|
}
|
|
|
|
export interface SuggestionRequestResult {
|
|
ok: boolean
|
|
reason?: SuggestionSkipReason
|
|
}
|
|
|
|
interface SuggestionEvents {
|
|
updated: (state: SuggestionState) => void
|
|
cleared: (payload: { reason: SuggestionSkipReason }) => void
|
|
'state-changed': (state: SuggestionState) => void
|
|
}
|
|
|
|
interface LocalMemoryContext {
|
|
continuationHints: string[]
|
|
relatedHints: string[]
|
|
phraseHints: string[]
|
|
appPhraseCount: number
|
|
}
|
|
|
|
/** 이 길이를 넘는 모델 출력은 버린다 (스트리밍 폭주 방지) */
|
|
const MAX_RAW_OUTPUT_CHARS = 1200
|
|
|
|
/**
|
|
* 모델을 메모리에 유지하는 시간.
|
|
*
|
|
* 콜드 리로드 실측(11.4s)이 요청 타임아웃(8s)보다 길어, 짧은 유휴 유지 시간은
|
|
* 유휴 후 첫 요청을 항상 타임아웃시켰다 — 충분히 길게 유지한다.
|
|
*/
|
|
const SUGGESTION_KEEP_ALIVE = '10m'
|
|
const SUGGESTION_KEEP_ALIVE_MS = 10 * 60_000
|
|
/** keep-alive 만료 직전 여유 — 경계에서 콜드 모델로 요청하지 않는다. */
|
|
const SUGGESTION_KEEP_ALIVE_MARGIN_MS = 30_000
|
|
/** 워밍업 실패 뒤 같은 모델 재시도를 미루는 시간 — 멈출 때마다 실패 요청을 쏘지 않는다. */
|
|
const WARM_UP_FAILURE_BACKOFF_MS = SUGGESTION_DEFAULTS.minIntervalMs
|
|
|
|
export 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 = ''
|
|
/** 생성 중 — 후보 도착 전에도 오버레이를 띄운다 */
|
|
private _generating = false
|
|
/** 모델 적재 중 — UI 가 "준비 중" 을 표시한다 */
|
|
private _warmingUp = false
|
|
/**
|
|
* 생성 세대 토큰.
|
|
*
|
|
* 사용자가 제안을 닫거나 새로 시작하면 증가한다. 진행 중이던 요청이 나중에
|
|
* 끝나도 토큰이 달라졌으면 결과를 버린다 — "X 로 닫았는데 잠시 뒤 다시 뜨던"
|
|
* 문제를 막는다.
|
|
*/
|
|
private _generationToken = 0
|
|
/** 마지막 문맥 수신 시각 — 늦게 도착한 결과를 판단한다 */
|
|
private _lastContextAt = 0
|
|
/** 표시 수명 타이머 (TTL) */
|
|
private _ttlTimer: NodeJS.Timeout | null = null
|
|
/** 마지막으로 실제 요청한 접두 (같은 텍스트 반복 요청 방지) */
|
|
private _lastRequestedPrefix = ''
|
|
/** 사용자가 X 로 닫은 뒤 재표시를 막는 시각 (ms) */
|
|
private _userDismissedUntil = 0
|
|
/** 스트리밍 누적 텍스트 */
|
|
private _partialText = ''
|
|
/** 마지막 스트리밍 방출 시각 (IPC 과다 방출 방지) */
|
|
private _lastPartialEmitAt = 0
|
|
private _lastContext: TypingContext | null = null
|
|
private _provenance: SuggestionProvenance | null = null
|
|
/** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */
|
|
private _windowHandle: number | null = null
|
|
|
|
/** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */
|
|
private readonly _budget: SuggestionBudget
|
|
/** 모델별 온기 (keep-alive) · 워밍업 실패 기록 */
|
|
private readonly _warmth: ModelWarmTracker
|
|
private readonly _repository: SuggestionRepository
|
|
private readonly _foreground: SuggestionForegroundProbe
|
|
private readonly _learning: SuggestionLearningPort
|
|
|
|
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
|
|
private _warmUpAbort: AbortController | null = null
|
|
private _warmUpRetryTimer: NodeJS.Timeout | null = null
|
|
|
|
constructor(deps: SuggestionServiceDeps = createDefaultSuggestionServiceDeps()) {
|
|
super()
|
|
this._budget = deps.budget
|
|
this._warmth = deps.warmth
|
|
this._repository = deps.repository
|
|
this._foreground = deps.foreground
|
|
this._learning = deps.learning
|
|
}
|
|
|
|
// ── 진단 접근자 (기존 진단·테스트가 읽는 예산 필드 이름을 유지한다) ──
|
|
// 예산 상태의 정본은 SuggestionBudget 이다. 새 코드는 _budget 을 직접 쓴다.
|
|
|
|
/** @internal */
|
|
get _lastRequestAt(): number {
|
|
return this._budget.lastRequestAt
|
|
}
|
|
|
|
/** @internal 진행 플래그 누수(watchdog) 재현용 */
|
|
set _lastRequestAt(at: number) {
|
|
this._budget.overrideLastRequestAt(at)
|
|
}
|
|
|
|
/** @internal */
|
|
get _prevRequestAt(): number {
|
|
return this._budget.prevRequestAt
|
|
}
|
|
|
|
/** @internal */
|
|
get _minuteCount(): number {
|
|
return this._budget.minuteCount
|
|
}
|
|
|
|
/** @internal */
|
|
get _dayCount(): number {
|
|
return this._budget.dayCount
|
|
}
|
|
|
|
/** @internal */
|
|
get _consecutiveFailures(): number {
|
|
return this._budget.consecutiveFailures
|
|
}
|
|
|
|
/** @internal */
|
|
get _cooldownUntil(): number {
|
|
return this._budget.cooldownUntil
|
|
}
|
|
|
|
override on<K extends keyof SuggestionEvents>(event: K, listener: SuggestionEvents[K]): this {
|
|
return super.on(event, listener as (...args: unknown[]) => void)
|
|
}
|
|
|
|
override off<K extends keyof SuggestionEvents>(event: K, listener: SuggestionEvents[K]): this {
|
|
return super.off(event, listener as (...args: unknown[]) => void)
|
|
}
|
|
|
|
override emit<K extends keyof SuggestionEvents>(
|
|
event: K,
|
|
...args: Parameters<SuggestionEvents[K]>
|
|
): boolean {
|
|
return super.emit(event, ...args)
|
|
}
|
|
|
|
// ── 상태 ──────────────────────────────────────────────
|
|
|
|
isEnabled(): boolean {
|
|
return configGet('suggestionEnabled') === true
|
|
}
|
|
|
|
get isVisible(): boolean {
|
|
return this._candidates.length > 0
|
|
}
|
|
|
|
get isPresentationActive(): boolean {
|
|
return this.isVisible || this._generating || this._warmingUp || this._partialText.length > 0
|
|
}
|
|
|
|
get activeText(): string | null {
|
|
return this._candidates[this._activeIndex]?.text ?? null
|
|
}
|
|
|
|
getState(): SuggestionState {
|
|
const now = Date.now()
|
|
return {
|
|
enabled: this.isEnabled(),
|
|
modelId: this.resolveModel(),
|
|
modelAvailable: getLocalLLMService().isAvailable(),
|
|
visible: this.isVisible,
|
|
generating: this._generating,
|
|
warmingUp: this._warmingUp,
|
|
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,
|
|
requestsToday: this._budget.requestsToday(now),
|
|
coolingDown: this._budget.isCoolingDown(now),
|
|
dailyBudget: configGet('suggestionDailyBudget') ?? SUGGESTION_DEFAULTS.dailyBudget,
|
|
lastLatencyMs: this._lastLatencyMs,
|
|
triggerDelayMs: this.readPolicyConfig().triggerDelayMs,
|
|
minPrefixChars: this.readPolicyConfig().minPrefixChars,
|
|
requestTimeoutMs: this.readTimeoutMs(),
|
|
learnTypedText: configGet('inputLearnTypedText') === true,
|
|
telemetryEnabled: configGet('inputTelemetryEnabled') === true,
|
|
overlayInteractive: configGet('suggestionOverlayInteractive') !== false,
|
|
provenance: this._provenance
|
|
}
|
|
}
|
|
|
|
/** 제안 전용 모델 (설정 없으면 기본 LLM 모델). */
|
|
resolveModel(): string | null {
|
|
const dedicated = configGet('suggestionModelId')
|
|
if (dedicated) return dedicated
|
|
return configGet('llmModelId') ?? null
|
|
}
|
|
|
|
/** 설정 변경 반영 — 꺼지면 즉시 오버레이를 내린다. */
|
|
applyConfig(): void {
|
|
if (!this.isEnabled()) {
|
|
this.dismiss('disabled')
|
|
}
|
|
this.emit('state-changed', this.getState())
|
|
}
|
|
|
|
/**
|
|
* 제안에 쓸 모델이 바뀌었다 (전용 모델 설정 또는 기본 LLM 모델 변경).
|
|
*
|
|
* 새 모델은 콜드일 수 있다 — 첫 멈춤에서 콜드 로드가 타임아웃되기 전에 미리 올린다.
|
|
* 온기는 모델별로 추적하므로 이 호출이 없어도 첫 요청은 워밍업 경로로 간다.
|
|
*/
|
|
handleModelChanged(): void {
|
|
if (!this.isEnabled()) return
|
|
const model = this.resolveModel()
|
|
if (!model || this._warmth.isWarm(model, Date.now())) return
|
|
logger.info(`제안 모델 변경 감지 (model=${model}) — 미리 워밍업한다`)
|
|
void this.warmUp()
|
|
}
|
|
|
|
setEnabled(enabled: boolean): void {
|
|
configSet('suggestionEnabled', enabled)
|
|
if (!enabled) this.dismiss('disabled')
|
|
else void this.warmUp()
|
|
this.emit('state-changed', this.getState())
|
|
}
|
|
|
|
/** 설정에서 명시적으로 제안을 켤 때만 모델을 짧게 준비한다. */
|
|
warmUp(): Promise<void> {
|
|
if (!this.isEnabled()) return Promise.resolve()
|
|
if (this._warmUpPromise) return this._warmUpPromise
|
|
|
|
const abort = new AbortController()
|
|
this._warmUpAbort = abort
|
|
this._warmingUp = true
|
|
this.emit('state-changed', this.getState())
|
|
const warmUp = this._warmUpUntilReady(abort)
|
|
this._warmUpPromise = warmUp
|
|
void warmUp.finally(() => {
|
|
if (this._warmUpPromise === warmUp) this._warmUpPromise = null
|
|
if (this._warmUpAbort === abort) this._warmUpAbort = null
|
|
})
|
|
return warmUp
|
|
}
|
|
|
|
private async _warmUpUntilReady(abort: AbortController): Promise<void> {
|
|
try {
|
|
for (let attempt = 0; attempt <= 15; attempt += 1) {
|
|
if (abort.signal.aborted || !this.isEnabled()) return
|
|
if (!getLocalLLMService().isAvailable()) {
|
|
if (attempt === 15) return
|
|
await this._waitForWarmUpRetry(abort.signal)
|
|
continue
|
|
}
|
|
|
|
const model = this.resolveModel()
|
|
if (!model) return
|
|
|
|
try {
|
|
const stream = getLocalLLMService().streamGenerate('hi', {
|
|
model,
|
|
maxTokens: 1,
|
|
temperature: 0,
|
|
signal: abort.signal,
|
|
keepAlive: SUGGESTION_KEEP_ALIVE
|
|
})
|
|
for await (const chunk of stream) void chunk
|
|
if (abort.signal.aborted) return
|
|
this._noteModelWarm(model)
|
|
logger.info(`제안 모델 워밍업 완료 (model=${model})`)
|
|
} catch (error) {
|
|
if (!abort.signal.aborted) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
logger.warn(`제안 모델 워밍업 실패: ${message}`)
|
|
// 실패한 워밍업도 실패다 — 기록하지 않으면 멈출 때마다 새 워밍업을 쏘며
|
|
// "준비 중" 만 깜빡이고, 쿨다운·예산이 전혀 걸리지 않는다.
|
|
this._warmth.noteWarmUpFailure(model, Date.now())
|
|
this._lastSkipReason = 'generation-failed'
|
|
this._noteFailure(`warm-up: ${message}`)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
} finally {
|
|
this._warmingUp = false
|
|
// 'state-changed' 뿐 아니라 'updated' 도 보내야 한다 — 오버레이는 'updated' 를
|
|
// 듣고 표시 여부를 판단하는데, 이게 빠지면 타이핑이 멈춘 사이 워밍업이 끝나도
|
|
// "준비 중" 오버레이가 20초 TTL 까지 그대로 남는다.
|
|
this.emit('updated', this.getState())
|
|
this.emit('state-changed', this.getState())
|
|
}
|
|
}
|
|
|
|
private _waitForWarmUpRetry(signal: AbortSignal): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
const finish = (): void => {
|
|
signal.removeEventListener('abort', finish)
|
|
if (this._warmUpRetryTimer) {
|
|
clearTimeout(this._warmUpRetryTimer)
|
|
this._warmUpRetryTimer = null
|
|
}
|
|
resolve()
|
|
}
|
|
this._warmUpRetryTimer = setTimeout(finish, 2000)
|
|
this._warmUpRetryTimer.unref?.()
|
|
signal.addEventListener('abort', finish, { once: true })
|
|
})
|
|
}
|
|
|
|
private _cancelWarmUp(): void {
|
|
if (this._warmUpRetryTimer) {
|
|
clearTimeout(this._warmUpRetryTimer)
|
|
this._warmUpRetryTimer = null
|
|
}
|
|
this._warmUpAbort?.abort()
|
|
this._warmingUp = false
|
|
}
|
|
|
|
// ── 입력 텍스트 처리 ────────────────────────────────
|
|
|
|
/**
|
|
* 입력 텔레메트리가 보내는 "지금 치고 있는 것" 이벤트.
|
|
*
|
|
* 판단은 core `decideSuggestionStep` 이 한다(세션 유효성 → 쿨다운 → 사용자 닫기 →
|
|
* 정책 → 모델 온기). 서비스는 그 결과만 실행한다.
|
|
*/
|
|
handleTypingContext(input: TypingContext): void {
|
|
// 터미널은 화면 전체가 읽힌다 — 입력 줄만 접두로 쓴다. 입력 줄이 없으면 빈 접두(제안 안 함).
|
|
const context: TypingContext = isTerminalApp(input.appName)
|
|
? { ...input, prefix: extractTerminalPromptPrefix(input.fullText ?? input.prefix) ?? '' }
|
|
: input
|
|
this._lastContext = context
|
|
const now = Date.now()
|
|
this._lastContextAt = now
|
|
this._watchdog()
|
|
|
|
const config = this.readPolicyConfig()
|
|
this._logDecisionInputs(context)
|
|
this._abortStaleGeneration(context.prefix)
|
|
|
|
const model = this.resolveModel()
|
|
const step = decideSuggestionStep({
|
|
policy: this._buildPolicyInput(context, config, now),
|
|
sessionVisible: this.isVisible,
|
|
sessionPrefix: this._generatedForPrefix,
|
|
coolingDown: this._budget.isCoolingDown(now),
|
|
userDismissedQuiet: now < this._userDismissedUntil,
|
|
lastRequestedPrefix: this._lastRequestedPrefix,
|
|
modelWarm: this._warmth.isWarm(model, now),
|
|
warmUpBackoff: this._warmth.isBackingOff(model, now, WARM_UP_FAILURE_BACKOFF_MS)
|
|
})
|
|
this._executeStep(step, context, config, now)
|
|
}
|
|
|
|
private _executeStep(step: SuggestionStep, context: TypingContext, config: PolicyConfig, now: number): void {
|
|
switch (step.action) {
|
|
case 'end-session':
|
|
// 세션(페이지 넘기며 보는 고정 목록)을 보여주는 중에 다음 문장이 시작됐다 —
|
|
// 이어 치기로 자란 것도 포함, IME 마지막 글자 조합만 예외. 즉시 세션을 끝내고
|
|
// 다음 멈춤에서 정책 게이트를 다시 거쳐 새 세션을 시작한다.
|
|
logger.debug('제안 닫음: 이어서 입력함 — 멈추면 새 문맥으로 다시 만든다')
|
|
this.dismiss(step.reason)
|
|
// 사용자가 고르지 않고 계속 쳤다 — 이 세션 때문에 최소 간격(5초)에 걸려
|
|
// 정작 멈췄을 때 아무것도 안 뜨면 안 된다. 분당·일일 상한은 그대로 둔다.
|
|
this._budget.relaxInterval(now, SUGGESTION_DEFAULTS.minIntervalMs)
|
|
this.emit('state-changed', this.getState())
|
|
return
|
|
case 'generate':
|
|
// 비동기 실패가 조용히 사라지면 기능이 죽은 이유를 알 수 없다.
|
|
void this._generate(step.prefix, context, config.maxCandidates, config.maxChars).catch(
|
|
(error: unknown) => {
|
|
logger.warn(
|
|
`제안 생성 파이프라인 예외: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
this._releaseGeneration()
|
|
}
|
|
)
|
|
return
|
|
case 'settle':
|
|
this._settle(step, context, config)
|
|
return
|
|
}
|
|
}
|
|
|
|
/** 생성하지 않는 결정 — 사유를 남기고, 필요하면 로컬 기억/준비 중/닫기를 수행한다. */
|
|
private _settle(
|
|
step: Extract<SuggestionStep, { action: 'settle' }>,
|
|
context: TypingContext,
|
|
config: PolicyConfig
|
|
): void {
|
|
logger.debug(`제안 보류: ${step.reason} (${step.then})`)
|
|
this._lastSkipReason = step.reason
|
|
|
|
if (
|
|
step.tryLocalMemory &&
|
|
this._publishLocalMemory(
|
|
this._currentPrefix(context),
|
|
context,
|
|
config.maxCandidates,
|
|
config.maxChars,
|
|
0,
|
|
null
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
|
|
switch (step.then) {
|
|
case 'record':
|
|
break
|
|
case 'dismiss':
|
|
if (this.isPresentationActive) this.dismiss(step.reason)
|
|
break
|
|
case 'warm-up':
|
|
void this.warmUp()
|
|
this._showWarming(context)
|
|
break
|
|
case 'show-warming':
|
|
// 모델이 아직 안 떠 있으면 "준비 중" 을 보여준다 — 아무 반응이 없으면
|
|
// 사용자는 기능이 죽었다고 판단한다(첫 실행에서 실제로 그랬다).
|
|
this._showWarming(context)
|
|
break
|
|
}
|
|
this.emit('state-changed', this.getState())
|
|
}
|
|
|
|
/** "준비 중" 오버레이 — 워밍업 안내도 수명이 있어, 갱신이 없으면 스스로 사라진다. */
|
|
private _showWarming(context: TypingContext): void {
|
|
this._warmingUp = true
|
|
this._anchor = context.anchor
|
|
this._anchorKind = context.anchorKind
|
|
this._appName = context.appName
|
|
this._armVisibleTtl()
|
|
this.emit('updated', this.getState())
|
|
}
|
|
|
|
/** 수동 요청 (설정/단축키 경로). 디바운스를 건너뛴다. */
|
|
async requestNow(): Promise<SuggestionRequestResult> {
|
|
if (!this.isEnabled()) return { ok: false, reason: 'disabled' }
|
|
const context = this._lastContext
|
|
if (!context?.available || !context.isEditable || context.isPassword || context.hasSelection) {
|
|
const reason = context?.isPassword
|
|
? 'password-field'
|
|
: context?.hasSelection
|
|
? 'selection-active'
|
|
: 'not-editable'
|
|
if (this.isPresentationActive) this.dismiss(reason)
|
|
return { ok: false, reason }
|
|
}
|
|
const prefix = normalizeRequestPrefix(context.prefix)
|
|
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, explicitContext, config.maxCandidates, config.maxChars).catch((error: unknown) => {
|
|
logger.warn(`수동 제안 생성 예외: ${error instanceof Error ? error.message : String(error)}`)
|
|
this._releaseGeneration()
|
|
})
|
|
return this.isVisible ? { ok: true } : { ok: false, reason: this._lastSkipReason ?? 'generation-failed' }
|
|
}
|
|
|
|
/**
|
|
* 정책 입력 조립 — 모든 경로가 이 한 곳에서 만든다.
|
|
*
|
|
* 기본값: 모델 가용성은 실제 값, overlayVisible 은 "생성/워밍업/후보 표시 중" 전체.
|
|
*/
|
|
private _buildPolicyInput(
|
|
context: TypingContext,
|
|
config: PolicyConfig,
|
|
now: number,
|
|
overrides: Partial<Pick<SuggestionPolicyInput, 'modelAvailable' | 'overlayVisible'>> = {}
|
|
): SuggestionPolicyInput {
|
|
const budget = this._budget.snapshot(now)
|
|
return {
|
|
enabled: this.isEnabled(),
|
|
modelAvailable: overrides.modelAvailable ?? getLocalLLMService().isAvailable(),
|
|
overlayVisible: overrides.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,
|
|
minPrefixChars: config.minPrefixChars,
|
|
sinceLastRequestMs: budget.sinceLastRequestMs,
|
|
minIntervalMs: SUGGESTION_DEFAULTS.minIntervalMs,
|
|
requestsThisMinute: budget.requestsThisMinute,
|
|
maxRequestsPerMinute: config.maxRequestsPerMinute,
|
|
requestsToday: budget.requestsToday,
|
|
dailyBudget: config.dailyBudget
|
|
}
|
|
}
|
|
|
|
/** 로컬 기억은 모델과 무관하다 — 같은 정책을 "모델 있음 · 표시 없음" 으로 평가한다. */
|
|
private _canUseLocalMemory(context: TypingContext, config: PolicyConfig, now: number): boolean {
|
|
return (
|
|
decideSuggestion(
|
|
this._buildPolicyInput(context, config, now, { modelAvailable: true, overlayVisible: false })
|
|
).action === 'request'
|
|
)
|
|
}
|
|
|
|
private _collectMemoryHints(prefix: string, appName: string | null): LocalMemoryContext {
|
|
const telemetry = getInputTelemetryService()
|
|
const graphContext = getPersonalGraphService().retrieveContext(prefix, 4)
|
|
const phrases = telemetry.listPhrases(60)
|
|
const phraseHints = selectPhraseHints(phrases, prefix, 5, { appName })
|
|
const requestedApp = appName?.trim().toLowerCase()
|
|
const appPhraseCount = requestedApp
|
|
? phrases.filter(
|
|
(phrase) =>
|
|
phrase.appName?.trim().toLowerCase() === requestedApp && phraseHints.includes(phrase.phrase)
|
|
).length
|
|
: 0
|
|
return {
|
|
continuationHints: graphContext.continuations,
|
|
relatedHints: graphContext.related,
|
|
phraseHints,
|
|
appPhraseCount
|
|
}
|
|
}
|
|
|
|
private _isCurrentFallbackContext(
|
|
prefix: string,
|
|
context: TypingContext,
|
|
token: number | null
|
|
): boolean {
|
|
if (token !== null && token !== this._generationToken) return false
|
|
const current = this._lastContext
|
|
if (
|
|
!current ||
|
|
!current.available ||
|
|
!current.isEditable ||
|
|
current.isPassword ||
|
|
current.isComposing ||
|
|
current.hasSelection
|
|
) {
|
|
return false
|
|
}
|
|
return (
|
|
normalizeSessionPrefix(current.prefix) === normalizeSessionPrefix(prefix) &&
|
|
current.appName === context.appName &&
|
|
current.windowTitle === context.windowTitle
|
|
)
|
|
}
|
|
|
|
private _publishLocalMemory(
|
|
prefix: string,
|
|
context: TypingContext,
|
|
maxCandidates: number,
|
|
maxChars: number,
|
|
latencyMs: number,
|
|
token: number | null,
|
|
memory: LocalMemoryContext | null = null
|
|
): boolean {
|
|
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
|
|
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
|
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
|
|
const candidates = buildLocalSuggestionCandidates(trimmedPrefix, localMemory, maxCandidates, maxChars)
|
|
if (candidates.length === 0) return false
|
|
|
|
this._partialText = ''
|
|
this._generating = false
|
|
this._warmingUp = false
|
|
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._windowHandle = context.windowHandle ?? null
|
|
this._generatedForPrefix = trimmedPrefix
|
|
this._targetTotal = candidates.length
|
|
this._lastSkipReason = null
|
|
this._lastLatencyMs = latencyMs
|
|
this._provenance = {
|
|
mode: 'local-memory',
|
|
continuationCount: localMemory.continuationHints.length,
|
|
relatedCount: localMemory.relatedHints.length,
|
|
phraseCount: localMemory.phraseHints.length,
|
|
appPhraseCount: localMemory.appPhraseCount
|
|
}
|
|
this._recordSuggestion({
|
|
prefix: trimmedPrefix,
|
|
text: candidates[0],
|
|
model: 'local-memory',
|
|
latencyMs,
|
|
candidateCount: candidates.length
|
|
})
|
|
this._armVisibleTtl()
|
|
this.emit('updated', this.getState())
|
|
this.emit('state-changed', this.getState())
|
|
logger.info(`로컬 기억 제안 ${candidates.length}개 생성`)
|
|
return true
|
|
}
|
|
|
|
// ── 생성 ──────────────────────────────────────────────
|
|
|
|
private async _generate(
|
|
prefix: string,
|
|
context: TypingContext,
|
|
maxCandidates: number,
|
|
maxChars: number
|
|
): Promise<void> {
|
|
if (this._inFlight) {
|
|
// 침묵하면 "왜 안 뜨는지" 를 알 수 없다 — 진단을 남긴다.
|
|
logger.debug(`제안 건너뜀: 이미 생성 중 (${Date.now() - this._budget.lastRequestAt}ms 경과)`)
|
|
return
|
|
}
|
|
|
|
// 어떤 경로로 끝나든 진행 플래그는 반드시 해제한다.
|
|
// (emit/showOverlay 쪽 예외가 여기로 새면 _inFlight 가 true 로 굳어
|
|
// 이후 모든 제안이 조용히 막힌다 — "한 번 나오고 안 나옴" 의 원인 후보)
|
|
try {
|
|
|
|
const startedAt = Date.now()
|
|
// 세션 접두 — 프롬프트 입력이자 세션 유효성 비교의 기준 (normalizeSessionPrefix 정본)
|
|
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
|
const model = this.resolveModel()
|
|
if (!model) {
|
|
const config = this.readPolicyConfig()
|
|
if (
|
|
this._canUseLocalMemory(context, config, startedAt) &&
|
|
this._publishLocalMemory(
|
|
trimmedPrefix,
|
|
context,
|
|
maxCandidates,
|
|
maxChars,
|
|
Date.now() - startedAt,
|
|
null
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
this._lastSkipReason = 'model-unavailable'
|
|
this.emit('state-changed', this.getState())
|
|
return
|
|
}
|
|
|
|
this._abort?.abort()
|
|
const abort = new AbortController()
|
|
this._abort = abort
|
|
const token = (this._generationToken += 1)
|
|
|
|
this._inFlight = true
|
|
this._generating = true
|
|
this._lastRequestedPrefix = normalizeRequestPrefix(prefix)
|
|
this._warmingUp = false
|
|
this._provenance = null
|
|
this._budget.consume(Date.now())
|
|
|
|
// 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고
|
|
// "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다.
|
|
this._candidates = []
|
|
this._activeIndex = 0
|
|
this._anchor = context.anchor
|
|
this._anchorKind = context.anchorKind
|
|
this._appName = context.appName
|
|
this._windowTitle = context.windowTitle
|
|
this._windowHandle = context.windowHandle ?? null
|
|
this._armVisibleTtl()
|
|
this.emit('updated', this.getState())
|
|
|
|
const timeoutMs = this.readTimeoutMs()
|
|
let timedOut = false
|
|
if (this._timeoutTimer) clearTimeout(this._timeoutTimer)
|
|
this._timeoutTimer = setTimeout(() => {
|
|
// 모델이 하드웨어보다 느릴 때 UI 를 붙잡지 않는다 (다음 타이핑 주기에 재시도).
|
|
if (!abort.signal.aborted) {
|
|
timedOut = true
|
|
abort.abort()
|
|
}
|
|
}, timeoutMs)
|
|
this._timeoutTimer.unref?.()
|
|
|
|
// 개인 그래프 문맥: (1) 과거에 그 꼬리 뒤에 실제로 이어 쓴 문장,
|
|
// (2) follows/용어공유 관계로 끌어온 관련 문장.
|
|
const memory = this._collectMemoryHints(trimmedPrefix, context.appName)
|
|
const continuationHints = memory.continuationHints
|
|
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: 1,
|
|
maxChars
|
|
})
|
|
|
|
let raw = ''
|
|
try {
|
|
this._partialText = ''
|
|
|
|
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._noteModelWarm(model)
|
|
|
|
// 도착하는 대로 오버레이에 흘려보낸다 — 사용자는 "계속 생성되는" 것을 본다.
|
|
// IPC 과다 방출을 막기 위해 120ms 간격으로만 보낸다.
|
|
this._partialText = sanitizePartial(raw)
|
|
const now = Date.now()
|
|
if (now - this._lastPartialEmitAt >= 120) {
|
|
this._lastPartialEmitAt = now
|
|
this.emit('updated', this.getState())
|
|
}
|
|
|
|
if (raw.length >= MAX_RAW_OUTPUT_CHARS) break
|
|
}
|
|
} catch (error) {
|
|
if (!abort.signal.aborted) {
|
|
this._lastSkipReason = 'generation-failed'
|
|
this._noteFailure(error instanceof Error ? error.message : String(error))
|
|
if (
|
|
this._publishLocalMemory(
|
|
trimmedPrefix,
|
|
context,
|
|
maxCandidates,
|
|
maxChars,
|
|
Date.now() - startedAt,
|
|
token,
|
|
memory
|
|
)
|
|
) {
|
|
logger.warn(`모델 제안 실패 후 로컬 기억 제안으로 전환: ${error instanceof Error ? error.message : String(error)}`)
|
|
return
|
|
}
|
|
this.dismiss('generation-failed')
|
|
this.emit('state-changed', this.getState())
|
|
return
|
|
}
|
|
} finally {
|
|
if (this._abort === abort) this._abort = null
|
|
if (this._timeoutTimer) {
|
|
clearTimeout(this._timeoutTimer)
|
|
this._timeoutTimer = null
|
|
}
|
|
}
|
|
|
|
if (abort.signal.aborted) {
|
|
if (token !== this._generationToken) {
|
|
logger.debug(`중단된 제안 결과 폐기 (세대 ${token} ≠ ${this._generationToken})`)
|
|
return
|
|
}
|
|
|
|
if (!timedOut) return
|
|
|
|
this._lastSkipReason = 'generation-failed'
|
|
this._noteFailure(`timeout ${timeoutMs}ms`)
|
|
if (
|
|
this._publishLocalMemory(
|
|
trimmedPrefix,
|
|
context,
|
|
maxCandidates,
|
|
maxChars,
|
|
Date.now() - startedAt,
|
|
token,
|
|
memory
|
|
)
|
|
) {
|
|
logger.warn(`모델 제안 시간 초과 후 로컬 기억 제안으로 전환 (${timeoutMs}ms)`)
|
|
return
|
|
}
|
|
this.dismiss('generation-failed')
|
|
this.emit('state-changed', this.getState())
|
|
return
|
|
}
|
|
|
|
// 사용자가 그사이 닫았거나 새 요청이 시작됐다면 이 결과는 버린다.
|
|
if (token !== this._generationToken) {
|
|
logger.debug(`제안 결과 폐기 (세대 ${token} ≠ ${this._generationToken})`)
|
|
return
|
|
}
|
|
|
|
// 타이핑을 멈추고 자리를 뜬 뒤 도착한 결과는 화면에 띄우지 않는다.
|
|
// (아무것도 안 치는데 제안창이 뜨던 신고의 원인 중 하나)
|
|
const staleness = Date.now() - this._lastContextAt
|
|
if (this._lastContextAt > 0 && staleness > SUGGESTION_DEFAULTS.resultMaxStalenessMs) {
|
|
logger.debug(`제안 결과 폐기 (문맥이 ${staleness}ms 지났다)`)
|
|
this.dismiss('stale')
|
|
return
|
|
}
|
|
|
|
const candidates = parseSuggestionCandidates(raw, trimmedPrefix, 1, maxChars)
|
|
if (candidates.length === 0) {
|
|
this._lastSkipReason = 'generation-failed'
|
|
logger.info('제안 후보가 비어 있음 (모델 출력 정제 후)')
|
|
if (
|
|
this._publishLocalMemory(
|
|
trimmedPrefix,
|
|
context,
|
|
maxCandidates,
|
|
maxChars,
|
|
Date.now() - startedAt,
|
|
token,
|
|
memory
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
this.dismiss('generation-failed')
|
|
this.emit('state-changed', this.getState())
|
|
return
|
|
}
|
|
|
|
const latencyMs = Date.now() - startedAt
|
|
this._lastLatencyMs = latencyMs
|
|
this._budget.noteSuccess()
|
|
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._windowHandle = context.windowHandle ?? null
|
|
this._generatedForPrefix = trimmedPrefix
|
|
this._targetTotal = SUGGESTION_DEFAULTS.maxCandidatesTotal
|
|
this._lastSkipReason = null
|
|
this._provenance = {
|
|
mode: 'local-model',
|
|
continuationCount: continuationHints.length,
|
|
relatedCount: memory.relatedHints.length,
|
|
phraseCount: memory.phraseHints.length,
|
|
appPhraseCount: memory.appPhraseCount
|
|
}
|
|
|
|
this._recordSuggestion({ prefix: trimmedPrefix, text: candidates[0], model, latencyMs, candidateCount: candidates.length })
|
|
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 {
|
|
// 어떤 경로로 끝나든 진행 플래그를 해제한다 —
|
|
// 예외가 어디로 새든 다음 제안이 조용히 막히지 않는다.
|
|
this._releaseGeneration()
|
|
}
|
|
}
|
|
|
|
// ── 채우기 루프 (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._noteModelWarm(model)
|
|
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 })
|
|
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
|
|
this._armVisibleTtl()
|
|
this.emit('updated', this.getState())
|
|
this._recordSuggestion({
|
|
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)
|
|
)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 생성 플래그를 무조건 해제한다 (누수 감시 포함).
|
|
*
|
|
* 응답 제한 + 여유를 넘겨 진행 중이면 비정상 상태로 보고 스스로 복구한다.
|
|
*/
|
|
private _releaseGeneration(): void {
|
|
this._inFlight = false
|
|
// 채우기 루프가 막 시작됐으면 generating 을 그대로 둔다 — 아직 더 올 게 있다.
|
|
// (루프 자신이 끝날 때 스스로 false 로 내린다)
|
|
if (!this._filling) this._generating = false
|
|
this._partialText = ''
|
|
}
|
|
|
|
/** 마지막 생성 이후 흐른 시간 (ms). */
|
|
get inFlightAgeMs(): number {
|
|
return this._inFlight ? Date.now() - this._budget.lastRequestAt : 0
|
|
}
|
|
|
|
/** 응답 제한 (설정 → SSOT 기본값). */
|
|
private readTimeoutMs(): number {
|
|
const configured = configGet('suggestionRequestTimeoutMs')
|
|
return configured && configured > 0 ? configured : SUGGESTION_DEFAULTS.requestTimeoutMs
|
|
}
|
|
|
|
/** 현재 정책 기준의 접두. */
|
|
private _currentPrefix(context: TypingContext): string {
|
|
return normalizeRequestPrefix(context.prefix)
|
|
}
|
|
|
|
/** 판단 입력을 서명이 바뀔 때만 남긴다. */
|
|
private _logDecisionInputs(context: TypingContext): void {
|
|
const signature = [
|
|
context.available,
|
|
context.isEditable,
|
|
context.isPassword,
|
|
context.isComposing,
|
|
context.anchor ? 'A' : '-',
|
|
context.editedSinceFocus,
|
|
context.typedRecently,
|
|
getLocalLLMService().isAvailable(),
|
|
this.isEnabled()
|
|
].join('|')
|
|
|
|
if (signature === this._lastDecisionSignature) return
|
|
this._lastDecisionSignature = signature
|
|
logger.info(
|
|
`제안 판단 입력: avail=${context.available} edit=${context.isEditable} pw=${context.isPassword} ` +
|
|
`comp=${context.isComposing} edited=${context.editedSinceFocus} typed=${context.typedRecently} ` +
|
|
`prefixLen=${context.prefix.length} idle=${context.idleMs}ms ` +
|
|
`app=${context.appName ?? '-'} enabled=${this.isEnabled()} ` +
|
|
`model=${getLocalLLMService().isAvailable()} anchor=${context.anchor ? 'yes' : 'no'}`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 생성 실패를 기록하고, 연속 실패면 잠시 쉰다.
|
|
*
|
|
* 실패할 때마다 새 요청이 스피너를 다시 걸면 "끝나지도 꺼지지도 않는" 것처럼 보인다
|
|
* (실측 신고). 임계 도달 시 쿨다운 동안은 요청 자체를 하지 않는다.
|
|
*/
|
|
private _noteFailure(reason: string): void {
|
|
if (!this._budget.noteFailure(Date.now())) return
|
|
logger.warn(
|
|
`제안 생성 연속 실패 (${reason}) — ${Math.round(
|
|
SUGGESTION_DEFAULTS.failureCooldownMs / 1000
|
|
)}초 동안 요청을 쉰다 (모델이 다른 작업으로 바쁠 수 있음)`
|
|
)
|
|
}
|
|
|
|
/** 이 모델이 방금 응답했다 — keep-alive 동안 메모리에 남아 있다고 본다. */
|
|
private _noteModelWarm(model: string): void {
|
|
this._warmth.noteWarm(model, Date.now() + SUGGESTION_KEEP_ALIVE_MS - SUGGESTION_KEEP_ALIVE_MARGIN_MS)
|
|
}
|
|
|
|
private _armVisibleTtl(): void {
|
|
if (this._ttlTimer) clearTimeout(this._ttlTimer)
|
|
this._ttlTimer = setTimeout(() => {
|
|
this._ttlTimer = null
|
|
logger.debug('제안 표시 수명 만료 — 자동으로 닫는다')
|
|
this.dismiss('stale')
|
|
}, SUGGESTION_DEFAULTS.visibleTtlMs)
|
|
this._ttlTimer.unref?.()
|
|
}
|
|
|
|
/** 표시 수명 타이머 해제. */
|
|
private _clearVisibleTtl(): void {
|
|
if (this._ttlTimer) {
|
|
clearTimeout(this._ttlTimer)
|
|
this._ttlTimer = null
|
|
}
|
|
}
|
|
|
|
private _abortStaleGeneration(currentPrefix: string): void {
|
|
if (!this._inFlight || !this._abort || this._abort.signal.aborted) return
|
|
const refresh = decideSuggestionRefresh(this._lastRequestedPrefix, currentPrefix)
|
|
if (refresh === 'keep') return
|
|
|
|
this._generationToken += 1
|
|
this._abort.abort()
|
|
|
|
// 취소된 요청은 속도 제한 예산을 쓰지 않았던 것으로 되돌린다.
|
|
// (IME 조합 중 마지막 글자가 바뀌어 stale 로 잡히면, 취소가 예산을 갉아먹어
|
|
// 정작 사용자가 멈췄을 때 rate-limited 로 막히던 문제)
|
|
this._budget.refund()
|
|
// 보여줄 후보가 없으면 스피너만 남는다 — 오버레이를 지운다.
|
|
if (this._candidates.length === 0) this.dismiss('stale')
|
|
logger.debug(`제안 생성 취소 (${refresh}) — 다음 정상 입력 문맥에서만 재평가`)
|
|
}
|
|
|
|
/**
|
|
* 진행 플래그가 비정상적으로 오래 남았으면 강제 해제한다.
|
|
*
|
|
* 응답 제한(기본 8초) + 5초를 넘긴 in-flight 는 죽은 것으로 본다 —
|
|
* 어떤 예외 경로로든 플래그가 누수되면 제안이 영구히 멈추기 때문이다.
|
|
*/
|
|
private _watchdog(): void {
|
|
if (!this._inFlight) return
|
|
const age = Date.now() - this._budget.lastRequestAt
|
|
if (age < this.readTimeoutMs() + 5000) return
|
|
logger.warn(`제안 생성 플래그 누수 감지 (${age}ms) — 강제 해제`)
|
|
this._generationToken += 1
|
|
this._abort?.abort()
|
|
this._abort = null
|
|
if (this._timeoutTimer) {
|
|
clearTimeout(this._timeoutTimer)
|
|
this._timeoutTimer = null
|
|
}
|
|
this._releaseGeneration()
|
|
this._lastSkipReason = 'stale'
|
|
this.emit('updated', this.getState())
|
|
}
|
|
|
|
// ── 사용자 동작 ───────────────────────────────────────
|
|
|
|
/** 이전 후보로 순환 (전체 후보를 가로질러, 페이지 무관). */
|
|
previous(): SuggestionState {
|
|
if (this._candidates.length > 1) {
|
|
this._moveActive((this._activeIndex - 1 + this._candidates.length) % this._candidates.length)
|
|
}
|
|
return this.getState()
|
|
}
|
|
|
|
/**
|
|
* 다음 후보로 — 페이지는 활성 후보를 따라 넘어간다.
|
|
*
|
|
* 마지막 후보에서 아직 더 만드는 중이면 그 자리에 머문다: 처음으로 돌아가 버리면
|
|
* 곧 도착할 후보를 보려던 사용자가 길을 잃는다. 다 만들었으면 처음으로 돌아간다.
|
|
*/
|
|
next(): SuggestionState {
|
|
const last = this._candidates.length - 1
|
|
if (last < 1) return this.getState()
|
|
if (this._activeIndex < last) this._moveActive(this._activeIndex + 1)
|
|
else if (!this._filling) this._moveActive(0)
|
|
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') {
|
|
// 명시적 닫기: 잠깐 조용히 있고, 진행 중 생성은 무효화한다.
|
|
this._userDismissedUntil = Date.now() + SUGGESTION_DEFAULTS.userDismissQuietMs
|
|
logger.info(`사용자가 제안을 닫음 — ${SUGGESTION_DEFAULTS.userDismissQuietMs}ms 동안 재표시하지 않는다`)
|
|
}
|
|
const wasVisible = this.isPresentationActive
|
|
// 진행 중이던 생성을 무효화한다 (닫았는데 잠시 뒤 결과가 다시 뜨는 것을 막는다).
|
|
this._generationToken += 1
|
|
this._clearVisibleTtl()
|
|
this._candidates = []
|
|
this._generating = false
|
|
this._warmingUp = false
|
|
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
|
|
}
|
|
// 워밍업은 오버레이 표시와 독립된 요청이다 — 포커스 전환/stale 등으로 오버레이를
|
|
// 지울 때마다 취소하면 모델이 영영 안 뜬다. 설정에서 기능을 끌 때만 취소한다.
|
|
if (reason === 'disabled') this._cancelWarmUp()
|
|
if (wasVisible || reason === 'dismissed') {
|
|
this.emit('cleared', { reason })
|
|
}
|
|
}
|
|
|
|
/** 활성 후보를 대상 앱에 삽입하고 수락으로 기록한다. */
|
|
async accept(index?: number): Promise<SuggestionRequestResult> {
|
|
if (index !== undefined && index >= 0 && index < this._candidates.length) {
|
|
this._activeIndex = index
|
|
}
|
|
const candidate = this._candidates[this._activeIndex]
|
|
if (!candidate) {
|
|
// 창이 이미 닫힌 뒤의 클릭·단축키 — 조용히 끝나면 "골라도 안 들어간다" 의 원인이 안 보인다.
|
|
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
|
|
return { ok: false, reason: 'already-visible' }
|
|
}
|
|
const text = candidate.text
|
|
const appName = this._appName
|
|
const windowTitle = this._windowTitle
|
|
const targetWindow = this._windowHandle
|
|
|
|
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
|
|
this.dismiss('accepted')
|
|
this.emit('state-changed', this.getState())
|
|
|
|
// 단축키로 수락했다면 사용자가 아직 Ctrl+Alt 를 쥐고 있다. 그대로 Ctrl+V 를 보내면
|
|
// 대상 앱에 Ctrl+Alt+V 가 들어가 붙여넣기가 되지 않는다(실측) — 뗄 때까지 기다린다.
|
|
if (!(await waitForModifiersReleased())) {
|
|
logger.warn('수정자 키가 떼어지지 않은 채 제안을 삽입한다 (1.5초 초과)')
|
|
}
|
|
|
|
// 붙여넣기(Ctrl+V)는 그 순간의 포그라운드 창으로 간다. 오버레이는 다음 스냅샷이 와야
|
|
// 닫히므로, 그사이 다른 앱으로 옮겨 간 뒤 수락하면 엉뚱한 창에 삽입됐다 — 세션을 만든
|
|
// 창이 아니면 삽입하지 않는다 (창을 알 수 없으면 기존처럼 진행한다).
|
|
if (targetWindow !== null) {
|
|
const currentWindow = this._foreground.currentWindowHandle()
|
|
if (currentWindow !== null && currentWindow !== targetWindow) {
|
|
logger.info(`제안 수락 취소: 포커스가 다른 창으로 옮겨 감 (app=${appName ?? '-'})`)
|
|
this._lastSkipReason = 'stale'
|
|
this.emit('state-changed', this.getState())
|
|
return { ok: false, reason: 'stale' }
|
|
}
|
|
}
|
|
|
|
// 삽입 직후의 텍스트 스냅샷 diff 가 이 텍스트를 "타이핑" 으로 다시 학습하지 않도록 알린다.
|
|
const cancelExpectedInsert = this._learning.expectProgrammaticInsert(text)
|
|
const method = configGet('insertMethod')
|
|
try {
|
|
const result = await getTextInsertService().insertText(
|
|
text,
|
|
method === 'keyboard' ? 'keyboard' : 'clipboard'
|
|
)
|
|
if (!result.success) {
|
|
cancelExpectedInsert()
|
|
logger.warn(`제안 삽입 실패 (method=${result.method}, len=${result.textLength})`)
|
|
return { ok: false, reason: 'generation-failed' }
|
|
}
|
|
} catch (error) {
|
|
cancelExpectedInsert()
|
|
logger.warn(`제안 삽입 예외: ${error instanceof Error ? error.message : String(error)}`)
|
|
return { ok: false, reason: 'generation-failed' }
|
|
}
|
|
|
|
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
|
|
this._repository.markAccepted(text)
|
|
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
|
this._learning.recordAccepted(text, { appName, windowTitle })
|
|
return { ok: true }
|
|
}
|
|
|
|
getHistory(limit = 50): SuggestionHistoryEntry[] {
|
|
return this._repository.list(limit)
|
|
}
|
|
|
|
// ── 내부 ──────────────────────────────────────────────
|
|
|
|
private readPolicyConfig(): PolicyConfig {
|
|
return {
|
|
triggerDelayMs: Math.max(
|
|
SUGGESTION_DEFAULTS.minTriggerDelayMs,
|
|
configGet('suggestionTriggerDelayMs') || SUGGESTION_DEFAULTS.triggerDelayMs
|
|
),
|
|
minPrefixChars: configGet('suggestionMinPrefixChars') || SUGGESTION_DEFAULTS.minPrefixChars,
|
|
maxRequestsPerMinute:
|
|
Math.min(
|
|
configGet('suggestionMaxRequestsPerMinute') || SUGGESTION_DEFAULTS.maxRequestsPerMinute,
|
|
12
|
|
),
|
|
dailyBudget: configGet('suggestionDailyBudget') || SUGGESTION_DEFAULTS.dailyBudget,
|
|
maxCandidates: SUGGESTION_DEFAULTS.maxCandidates,
|
|
maxChars: SUGGESTION_MAX_OUTPUT_CHARS,
|
|
// 터미널은 셸 프롬프트라 문장 제안이 의미 없다 — 사용자 목록과 무관하게 뺀다.
|
|
// 터미널도 제안한다(입력 줄만 추출). 학습 제외는 InputTelemetryService 가 따로 한다.
|
|
excludedApps: [...configGet('inputExcludedApps')]
|
|
}
|
|
}
|
|
|
|
private _recordSuggestion(input: Omit<SuggestionRecordInput, 'appName'>): void {
|
|
this._repository.record({ ...input, appName: this._appName })
|
|
}
|
|
|
|
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
|
isAppAllowed(appName: string | null): boolean {
|
|
if (!appName) return true
|
|
return !isAppExcluded(appName, configGet('inputExcludedApps'))
|
|
}
|
|
|
|
dispose(): void {
|
|
this.dismiss('disabled')
|
|
this._cancelWarmUp()
|
|
this.removeAllListeners()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스트리밍 중간 텍스트 정제 — 번호/불릿/따옴표와 지시문 누출을 화면에 잠깐
|
|
* 보여주지 않도록 줄 단위로 다듬는다.
|
|
*/
|
|
function sanitizePartial(raw: string): string {
|
|
const lines = raw
|
|
.split(/\r?\n/u)
|
|
.map((line) => sanitizeSuggestionLine(line, SUGGESTION_MAX_OUTPUT_CHARS) ?? line.trim())
|
|
.filter((line) => line.length > 0)
|
|
return lines.slice(0, 2).join(' ').slice(0, SUGGESTION_MAX_OUTPUT_CHARS)
|
|
}
|
|
|
|
let instance: SuggestionService | null = null
|
|
|
|
export function getSuggestionService(): SuggestionService {
|
|
if (!instance) instance = new SuggestionService()
|
|
return instance
|
|
}
|
|
|
|
export function resetSuggestionServiceForTests(): void {
|
|
instance?.dispose()
|
|
instance = null
|
|
}
|