release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a personal phrase memory to the desktop app, and fixes custom instructions so they process the text instead of inserting the instruction's own wording. Local model requests are now bounded and individually cancellable. Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the landing and web download links, and records the new INPUT feature rows and the open verification gaps in the infrastructure map.
This commit is contained in:
parent
99f06c253c
commit
5c11ee2fde
104 changed files with 14410 additions and 174 deletions
|
|
@ -11,6 +11,8 @@ import { persistCompletedVoiceSessionSafe } from './voice-session-persist'
|
|||
import { getTextInsertService } from './services/TextInsertService'
|
||||
import { getCustomInstructionService } from './services/CustomInstructionService'
|
||||
import { getVoiceCommandService } from './services/VoiceCommandService'
|
||||
import { getInputTelemetryService } from './services/InputTelemetryService'
|
||||
import { getSuggestionService } from './services/SuggestionService'
|
||||
import { getSoundEffectService } from './services/SoundEffectService'
|
||||
import { getAutoLaunchService } from './services/AutoLaunchService'
|
||||
import { getAudioCaptureService } from './services/AudioCaptureService'
|
||||
|
|
@ -30,6 +32,8 @@ import {
|
|||
isCommandPopupVisible,
|
||||
hideRecordingTip,
|
||||
updateRecordingTipState,
|
||||
showSuggestionOverlay,
|
||||
hideSuggestionOverlay,
|
||||
} from './windows/WindowManager'
|
||||
import { createTray } from './windows/TrayManager'
|
||||
import { registerAllIpcHandlers } from './ipc'
|
||||
|
|
@ -62,6 +66,7 @@ export async function bootstrap(): Promise<void> {
|
|||
{ name: 'auto-launch', critical: false, fn: initAutoLaunch },
|
||||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||
{ name: 'key-bindings', critical: false, fn: initKeyBindings },
|
||||
{ name: 'input-intelligence', critical: false, fn: initInputIntelligence },
|
||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||
{ name: 'stt-warmup', critical: false, fn: initSTTWarmup },
|
||||
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
|
||||
|
|
@ -125,6 +130,91 @@ async function initKeyBindings(): Promise<void> {
|
|||
keyBindings.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 인텔리전스 배선 — 텔레메트리 → 제안 → 오버레이.
|
||||
*
|
||||
* 두 서비스를 직접 서로 import 하지 않고 여기서 이벤트로 는다
|
||||
* (서비스 간 결합을 늘리지 않으면서 교체 가능성을 남긴다).
|
||||
*/
|
||||
async function initInputIntelligence(): Promise<void> {
|
||||
const telemetry = getInputTelemetryService()
|
||||
const suggestion = getSuggestionService()
|
||||
|
||||
telemetry.on('typed-context', (context) => suggestion.handleTypingContext(context))
|
||||
telemetry.on('state-changed', (state) =>
|
||||
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.STATE_CHANGED, state)
|
||||
)
|
||||
telemetry.on('activity', (payload) =>
|
||||
sendToMainWindow(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, payload)
|
||||
)
|
||||
|
||||
suggestion.on('updated', (state) => {
|
||||
// 오버레이/윈도우 호출이 예외를 던지면 서비스의 진행 플래그가 굳을 수 있다.
|
||||
// 이벤트 경로는 어떤 경우에도 조용히 삼킨다 (UI 문제로 기능 전체가 멈추면 안 된다).
|
||||
try {
|
||||
const hasSomethingToShow =
|
||||
state.candidates.length > 0 || state.generating || state.warmingUp || !!state.partialText
|
||||
const shouldPresent = hasSomethingToShow && !!state.anchor
|
||||
telemetry.setSuggestionPresentationActive(shouldPresent)
|
||||
if (!shouldPresent) {
|
||||
hideSuggestionOverlay()
|
||||
} else {
|
||||
showSuggestionOverlay({
|
||||
candidates: state.candidates,
|
||||
activeIndex: state.activeIndex,
|
||||
generating: state.generating,
|
||||
warmingUp: state.warmingUp,
|
||||
partialText: state.partialText,
|
||||
anchor: state.anchor,
|
||||
appName: state.appName,
|
||||
provenance: state.provenance
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`제안 오버레이 표시 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.UPDATED, state)
|
||||
})
|
||||
suggestion.on('cleared', (payload) => {
|
||||
telemetry.setSuggestionPresentationActive(false)
|
||||
try {
|
||||
hideSuggestionOverlay()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 오버레이 숨기기 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.CLEARED, payload)
|
||||
})
|
||||
suggestion.on('state-changed', (state) =>
|
||||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state)
|
||||
)
|
||||
|
||||
// 제안 수락/순환/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
|
||||
getKeyBindingService().on('triggered', (payload) => {
|
||||
if (payload.type !== 'pressed') return
|
||||
if (payload.actionId === 'suggestion-accept') void suggestion.accept()
|
||||
else if (payload.actionId === 'suggestion-next') suggestion.next()
|
||||
else if (payload.actionId === 'suggestion-prev') suggestion.previous()
|
||||
else if (payload.actionId === 'suggestion-dismiss') suggestion.dismiss('dismissed')
|
||||
})
|
||||
|
||||
// Chromium(Electron)은 접근성 지원이 감지될 때만 AX 트리를 만든다. 켜지 않으면
|
||||
// 우리 앱 자신의 입력창은 UIA 로 읽히지 않아 D3RO 안에서 타이핑할 때 제안이 죽는다.
|
||||
if (configGet('inputTelemetryEnabled') || configGet('suggestionEnabled')) {
|
||||
app.setAccessibilitySupportEnabled(true)
|
||||
logger.info('[bootstrap] accessibility tree enabled for UIA context capture')
|
||||
}
|
||||
|
||||
telemetry.start()
|
||||
}
|
||||
|
||||
/** 메인 도우로만 이벤트 전송 (팝업은 자체 내부 채널을 다) */
|
||||
function sendToMainWindow(channel: IPCChannel, payload: unknown): void {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send(channel, payload)
|
||||
}
|
||||
}
|
||||
|
||||
async function initCustomInstructions(): Promise<void> {
|
||||
getCustomInstructionService().initialize()
|
||||
}
|
||||
|
|
@ -228,6 +318,7 @@ function unregisterPopupNavKeys(): void {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
async function initVoiceMode(): Promise<void> {
|
||||
const voiceMode = getVoiceModeService()
|
||||
voiceMode.connectKeyBindings()
|
||||
|
|
|
|||
|
|
@ -246,6 +246,99 @@ function applySchema(s: Database.Database): void {
|
|||
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
|
||||
`)
|
||||
|
||||
// 입력 인텔리전스: 텔레메트리 집계 / 학습 문장 / 개인 문구 / 제안 이력
|
||||
s.exec(`
|
||||
CREATE TABLE IF NOT EXISTS input_activity (
|
||||
id TEXT PRIMARY KEY,
|
||||
date TEXT NOT NULL,
|
||||
hour INTEGER NOT NULL,
|
||||
app_name TEXT NOT NULL DEFAULT '',
|
||||
keystrokes INTEGER NOT NULL DEFAULT 0,
|
||||
shortcuts INTEGER NOT NULL DEFAULT 0,
|
||||
backspaces INTEGER NOT NULL DEFAULT 0,
|
||||
clicks INTEGER NOT NULL DEFAULT 0,
|
||||
double_clicks INTEGER NOT NULL DEFAULT 0,
|
||||
scroll_ticks INTEGER NOT NULL DEFAULT 0,
|
||||
mouse_distance_px INTEGER NOT NULL DEFAULT 0,
|
||||
chars INTEGER NOT NULL DEFAULT 0,
|
||||
words INTEGER NOT NULL DEFAULT 0,
|
||||
sentences INTEGER NOT NULL DEFAULT 0,
|
||||
active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_input_activity_bucket
|
||||
ON input_activity(date, hour, app_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_input_activity_date ON input_activity(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS typing_samples (
|
||||
id TEXT PRIMARY KEY,
|
||||
text TEXT NOT NULL,
|
||||
word_count INTEGER NOT NULL DEFAULT 0,
|
||||
char_count INTEGER NOT NULL DEFAULT 0,
|
||||
app_name TEXT,
|
||||
window_title TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'typed',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_typing_samples_created_at ON typing_samples(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_typing_samples_source ON typing_samples(source);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personal_phrases (
|
||||
id TEXT PRIMARY KEY,
|
||||
phrase TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'typed',
|
||||
last_used_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_personal_phrases_phrase ON personal_phrases(phrase);
|
||||
CREATE INDEX IF NOT EXISTS idx_personal_phrases_count ON personal_phrases(count DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suggestions (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_name TEXT,
|
||||
prefix_text TEXT NOT NULL DEFAULT '',
|
||||
suggestion_text TEXT NOT NULL,
|
||||
candidate_count INTEGER NOT NULL DEFAULT 1,
|
||||
model TEXT,
|
||||
latency_ms INTEGER,
|
||||
accepted INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_suggestions_created_at ON suggestions(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_suggestions_accepted ON suggestions(accepted);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS phrase_edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_phrase TEXT NOT NULL,
|
||||
to_phrase TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'follows',
|
||||
weight INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_phrase_edges_unique
|
||||
ON phrase_edges(from_phrase, to_phrase, kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_phrase_edges_from ON phrase_edges(from_phrase);
|
||||
CREATE INDEX IF NOT EXISTS idx_phrase_edges_weight ON phrase_edges(weight DESC);
|
||||
`)
|
||||
|
||||
// 개인 그래프: personal_phrases 에 terms/app_name 컬럼 (기존 DB 마이그레이션)
|
||||
try {
|
||||
const phraseCols = s.pragma('table_info(personal_phrases)') as Array<{ name: string }>
|
||||
if (!phraseCols.some((c) => c.name === 'terms')) {
|
||||
s.exec('ALTER TABLE personal_phrases ADD COLUMN terms TEXT')
|
||||
logger.info('Migrated: added terms column to personal_phrases')
|
||||
}
|
||||
if (!phraseCols.some((c) => c.name === 'app_name')) {
|
||||
s.exec('ALTER TABLE personal_phrases ADD COLUMN app_name TEXT')
|
||||
logger.info('Migrated: added app_name column to personal_phrases')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`personal_phrases graph migration failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼
|
||||
try {
|
||||
const msCols = s.pragma('table_info(meeting_sessions)') as Array<{ name: string }>
|
||||
|
|
|
|||
|
|
@ -229,6 +229,137 @@ export const meetingDocuments = sqliteTable(
|
|||
export type MeetingDocumentRow = typeof meetingDocuments.$inferSelect
|
||||
export type NewMeetingDocumentRow = typeof meetingDocuments.$inferInsert
|
||||
|
||||
// ── input_activity (입력 텔레메트리) ─────────────────────
|
||||
// 시간(hour)·앱 단위 집계 행. 키 내용은 저장하지 않는다 —
|
||||
// ActivityWatch aw-watcher-input 과 같은 데이터 최소화 정책 (카운터 + 거리만).
|
||||
export const inputActivity = sqliteTable(
|
||||
'input_activity',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
/** 로컬 날짜 YYYY-MM-DD */
|
||||
date: text('date').notNull(),
|
||||
/** 0~23 */
|
||||
hour: integer('hour').notNull(),
|
||||
/** 포그라운드 프로세스명 (없으면 '') */
|
||||
appName: text('app_name').notNull().default(''),
|
||||
keystrokes: integer('keystrokes').notNull().default(0),
|
||||
shortcuts: integer('shortcuts').notNull().default(0),
|
||||
backspaces: integer('backspaces').notNull().default(0),
|
||||
clicks: integer('clicks').notNull().default(0),
|
||||
doubleClicks: integer('double_clicks').notNull().default(0),
|
||||
scrollTicks: integer('scroll_ticks').notNull().default(0),
|
||||
mouseDistancePx: integer('mouse_distance_px').notNull().default(0),
|
||||
chars: integer('chars').notNull().default(0),
|
||||
words: integer('words').notNull().default(0),
|
||||
sentences: integer('sentences').notNull().default(0),
|
||||
activeMs: integer('active_ms').notNull().default(0),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_input_activity_bucket').on(table.date, table.hour, table.appName),
|
||||
index('idx_input_activity_date').on(table.date),
|
||||
]
|
||||
)
|
||||
|
||||
// ── typing_samples (학습된 이핑/음성 문장) ─────────────
|
||||
export const typingSamples = sqliteTable(
|
||||
'typing_samples',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
text: text('text').notNull(),
|
||||
wordCount: integer('word_count').notNull().default(0),
|
||||
charCount: integer('char_count').notNull().default(0),
|
||||
appName: text('app_name'),
|
||||
windowTitle: text('window_title'),
|
||||
source: text('source', { enum: ['typed', 'voice', 'suggestion', 'clipboard'] })
|
||||
.notNull()
|
||||
.default('typed'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_typing_samples_created_at').on(table.createdAt),
|
||||
index('idx_typing_samples_source').on(table.source),
|
||||
]
|
||||
)
|
||||
|
||||
// ── personal_phrases (개인화 문구) ──────────────────────
|
||||
export const personalPhrases = sqliteTable(
|
||||
'personal_phrases',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
phrase: text('phrase').notNull(),
|
||||
count: integer('count').notNull().default(1),
|
||||
source: text('source', { enum: ['typed', 'voice', 'suggestion', 'clipboard'] })
|
||||
.notNull()
|
||||
.default('typed'),
|
||||
lastUsedAt: integer('last_used_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
/** 개인 그래프: 비교용 용어 (JSON string[]) */
|
||||
terms: text('terms'),
|
||||
/** 마지막으로 쓰인 앱 (그래프 문맥 표시용) */
|
||||
appName: text('app_name'),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_personal_phrases_phrase').on(table.phrase),
|
||||
index('idx_personal_phrases_count').on(table.count),
|
||||
]
|
||||
)
|
||||
|
||||
// ── suggestions (제안 이력 · 수락률 측정) ────────────────
|
||||
export const suggestions = sqliteTable(
|
||||
'suggestions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
appName: text('app_name'),
|
||||
/** 어렛 앞 접두 (프롬프트 스트) */
|
||||
prefixText: text('prefix_text').notNull().default(''),
|
||||
suggestionText: text('suggestion_text').notNull(),
|
||||
candidateCount: integer('candidate_count').notNull().default(1),
|
||||
model: text('model'),
|
||||
latencyMs: integer('latency_ms'),
|
||||
accepted: integer('accepted', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_suggestions_created_at').on(table.createdAt),
|
||||
index('idx_suggestions_accepted').on(table.accepted),
|
||||
]
|
||||
)
|
||||
|
||||
// ── phrase_edges (개인 그래프 엣지) ──────────────────────
|
||||
// 문장(노드) 사이의 관계. follows = 한 텍스트 안에서 연달아 나온 문장 쌍,
|
||||
// shares_terms = 용어를 충분히 공유하는 문장 쌍. 관계형 개인화의 근거다.
|
||||
export const phraseEdges = sqliteTable(
|
||||
'phrase_edges',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
fromPhrase: text('from_phrase').notNull(),
|
||||
toPhrase: text('to_phrase').notNull(),
|
||||
kind: text('kind', { enum: ['follows', 'shares_terms'] })
|
||||
.notNull()
|
||||
.default('follows'),
|
||||
weight: integer('weight').notNull().default(1),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_phrase_edges_unique').on(table.fromPhrase, table.toPhrase, table.kind),
|
||||
index('idx_phrase_edges_from').on(table.fromPhrase),
|
||||
index('idx_phrase_edges_weight').on(table.weight),
|
||||
]
|
||||
)
|
||||
|
||||
export type PhraseEdgeRow = typeof phraseEdges.$inferSelect
|
||||
export type NewPhraseEdgeRow = typeof phraseEdges.$inferInsert
|
||||
|
||||
export type InputActivityRow = typeof inputActivity.$inferSelect
|
||||
export type NewInputActivityRow = typeof inputActivity.$inferInsert
|
||||
export type TypingSampleRow = typeof typingSamples.$inferSelect
|
||||
export type NewTypingSampleRow = typeof typingSamples.$inferInsert
|
||||
export type PersonalPhraseRow = typeof personalPhrases.$inferSelect
|
||||
export type NewPersonalPhraseRow = typeof personalPhrases.$inferInsert
|
||||
export type SuggestionRow = typeof suggestions.$inferSelect
|
||||
export type NewSuggestionRow = typeof suggestions.$inferInsert
|
||||
|
||||
// ── 타입 추출 ────────────────────────────────────────────
|
||||
export type History = typeof history.$inferSelect
|
||||
export type NewHistory = typeof history.$inferInsert
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import { registerCloudSyncHandlers } from './cloud-sync-handlers'
|
|||
import { registerAdsHandlers } from './ads-handlers'
|
||||
import { registerSupportHandlers } from './support-handlers'
|
||||
import { registerPaymentHandlers } from './payment-handlers'
|
||||
import { registerInputTelemetryHandlers } from './input-telemetry-handlers'
|
||||
import { registerSuggestionHandlers } from './suggestion-handlers'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
|
||||
const logger = getLogger('ipc')
|
||||
|
|
@ -63,5 +65,7 @@ export function registerAllIpcHandlers(): void {
|
|||
registerAdsHandlers()
|
||||
registerSupportHandlers()
|
||||
registerPaymentHandlers()
|
||||
registerInputTelemetryHandlers()
|
||||
registerSuggestionHandlers()
|
||||
logger.info('All IPC handlers registered')
|
||||
}
|
||||
|
|
|
|||
164
apps/desktop/src/main/ipc/input-telemetry-handlers.ts
Normal file
164
apps/desktop/src/main/ipc/input-telemetry-handlers.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// src/main/ipc/input-telemetry-handlers.ts
|
||||
// 입력 텔레메트리 수집 동의 · 리포트 · 개인 문구 관리.
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getInputTelemetryService } from '../services/InputTelemetryService'
|
||||
import { getPersonalGraphService } from '../services/PersonalGraphService'
|
||||
import { getSuggestionService } from '../services/SuggestionService'
|
||||
|
||||
export interface SetInputTelemetryEnabledParams {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface SetInputTelemetryPausedParams {
|
||||
paused: boolean
|
||||
}
|
||||
|
||||
export interface InputSummaryParams {
|
||||
days?: number
|
||||
}
|
||||
|
||||
export interface InputPhrasesParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface DeletePhraseParams {
|
||||
id: string
|
||||
}
|
||||
|
||||
export function registerInputTelemetryHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_STATE, async () => {
|
||||
try {
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.InputTelemetryConfigFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED,
|
||||
async (_event, params: SetInputTelemetryEnabledParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setEnabled(params.enabled === true)
|
||||
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다.
|
||||
if (params.enabled !== true) getSuggestionService().applyConfig()
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.InputTelemetryConfigFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED,
|
||||
async (_event, params: SetInputTelemetryPausedParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setPaused(params.paused === true)
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.InputTelemetryConfigFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_SUMMARY,
|
||||
async (_event, params?: InputSummaryParams) => {
|
||||
try {
|
||||
return ipcSuccess(getInputTelemetryService().getSummary(params?.days ?? 7))
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_PRIVACY_RECEIPT, async () => {
|
||||
try {
|
||||
return ipcSuccess(getInputTelemetryService().getPrivacyReceipt())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_PHRASES,
|
||||
async (_event, params?: InputPhrasesParams) => {
|
||||
try {
|
||||
return ipcSuccess(getInputTelemetryService().listPhrases(params?.limit ?? 100))
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.DELETE_PHRASE,
|
||||
async (_event, params: DeletePhraseParams) => {
|
||||
try {
|
||||
return ipcSuccess(getInputTelemetryService().deletePhrase(params.id))
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBWriteFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.GET_GRAPH, async () => {
|
||||
try {
|
||||
return ipcSuccess(getPersonalGraphService().getStats())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.QUERY_GRAPH,
|
||||
async (_event, params: { text: string; limit?: number }) => {
|
||||
try {
|
||||
return ipcSuccess(getPersonalGraphService().query(params.text, params.limit ?? 12))
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.INPUT_TELEMETRY.CLEAR_ALL, async () => {
|
||||
try {
|
||||
getInputTelemetryService().clearAll()
|
||||
return ipcSuccess(undefined)
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBWriteFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
190
apps/desktop/src/main/ipc/suggestion-handlers.ts
Normal file
190
apps/desktop/src/main/ipc/suggestion-handlers.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// src/main/ipc/suggestion-handlers.ts
|
||||
// 다음 문장 제안(ghost text) 상태/설정/수락 + 오버레이 팝업 내부 채널.
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ipcSuccess, ipcError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getSuggestionService } from '../services/SuggestionService'
|
||||
import { configSet } from '../services/ConfigService'
|
||||
import { applySuggestionOverlayConfig, hideSuggestionOverlay } from '../windows/WindowManager'
|
||||
|
||||
export interface SetSuggestionConfigParams {
|
||||
enabled?: boolean
|
||||
modelId?: string | null
|
||||
triggerDelayMs?: number
|
||||
minPrefixChars?: number
|
||||
maxRequestsPerMinute?: number
|
||||
dailyBudget?: number
|
||||
overlayInteractive?: boolean
|
||||
learnTypedText?: boolean
|
||||
excludedApps?: string[]
|
||||
requestTimeoutMs?: number
|
||||
}
|
||||
|
||||
export interface SuggestionAcceptParams {
|
||||
index?: number
|
||||
}
|
||||
|
||||
export interface SuggestionHistoryParams {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function registerSuggestionHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.SUGGESTION.GET_STATE, async () => {
|
||||
try {
|
||||
return ipcSuccess(getSuggestionService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionGenerationFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SUGGESTION.SET_CONFIG,
|
||||
async (_event, params: SetSuggestionConfigParams) => {
|
||||
try {
|
||||
const service = getSuggestionService()
|
||||
if (params.enabled !== undefined) service.setEnabled(params.enabled === true)
|
||||
if (params.modelId !== undefined) configSet('suggestionModelId', params.modelId || null)
|
||||
if (params.triggerDelayMs !== undefined) {
|
||||
configSet('suggestionTriggerDelayMs', clamp(params.triggerDelayMs, 100, 2000))
|
||||
}
|
||||
if (params.minPrefixChars !== undefined) {
|
||||
configSet('suggestionMinPrefixChars', clamp(params.minPrefixChars, 4, 40))
|
||||
}
|
||||
if (params.maxRequestsPerMinute !== undefined) {
|
||||
configSet('suggestionMaxRequestsPerMinute', clamp(params.maxRequestsPerMinute, 1, 12))
|
||||
}
|
||||
if (params.dailyBudget !== undefined) {
|
||||
configSet('suggestionDailyBudget', clamp(params.dailyBudget, 1, 100000))
|
||||
}
|
||||
if (params.requestTimeoutMs !== undefined) {
|
||||
configSet('suggestionRequestTimeoutMs', clamp(params.requestTimeoutMs, 500, 60000))
|
||||
}
|
||||
if (params.overlayInteractive !== undefined) {
|
||||
configSet('suggestionOverlayInteractive', params.overlayInteractive === true)
|
||||
applySuggestionOverlayConfig()
|
||||
}
|
||||
if (params.learnTypedText !== undefined) {
|
||||
configSet('inputLearnTypedText', params.learnTypedText === true)
|
||||
}
|
||||
if (params.excludedApps !== undefined) {
|
||||
configSet('inputExcludedApps', sanitizeExcludedApps(params.excludedApps))
|
||||
}
|
||||
|
||||
service.applyConfig()
|
||||
return ipcSuccess(service.getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.ConfigWriteFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SUGGESTION.REQUEST_NOW, async () => {
|
||||
try {
|
||||
const result = await getSuggestionService().requestNow()
|
||||
return ipcSuccess(result)
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionGenerationFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SUGGESTION.ACCEPT,
|
||||
async (_event, params?: SuggestionAcceptParams) => {
|
||||
try {
|
||||
const result = await getSuggestionService().accept(params?.index)
|
||||
return ipcSuccess(result)
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionAcceptFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SUGGESTION.NEXT, async () => {
|
||||
try {
|
||||
return ipcSuccess(getSuggestionService().next())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionGenerationFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SUGGESTION.PREV, async () => {
|
||||
try {
|
||||
return ipcSuccess(getSuggestionService().previous())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionGenerationFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SUGGESTION.DISMISS, async () => {
|
||||
try {
|
||||
getSuggestionService().dismiss('dismissed')
|
||||
return ipcSuccess(undefined)
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.SuggestionGenerationFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.SUGGESTION.GET_HISTORY,
|
||||
async (_event, params?: SuggestionHistoryParams) => {
|
||||
try {
|
||||
return ipcSuccess(getSuggestionService().getHistory(params?.limit ?? 50))
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
ErrorCode.DBQueryFailed,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── 오버레이 팝업 → 메인 (클릭 수락/닫기) ──────────────
|
||||
ipcMain.on(IPC_CHANNELS.POPUP_SUGGESTION.ACCEPT, (_event, index?: number) => {
|
||||
void getSuggestionService().accept(index)
|
||||
})
|
||||
|
||||
ipcMain.on(IPC_CHANNELS.POPUP_SUGGESTION.DISMISS, () => {
|
||||
hideSuggestionOverlay()
|
||||
getSuggestionService().dismiss('dismissed')
|
||||
})
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return min
|
||||
return Math.max(min, Math.min(Math.round(numeric), max))
|
||||
}
|
||||
|
||||
/** 실행 파일명만 남기고 정규화 (최대 100개). */
|
||||
function sanitizeExcludedApps(apps: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const raw of apps) {
|
||||
const value = String(raw).trim().slice(0, 120)
|
||||
if (!value) continue
|
||||
seen.add(value)
|
||||
if (seen.size >= 100) break
|
||||
}
|
||||
return [...seen]
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { app } from 'electron'
|
||||
import { getLogger } from './services/LoggerService'
|
||||
import { getConfigService } from './services/ConfigService'
|
||||
import { disposeLocalLLMService } from './services/LocalLLMService'
|
||||
|
||||
const logger = getLogger('lifecycle')
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ export function setupLifecycle(): void {
|
|||
|
||||
app.on('will-quit', () => {
|
||||
logger.info('[lifecycle] will-quit — flushing config')
|
||||
disposeLocalLLMService()
|
||||
try {
|
||||
const configService = getConfigService()
|
||||
if (configService) {
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ class AudioCaptureService extends EventEmitter {
|
|||
return new Promise<AudioDevice[]>((resolve) => {
|
||||
exec(
|
||||
`powershell -NoProfile -Command "${psCommand}"`,
|
||||
{ encoding: 'utf8', timeout: 3000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } },
|
||||
{ encoding: 'utf8', timeout: 3000, env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, windowsHide: true },
|
||||
(err, stdout) => {
|
||||
if (err || !stdout?.trim()) {
|
||||
return resolve([
|
||||
|
|
|
|||
|
|
@ -3,12 +3,64 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import type { AppConfig, ConfigChangedEvent, KeyBindingActionId, KeyBindingMap } from '@d3ro/core/types'
|
||||
import { createDefaultBindingMap, normalizeBinding, parseBindingMap } from '@d3ro/core/keybinding'
|
||||
import {
|
||||
createDefaultBindingMap,
|
||||
findActionSpec,
|
||||
normalizeBinding,
|
||||
parseBindingMap
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('ConfigService')
|
||||
|
||||
/** 제안/텔레메트리 튜닝 기본값의 현재 개정판. 기본값을 바꾸면 올린다. */
|
||||
const SUGGESTION_TUNING_REVISION = 4
|
||||
|
||||
const INITIAL_SUGGESTION_TUNING = {
|
||||
suggestionTriggerDelayMs: 300,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 12,
|
||||
suggestionDailyBudget: 500
|
||||
} as const
|
||||
|
||||
/** 저장된 튜닝 값을 해당 개정판에서만 변경된 항목으로 올린다. */
|
||||
function migrateSuggestionTuning(activeStore: ElectronStore<AppConfig>): void {
|
||||
const raw = activeStore.store as unknown as Record<string, unknown>
|
||||
const current = Number(raw.suggestionTuningRevision ?? 0)
|
||||
if (Number.isFinite(current) && current >= SUGGESTION_TUNING_REVISION) return
|
||||
|
||||
if (current < 1) {
|
||||
for (const [key, value] of Object.entries(INITIAL_SUGGESTION_TUNING)) {
|
||||
activeStore.set(key as keyof AppConfig, value as AppConfig[keyof AppConfig])
|
||||
}
|
||||
}
|
||||
|
||||
if (current < 2) {
|
||||
const rawBindings = raw.keyBindings as unknown
|
||||
const bindings = parseBindingMap(rawBindings)
|
||||
const defaults = createDefaultBindingMap()
|
||||
const suggestionActions = ['suggestion-accept', 'suggestion-next', 'suggestion-prev', 'suggestion-dismiss'] as const
|
||||
for (const action of suggestionActions) {
|
||||
const spec = findActionSpec(action)
|
||||
if (!spec) continue
|
||||
bindings[action] = defaults[action] ?? spec.defaultBindings.map((binding) => ({ ...binding }))
|
||||
}
|
||||
activeStore.set('keyBindings', bindings)
|
||||
}
|
||||
|
||||
if (current < 3) activeStore.set('suggestionRequestTimeoutMs', 8000)
|
||||
if (current < 4) {
|
||||
activeStore.set('suggestionTriggerDelayMs', 600)
|
||||
activeStore.set('suggestionMaxRequestsPerMinute', 6)
|
||||
}
|
||||
|
||||
activeStore.set('suggestionTuningRevision', SUGGESTION_TUNING_REVISION)
|
||||
logger.info(
|
||||
`Migrated suggestion tuning values to revision ${SUGGESTION_TUNING_REVISION}`
|
||||
)
|
||||
}
|
||||
|
||||
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
||||
interface ElectronStore<T> {
|
||||
get<K extends keyof T>(key: K): T[K]
|
||||
|
|
@ -82,6 +134,21 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
updateChannel: 'latest',
|
||||
updateDeviceId: '',
|
||||
skippedUpdateVersion: null,
|
||||
// 입력 인텔리전스 — 옵트인. 켜기 전까지 어떤 입력도 수집하지 않는다.
|
||||
inputTelemetryEnabled: false,
|
||||
inputTelemetryPaused: false,
|
||||
inputLearnTypedText: false,
|
||||
inputExcludedApps: [],
|
||||
suggestionEnabled: false,
|
||||
suggestionModelId: null,
|
||||
// 기본값 정본: packages/core/src/input-intelligence.ts SUGGESTION_DEFAULTS
|
||||
suggestionTriggerDelayMs: 600,
|
||||
suggestionMinPrefixChars: 8,
|
||||
suggestionMaxRequestsPerMinute: 6,
|
||||
suggestionDailyBudget: 500,
|
||||
suggestionOverlayInteractive: true,
|
||||
suggestionRequestTimeoutMs: 8000,
|
||||
suggestionTuningRevision: 4,
|
||||
}
|
||||
|
||||
let store: ElectronStore<AppConfig> | null = null
|
||||
|
|
@ -195,6 +262,7 @@ export async function initConfigService(): Promise<void> {
|
|||
defaults: CONFIG_DEFAULTS
|
||||
})
|
||||
migrateKeyBindings(store)
|
||||
migrateSuggestionTuning(store)
|
||||
logger.info('ConfigService initialized')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
|
||||
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -319,7 +319,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
'-',
|
||||
]
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -369,7 +369,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
'pipe:1',
|
||||
]
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { history, stats } from '../db/schema'
|
|||
import type { History, NewHistory } from '../db/schema'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { getInputTelemetryService } from './InputTelemetryService'
|
||||
import type {
|
||||
HistoryEntry,
|
||||
HistoryQueryParams,
|
||||
|
|
@ -43,6 +44,18 @@ class HistoryService {
|
|||
// 비동기로 LLM 타이틀 자동 생성 (fire-and-forget)
|
||||
this.generateTitle(id).catch(() => { /* ignore */ })
|
||||
|
||||
// 입력 인리전스: 음성 전사도 개인 문구 코퍼스의 표본이다.
|
||||
// 학습 동의(inputLearnTypedText)가 없으면 서비스 내부에서 무시된다.
|
||||
try {
|
||||
getInputTelemetryService().recordExternalText(input.originalText, {
|
||||
appName: input.focusedAppName ?? null,
|
||||
windowTitle: input.focusedAppWindowTitle ?? null,
|
||||
source: 'voice'
|
||||
})
|
||||
} catch {
|
||||
// 학습 실패가 이력 저장을 막아서는 안 된다.
|
||||
}
|
||||
|
||||
return this._toEntry(entry as History)
|
||||
}
|
||||
|
||||
|
|
|
|||
1296
apps/desktop/src/main/services/InputTelemetryService.ts
Normal file
1296
apps/desktop/src/main/services/InputTelemetryService.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@ import { globalShortcut } from 'electron'
|
|||
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent, UiohookMouseEvent } from 'uiohook-napi'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { acquireGlobalInputHook } from './global-input-hook'
|
||||
import { configGet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { TIMING } from '@d3ro/core/constants'
|
||||
|
|
@ -33,6 +34,37 @@ import type {
|
|||
|
||||
const logger = getLogger('KeyBindingService')
|
||||
|
||||
/**
|
||||
* AltGr(=Ctrl+Alt) 함정 가드.
|
||||
*
|
||||
* 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면
|
||||
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신
|
||||
* 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는
|
||||
* 잠깐 보류하고, 그 사이에 다른 키가 눌리면(=조합을 만들려던 것) 취소한다.
|
||||
*
|
||||
* 받아쓰기(수정자 단독, Ctrl 없음)와 Alt+Shift 계열은 모양이 달라 영향받지 않는다.
|
||||
*/
|
||||
const ALTGR_CHORD_GRACE_MS = 280
|
||||
|
||||
/** 수정자 키의 VK 코드 (수정자만으로 끝나는 조합 판별). */
|
||||
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
|
||||
0x10, // Shift
|
||||
0x11, // Ctrl
|
||||
0x12, // Alt
|
||||
0x5b, // LWin
|
||||
0x5c, // RWin
|
||||
0xa0,
|
||||
0xa1, // L/R Shift
|
||||
0xa2,
|
||||
0xa3, // L/R Ctrl
|
||||
0xa4,
|
||||
0xa5 // L/R Alt
|
||||
])
|
||||
|
||||
let pendingAltGrEmit: NodeJS.Timeout | null = null
|
||||
/** 보류가 취소된 바인딩 — 대응하는 released 를 내보내지 않기 위해 기억한다. */
|
||||
const cancelledAltGrBindings = new Set<string>()
|
||||
|
||||
// ============================================================
|
||||
// 이벤트 페이로드
|
||||
// ============================================================
|
||||
|
|
@ -47,6 +79,21 @@ export interface KeyBindingTriggerPayload {
|
|||
timestamp: number
|
||||
/** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */
|
||||
durationMs: number
|
||||
/**
|
||||
* 매칭된 바인딩의 주 키 코드와 수정자 상태.
|
||||
*
|
||||
* 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다.
|
||||
* (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면
|
||||
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다)
|
||||
*/
|
||||
binding: {
|
||||
device: 'keyboard' | 'mouse'
|
||||
code: number
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
meta: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyBindingServiceEvents {
|
||||
|
|
@ -200,6 +247,17 @@ const UIOHOOK_TO_VK: ReadonlyMap<number, number> = new Map(
|
|||
Array.from(VK_TO_UIOHOOK, ([vk, uiohookCode]) => [uiohookCode, vk] as const)
|
||||
)
|
||||
|
||||
/**
|
||||
* uiohook 이트 키코드 → Windows VK (정본 좌표계).
|
||||
*
|
||||
* 같은 변환표를 두 곳에 두지 않기 위해 내보낸다. 입력 텔레메트리가 키 **종류**
|
||||
* (문자/숫자/백스페이스/단축키 …)를 분류할 때 쓴다 — 키 내용은 저장하지 않는다.
|
||||
* 매핑이 없는 키는 null.
|
||||
*/
|
||||
export function uiohookCodeToVk(code: number): number | null {
|
||||
return UIOHOOK_TO_VK.get(code) ?? null
|
||||
}
|
||||
|
||||
function isCtrlCode(code: number): boolean {
|
||||
return code === UiohookKey.Ctrl || code === UiohookKey.CtrlRight
|
||||
}
|
||||
|
|
@ -322,12 +380,21 @@ interface ActiveTrigger {
|
|||
actionId: KeyBindingActionId
|
||||
isDoublePress: boolean
|
||||
holdMode: boolean
|
||||
/** 눌림을 만든 바인딩 (AltGr 형태 판정에 필요) */
|
||||
entry: RegisteredBinding
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// KeyBindingService
|
||||
// ============================================================
|
||||
|
||||
/** AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. */
|
||||
function isAltGrShaped(binding: KeyBinding): boolean {
|
||||
if (binding.device !== 'keyboard') return false
|
||||
if (!binding.ctrl || !binding.alt) return false
|
||||
return MODIFIER_VK_CODES.has(binding.code)
|
||||
}
|
||||
|
||||
class KeyBindingService extends EventEmitter {
|
||||
private _isRunning = false
|
||||
|
||||
|
|
@ -354,6 +421,14 @@ class KeyBindingService extends EventEmitter {
|
|||
private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null
|
||||
private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null
|
||||
|
||||
/**
|
||||
* 글로벌 후 해제 함수.
|
||||
*
|
||||
* 후킹은 프로세스 전역 글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
|
||||
* 직접 stop 하면 상대방 수신을 죽이므로 참조 카운트 해제자를 보관한다.
|
||||
*/
|
||||
private _releaseHook: (() => void) | null = null
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._isRunning
|
||||
}
|
||||
|
|
@ -386,11 +461,11 @@ class KeyBindingService extends EventEmitter {
|
|||
uIOhook.on('keyup', this._onKeyUp)
|
||||
uIOhook.on('mousedown', this._onMouseDown)
|
||||
uIOhook.on('mouseup', this._onMouseUp)
|
||||
uIOhook.start()
|
||||
this._releaseHook = acquireGlobalInputHook()
|
||||
|
||||
this._isRunning = true
|
||||
this._syncAccelerators()
|
||||
logger.info('uiohook started, global keyboard/mouse hook active')
|
||||
logger.info('Key binding listeners attached to global input hook')
|
||||
} catch (error) {
|
||||
const d3roError = new D3ROError(
|
||||
ErrorCode.HotkeyHookInitFailed,
|
||||
|
|
@ -406,7 +481,8 @@ class KeyBindingService extends EventEmitter {
|
|||
if (!this._isRunning) return
|
||||
|
||||
try {
|
||||
uIOhook.stop()
|
||||
this._releaseHook?.()
|
||||
this._releaseHook = null
|
||||
|
||||
if (this._onKeyDown) {
|
||||
uIOhook.removeListener('keydown', this._onKeyDown)
|
||||
|
|
@ -684,24 +760,74 @@ class KeyBindingService extends EventEmitter {
|
|||
triggers.push({
|
||||
actionId: entry.actionId,
|
||||
isDoublePress,
|
||||
holdMode: entry.spec.holdMode
|
||||
holdMode: entry.spec.holdMode,
|
||||
entry
|
||||
})
|
||||
}
|
||||
this._activeTriggers.set(key, triggers)
|
||||
|
||||
for (const trigger of triggers) {
|
||||
logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`)
|
||||
this.emit('triggered', {
|
||||
actionId: trigger.actionId,
|
||||
type: 'pressed',
|
||||
isDoublePress: trigger.isDoublePress,
|
||||
holdMode: trigger.holdMode,
|
||||
timestamp: now,
|
||||
durationMs: 0
|
||||
})
|
||||
this._emitPressed(trigger.entry, trigger, now, key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 눌림 이벤트를 내보낸다.
|
||||
*
|
||||
* AltGr 형태(수정자 전용 + Ctrl+Alt)는 곧 다른 키가 이어질 수 있으므로 잠깐
|
||||
* 보류한다. 그 사이 다른 바인딩의 눌림이 오면 취소한다 — 오른쪽 Alt 는 Windows 가
|
||||
* Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 Alt 순간에 Ctrl+AltRight(음성 명령)
|
||||
* 가 먼저 발동하던 문제를 이 지점에서 막는다.
|
||||
*/
|
||||
private _emitPressed(
|
||||
entry: RegisteredBinding,
|
||||
trigger: ActiveTrigger,
|
||||
now: number,
|
||||
bindingKeyValue: string
|
||||
): void {
|
||||
const payload: KeyBindingTriggerPayload = {
|
||||
actionId: trigger.actionId,
|
||||
type: 'pressed',
|
||||
isDoublePress: trigger.isDoublePress,
|
||||
holdMode: trigger.holdMode,
|
||||
timestamp: now,
|
||||
durationMs: 0,
|
||||
binding: {
|
||||
device: entry.binding.device,
|
||||
code: entry.binding.code,
|
||||
ctrl: entry.binding.ctrl,
|
||||
alt: entry.binding.alt,
|
||||
shift: entry.binding.shift,
|
||||
meta: entry.binding.meta
|
||||
}
|
||||
}
|
||||
|
||||
if (isAltGrShaped(entry.binding)) {
|
||||
if (pendingAltGrEmit) clearTimeout(pendingAltGrEmit)
|
||||
const actionId = trigger.actionId
|
||||
// 취소 표시는 _fireRelease 가 쓰는 것과 같은 키여야 한다 (device:code 조합이 아니다).
|
||||
cancelledAltGrBindings.add(actionId + ':' + bindingKeyValue)
|
||||
pendingAltGrEmit = setTimeout(() => {
|
||||
pendingAltGrEmit = null
|
||||
logger.debug(`AltGr 형태 트리거 유예 후 발동: "${actionId}"`)
|
||||
this.emit('triggered', payload)
|
||||
}, ALTGR_CHORD_GRACE_MS)
|
||||
pendingAltGrEmit.unref?.()
|
||||
return
|
||||
}
|
||||
|
||||
// 다른 키가 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소.
|
||||
if (pendingAltGrEmit) {
|
||||
clearTimeout(pendingAltGrEmit)
|
||||
pendingAltGrEmit = null
|
||||
logger.debug('AltGr 형태 보류 트리거 취소 (다른 키가 이어짐)')
|
||||
}
|
||||
|
||||
this.emit('triggered', payload)
|
||||
}
|
||||
|
||||
private _fireRelease(key: string): void {
|
||||
this._isKeyDown.set(key, false)
|
||||
|
||||
|
|
@ -717,13 +843,26 @@ class KeyBindingService extends EventEmitter {
|
|||
logger.debug(
|
||||
`Key binding released: "${trigger.actionId}" (duration=${durationMs}ms)`
|
||||
)
|
||||
if (cancelledAltGrBindings.delete(trigger.actionId + ':' + key)) {
|
||||
logger.debug(`AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.actionId}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
this.emit('triggered', {
|
||||
actionId: trigger.actionId,
|
||||
type: 'released',
|
||||
isDoublePress: trigger.isDoublePress,
|
||||
holdMode: trigger.holdMode,
|
||||
timestamp: now,
|
||||
durationMs
|
||||
durationMs,
|
||||
binding: {
|
||||
device: 'keyboard',
|
||||
code: 0,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ interface GenerateOptions {
|
|||
maxTokens?: number
|
||||
systemPrompt?: string
|
||||
stream?: boolean
|
||||
/**
|
||||
* 호출자 소유의 취소 시그널.
|
||||
*
|
||||
* 호출자별 취소를 위해 사용한다. `cancelGeneration()`은 모든 활성 요청을
|
||||
* 취소하므로 인라인 제안처럼 자기 요청만 중단해야 하는 쪽은 이 시그널을 쓴다.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
/** 요청별 생성 제한 시간(ms). */
|
||||
timeoutMs?: number
|
||||
/**
|
||||
* Ollama keep_alive 값 (예: '30m').
|
||||
*
|
||||
* 제안처럼 반복 호출되는 경로에서 모델이 메모리에서 내려가면 요청마다
|
||||
* 콜드 로딩(실측 20초+)을 다시 문다.
|
||||
*/
|
||||
keepAlive?: string
|
||||
}
|
||||
|
||||
interface GenerateResult {
|
||||
|
|
@ -57,6 +73,23 @@ interface OllamaGenerateResponse {
|
|||
eval_count?: number
|
||||
}
|
||||
|
||||
interface ChatStreamOptions {
|
||||
model?: string
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
keepAlive?: string
|
||||
}
|
||||
|
||||
type AbortCause = 'timeout' | 'cancelled'
|
||||
|
||||
interface ActiveRequest {
|
||||
controller: AbortController
|
||||
abortCause: AbortCause | null
|
||||
close: () => void
|
||||
}
|
||||
|
||||
interface OllamaTagsResponse {
|
||||
models: Array<{
|
||||
name: string
|
||||
|
|
@ -110,7 +143,8 @@ class LocalLLMService extends EventEmitter {
|
|||
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
private _available = false
|
||||
private _serverVersion: string | null = null
|
||||
private _abortController: AbortController | null = null
|
||||
private _activeRequests = new Set<ActiveRequest>()
|
||||
private _ensureRunningPromise: Promise<'running' | 'starting' | 'not-installed' | 'failed'> | null = null
|
||||
private _disposed = false
|
||||
|
||||
get state(): LLMState {
|
||||
|
|
@ -144,6 +178,22 @@ class LocalLLMService extends EventEmitter {
|
|||
* - 'failed': 스폰 시도 실패
|
||||
*/
|
||||
async ensureRunning(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> {
|
||||
if (this._ensureRunningPromise) {
|
||||
return this._ensureRunningPromise
|
||||
}
|
||||
|
||||
const task = this._ensureRunning()
|
||||
this._ensureRunningPromise = task
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
if (this._ensureRunningPromise === task) {
|
||||
this._ensureRunningPromise = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _ensureRunning(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> {
|
||||
if (await this._ping(1500)) {
|
||||
logger.info('Ollama server already running')
|
||||
return 'running'
|
||||
|
|
@ -307,6 +357,7 @@ class LocalLLMService extends EventEmitter {
|
|||
* Ollama 가용성 폴링을 시작한다 (5초 간격).
|
||||
*/
|
||||
startPolling(): void {
|
||||
if (this._pollInterval) return
|
||||
this._checkAvailability()
|
||||
this._pollInterval = setInterval(() => {
|
||||
if (this._state !== LLMState.Generating) {
|
||||
|
|
@ -333,8 +384,8 @@ class LocalLLMService extends EventEmitter {
|
|||
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
||||
|
||||
this._state = LLMState.Generating
|
||||
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 2048)
|
||||
const request = this._beginRequest(options?.signal, options?.timeoutMs, 120000)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/generate`, {
|
||||
|
|
@ -345,15 +396,16 @@ class LocalLLMService extends EventEmitter {
|
|||
prompt,
|
||||
system: options?.systemPrompt,
|
||||
stream: false,
|
||||
keep_alive: options?.keepAlive,
|
||||
// Ollama v0.20+ think 파라미터: reasoning 모델에서 thinking 토큰 생성 중단.
|
||||
// gemma4/llama3.2 등 non-reasoning 모델에서는 무시됨.
|
||||
think: false,
|
||||
options: {
|
||||
temperature: options?.temperature ?? 0.3,
|
||||
num_predict: options?.maxTokens ?? 2048
|
||||
num_predict: maxTokens
|
||||
}
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000)
|
||||
signal: request.controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -373,22 +425,18 @@ class LocalLLMService extends EventEmitter {
|
|||
totalDuration: data.total_duration ? data.total_duration / 1e6 : 0
|
||||
}
|
||||
|
||||
this._state = LLMState.Available
|
||||
this.emit('complete', { result })
|
||||
return result
|
||||
} catch (error) {
|
||||
this._state = LLMState.Available
|
||||
if (error instanceof D3ROError) throw error
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMProcessingFailed,
|
||||
`LLM generation failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw this._toRequestError(error, request, 'LLM generation failed')
|
||||
} finally {
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스트리밍 텍스트 생성. NDJSON 파싱.
|
||||
* 반환된 AbortController로 취소 가능.
|
||||
* 호출자별 AbortSignal 또는 전역 cancelGeneration()으로 취소 가능.
|
||||
*/
|
||||
async *streamGenerate(
|
||||
prompt: string,
|
||||
|
|
@ -400,9 +448,10 @@ class LocalLLMService extends EventEmitter {
|
|||
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
||||
|
||||
this._state = LLMState.Generating
|
||||
this._abortController = new AbortController()
|
||||
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 2048)
|
||||
const request = this._beginRequest(options?.signal, options?.timeoutMs, 120000)
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
|
||||
let doneFrame = false
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/generate`, {
|
||||
|
|
@ -413,13 +462,14 @@ class LocalLLMService extends EventEmitter {
|
|||
prompt,
|
||||
system: options?.systemPrompt,
|
||||
stream: true,
|
||||
keep_alive: options?.keepAlive,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: options?.temperature ?? 0.3,
|
||||
num_predict: options?.maxTokens ?? 2048
|
||||
num_predict: maxTokens
|
||||
}
|
||||
}),
|
||||
signal: this._abortController.signal
|
||||
signal: request.controller.signal
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
|
|
@ -429,11 +479,22 @@ class LocalLLMService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let fullText = ''
|
||||
let lastChunk: OllamaGenerateResponse | null = null
|
||||
const complete = (): GenerateResult => {
|
||||
const result: GenerateResult = {
|
||||
text: fullText,
|
||||
model: lastChunk?.model ?? model,
|
||||
promptTokens: lastChunk?.prompt_eval_count ?? 0,
|
||||
completionTokens: lastChunk?.eval_count ?? 0,
|
||||
totalDuration: lastChunk?.total_duration ? lastChunk.total_duration / 1e6 : 0
|
||||
}
|
||||
this.emit('complete', { result })
|
||||
return result
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
|
@ -448,45 +509,140 @@ class LocalLLMService extends EventEmitter {
|
|||
try {
|
||||
const chunk = JSON.parse(line) as OllamaGenerateResponse
|
||||
fullText += chunk.response
|
||||
this.emit('token', { token: chunk.response, done: chunk.done })
|
||||
yield chunk.response
|
||||
|
||||
if (chunk.done) {
|
||||
lastChunk = chunk
|
||||
doneFrame = true
|
||||
this.emit('token', { token: chunk.response, done: true })
|
||||
if (chunk.response) yield chunk.response
|
||||
return complete()
|
||||
}
|
||||
this.emit('token', { token: chunk.response, done: chunk.done })
|
||||
yield chunk.response
|
||||
} catch {
|
||||
logger.warn(`Failed to parse NDJSON line: ${line.substring(0, 100)}`)
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._state = LLMState.Available
|
||||
this._abortController = null
|
||||
|
||||
const result: GenerateResult = {
|
||||
text: fullText,
|
||||
model: lastChunk?.model ?? model,
|
||||
promptTokens: lastChunk?.prompt_eval_count ?? 0,
|
||||
completionTokens: lastChunk?.eval_count ?? 0,
|
||||
totalDuration: lastChunk?.total_duration ? lastChunk.total_duration / 1e6 : 0
|
||||
buffer += decoder.decode()
|
||||
const trailing = buffer.trim()
|
||||
if (trailing) {
|
||||
try {
|
||||
const chunk = JSON.parse(trailing) as OllamaGenerateResponse
|
||||
fullText += chunk.response
|
||||
if (chunk.done) {
|
||||
lastChunk = chunk
|
||||
doneFrame = true
|
||||
this.emit('token', { token: chunk.response, done: true })
|
||||
if (chunk.response) yield chunk.response
|
||||
return complete()
|
||||
}
|
||||
this.emit('token', { token: chunk.response, done: chunk.done })
|
||||
yield chunk.response
|
||||
} catch {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('complete', { result })
|
||||
return result
|
||||
if (!doneFrame) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama stream ended before completion')
|
||||
}
|
||||
|
||||
return complete()
|
||||
} catch (error) {
|
||||
this._state = LLMState.Available
|
||||
this._abortController = null
|
||||
if (error instanceof D3ROError) throw error
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM generation cancelled')
|
||||
throw this._toRequestError(error, request, 'LLM streaming failed')
|
||||
} finally {
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch (error) {
|
||||
logger.warn(`LLM stream reader cancellation failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMProcessingFailed,
|
||||
`LLM streaming failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (!doneFrame) {
|
||||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청별 취소·기한을 활성 요청 집합에 등록한다. */
|
||||
private _beginRequest(
|
||||
externalSignal: AbortSignal | undefined,
|
||||
timeoutMs: number | undefined,
|
||||
defaultTimeoutMs: number
|
||||
): ActiveRequest {
|
||||
const controller = new AbortController()
|
||||
const resolvedTimeoutMs = this._resolveTimeoutMs(timeoutMs, defaultTimeoutMs)
|
||||
const abort = (cause: AbortCause): void => {
|
||||
if (request.abortCause) return
|
||||
request.abortCause = cause
|
||||
controller.abort()
|
||||
}
|
||||
const onExternalAbort = (): void => abort('cancelled')
|
||||
const timeout = setTimeout(() => abort('timeout'), resolvedTimeoutMs)
|
||||
const request: ActiveRequest = {
|
||||
controller,
|
||||
abortCause: null,
|
||||
close: () => {
|
||||
clearTimeout(timeout)
|
||||
externalSignal?.removeEventListener('abort', onExternalAbort)
|
||||
this._activeRequests.delete(request)
|
||||
this._refreshState()
|
||||
}
|
||||
}
|
||||
this._activeRequests.add(request)
|
||||
this._refreshState()
|
||||
|
||||
if (externalSignal?.aborted) {
|
||||
onExternalAbort()
|
||||
} else {
|
||||
externalSignal?.addEventListener('abort', onExternalAbort, { once: true })
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
private _resolveMaxTokens(maxTokens: number | undefined, fallback: number): number {
|
||||
if (maxTokens === undefined) return fallback
|
||||
if (!Number.isSafeInteger(maxTokens) || maxTokens <= 0) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'maxTokens must be a positive safe integer')
|
||||
}
|
||||
return Math.min(maxTokens, 4096)
|
||||
}
|
||||
|
||||
private _resolveTimeoutMs(timeoutMs: number | undefined, fallback: number): number {
|
||||
if (timeoutMs === undefined) return fallback
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'timeoutMs must be a positive safe integer')
|
||||
}
|
||||
return timeoutMs
|
||||
}
|
||||
|
||||
private _toRequestError(error: unknown, request: ActiveRequest, prefix: string): D3ROError {
|
||||
if (request.abortCause === 'timeout') {
|
||||
return new D3ROError(ErrorCode.LLMProcessingTimeout, 'LLM generation timed out')
|
||||
}
|
||||
if (request.abortCause === 'cancelled' || request.controller.signal.aborted) {
|
||||
return new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM generation cancelled')
|
||||
}
|
||||
if (error instanceof D3ROError) return error
|
||||
return new D3ROError(
|
||||
ErrorCode.LLMProcessingFailed,
|
||||
`${prefix}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
private _refreshState(): void {
|
||||
this._state = this._disposed
|
||||
? LLMState.Unavailable
|
||||
: this._activeRequests.size > 0
|
||||
? LLMState.Generating
|
||||
: this._available
|
||||
? LLMState.Available
|
||||
: LLMState.Unavailable
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트를 LLM 액션에 따라 처리한다.
|
||||
*/
|
||||
|
|
@ -531,9 +687,11 @@ class LocalLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
cancelGeneration(): void {
|
||||
if (this._abortController) {
|
||||
this._abortController.abort()
|
||||
this._abortController = null
|
||||
if (this._activeRequests.size > 0) {
|
||||
for (const request of this._activeRequests) {
|
||||
if (!request.abortCause) request.abortCause = 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
logger.info('LLM generation cancelled')
|
||||
}
|
||||
}
|
||||
|
|
@ -679,7 +837,7 @@ class LocalLLMService extends EventEmitter {
|
|||
*/
|
||||
async *chatStream(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: { model?: string; temperature?: number },
|
||||
options?: ChatStreamOptions,
|
||||
): AsyncGenerator<string, string> {
|
||||
if (!this._available) {
|
||||
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama server not available')
|
||||
|
|
@ -687,9 +845,10 @@ class LocalLLMService extends EventEmitter {
|
|||
|
||||
const serverUrl = getOllamaServerUrl()
|
||||
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
||||
|
||||
this._abortController = new AbortController()
|
||||
this._state = LLMState.Generating
|
||||
const maxTokens = this._resolveMaxTokens(options?.maxTokens, 512)
|
||||
const request = this._beginRequest(options?.signal, options?.timeoutMs, 60000)
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
|
||||
let doneFrame = false
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/chat`, {
|
||||
|
|
@ -699,19 +858,21 @@ class LocalLLMService extends EventEmitter {
|
|||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
keep_alive: options?.keepAlive,
|
||||
think: false,
|
||||
options: {
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
num_predict: maxTokens,
|
||||
},
|
||||
}),
|
||||
signal: this._abortController.signal,
|
||||
signal: request.controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, `Chat API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let accumulated = ''
|
||||
|
|
@ -728,6 +889,9 @@ class LocalLLMService extends EventEmitter {
|
|||
if (!line.trim()) continue
|
||||
try {
|
||||
const chunk = JSON.parse(line) as { message?: { content: string }; done: boolean }
|
||||
if (chunk.done) {
|
||||
doneFrame = true
|
||||
}
|
||||
if (chunk.message?.content) {
|
||||
accumulated += chunk.message.content
|
||||
yield chunk.message.content
|
||||
|
|
@ -736,15 +900,47 @@ class LocalLLMService extends EventEmitter {
|
|||
return accumulated
|
||||
}
|
||||
} catch {
|
||||
// 불완전 JSON 무시
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulated
|
||||
buffer += decoder.decode()
|
||||
const trailing = buffer.trim()
|
||||
if (trailing) {
|
||||
try {
|
||||
const chunk = JSON.parse(trailing) as { message?: { content: string }; done: boolean }
|
||||
if (chunk.done) {
|
||||
doneFrame = true
|
||||
}
|
||||
if (chunk.message?.content) {
|
||||
accumulated += chunk.message.content
|
||||
yield chunk.message.content
|
||||
}
|
||||
if (chunk.done) {
|
||||
return accumulated
|
||||
}
|
||||
} catch {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama returned malformed NDJSON')
|
||||
}
|
||||
}
|
||||
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'Ollama chat stream ended before completion')
|
||||
} catch (error) {
|
||||
throw this._toRequestError(error, request, 'LLM chat streaming failed')
|
||||
} finally {
|
||||
this._state = this._available ? LLMState.Available : LLMState.Unavailable
|
||||
this._abortController = null
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch (error) {
|
||||
logger.warn(`LLM chat reader cancellation failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
if (!doneFrame) {
|
||||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -752,6 +948,7 @@ class LocalLLMService extends EventEmitter {
|
|||
this._disposed = true
|
||||
this.stopPolling()
|
||||
this.cancelGeneration()
|
||||
this._refreshState()
|
||||
this.removeAllListeners()
|
||||
logger.info('LocalLLMService disposed')
|
||||
}
|
||||
|
|
@ -800,11 +997,11 @@ class LocalLLMService extends EventEmitter {
|
|||
if (detectedVersion) this._serverVersion = detectedVersion
|
||||
|
||||
if (!wasAvailable && this._available) {
|
||||
this._state = LLMState.Available
|
||||
this._refreshState()
|
||||
this.emit('availability-changed', { available: true })
|
||||
logger.info(`Ollama server connected (version: ${this._serverVersion ?? 'active'})`)
|
||||
} else if (wasAvailable && !this._available) {
|
||||
this._state = LLMState.Unavailable
|
||||
this._refreshState()
|
||||
this._serverVersion = null
|
||||
this.emit('availability-changed', { available: false })
|
||||
logger.warn('Ollama server disconnected')
|
||||
|
|
@ -848,6 +1045,10 @@ export async function startLocalLLMAvailability(): Promise<void> {
|
|||
}
|
||||
|
||||
export function resetLocalLLMServiceForTests(): void {
|
||||
if (instance) instance.removeAllListeners()
|
||||
instance?.dispose()
|
||||
instance = null
|
||||
}
|
||||
|
||||
export function disposeLocalLLMService(): void {
|
||||
instance?.dispose()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,6 +354,17 @@ class LocalSTTService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이드카 프로세스만 보장하고 base URL 을 돌려준다 (모델 로딩 없음).
|
||||
*
|
||||
* 입력 인텔리전스(제안/학습)는 UIA 브리지가 사이드카에 있으므로 STT 모델 없이도
|
||||
* 사이드카 HTTP 서버가 필요하다. 실패하면 예외를 그대로 올린다.
|
||||
*/
|
||||
async ensureSidecar(): Promise<string> {
|
||||
await this._ensureSidecarRunning()
|
||||
return this._baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 녹음 중 실시간 미리보기 전사.
|
||||
* 최종 결과와 분리되어 삽입되지 않으며, 실패해도 빈 문자열을 반환한다.
|
||||
|
|
|
|||
392
apps/desktop/src/main/services/PersonalGraphService.ts
Normal file
392
apps/desktop/src/main/services/PersonalGraphService.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
// src/main/services/PersonalGraphService.ts
|
||||
//
|
||||
// 개인 그래프 — 사용자의 문장을 노드로, 관계를 엣지로 저장하고 제안 문맥을 끌어온다.
|
||||
//
|
||||
// 왜: n-gram 문자열 조회는 "같은 꼬리 뒤" 만 찾는다. 실제 글은 관계로 이어진다 —
|
||||
// 어떤 문장은 늘 다른 문장 뒤에 오고(follows), 어떤 문장들은 용어를 공유한다
|
||||
// (shares_terms). 그 관계를 저장하면 접두가 조금 어긋나도 관련 문맥을 쓸 수 있다.
|
||||
//
|
||||
// 전부 로컬 SQLite 다 (임베딩/네트워크 없음). 학습 동의(inputLearnTypedText)가 없으면
|
||||
// 표본이 저장되지 않으므로 그래프도 비어 있다.
|
||||
|
||||
import {
|
||||
SHARES_TERMS_MIN_SIMILARITY,
|
||||
buildSequenceEdges,
|
||||
continuationsFrom,
|
||||
extractTerms,
|
||||
jaccard,
|
||||
rankRelated,
|
||||
splitSentences,
|
||||
type GraphContext,
|
||||
type PersonalGraphQuery,
|
||||
type PersonalGraphStats,
|
||||
type PhraseSource,
|
||||
type RelatedCandidate
|
||||
} from '@d3ro/core/personal-graph'
|
||||
import { 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'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('PersonalGraphService')
|
||||
|
||||
/** 계약 타입은 core 정본을 따른다 (렌더러/프리로드와 공유). */
|
||||
export type { PersonalGraphQuery, PersonalGraphStats }
|
||||
|
||||
|
||||
/** 용어 공유 엣지 백필 시 한 번에 볼 노드 수. */
|
||||
const MAINTENANCE_NODE_LIMIT = 300
|
||||
/** 관계 조회에서 최근 노드를 훑는 수. */
|
||||
const TERM_SCAN_LIMIT = 400
|
||||
|
||||
class PersonalGraphService {
|
||||
/**
|
||||
* 텍스트를 그래프에 반영한다.
|
||||
*
|
||||
* 문장마다 노드를 upsert 하고, 연속한 문장 쌍을 follows 엣지로, 용어를 충분히
|
||||
* 공유하는 쌍을 shares_terms 엣지로 남긴다.
|
||||
*/
|
||||
indexText(
|
||||
text: string,
|
||||
meta: { source: PhraseSource; appName?: string | null; at?: number }
|
||||
): void {
|
||||
const sentences = splitSentences(text)
|
||||
if (sentences.length === 0) return
|
||||
|
||||
const at = meta.at ?? Date.now()
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const terms = extractTerms(sentence)
|
||||
db.insert(personalPhrases)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
phrase: sentence,
|
||||
count: 1,
|
||||
source: meta.source,
|
||||
lastUsedAt: at,
|
||||
createdAt: at,
|
||||
terms: JSON.stringify(terms),
|
||||
appName: meta.appName ?? null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: personalPhrases.phrase,
|
||||
set: {
|
||||
count: sql`${personalPhrases.count} + 1`,
|
||||
lastUsedAt: at,
|
||||
terms: JSON.stringify(terms),
|
||||
appName: meta.appName ?? null
|
||||
}
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
// follows 엣지
|
||||
for (const edge of buildSequenceEdges(sentences)) {
|
||||
this._upsertEdge(edge.from, edge.to, 'follows')
|
||||
}
|
||||
|
||||
// 같은 텍스트 안에서 용어를 많이 공유하는 쌍
|
||||
for (let i = 0; i < sentences.length; i += 1) {
|
||||
const termsA = extractTerms(sentences[i])
|
||||
for (let j = i + 1; j < sentences.length; j += 1) {
|
||||
const termsB = extractTerms(sentences[j])
|
||||
if (jaccard(termsA, termsB) < SHARES_TERMS_MIN_SIMILARITY) continue
|
||||
this._upsertEdge(sentences[i], sentences[j], 'shares_terms')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`그래프 반영 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _upsertEdge(from: string, to: string, kind: 'follows' | 'shares_terms'): void {
|
||||
const db = getDatabase()
|
||||
const now = Date.now()
|
||||
db.insert(phraseEdges)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
fromPhrase: from,
|
||||
toPhrase: to,
|
||||
kind,
|
||||
weight: 1,
|
||||
updatedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [phraseEdges.fromPhrase, phraseEdges.toPhrase, phraseEdges.kind],
|
||||
set: {
|
||||
weight: sql`${phraseEdges.weight} + 1`,
|
||||
updatedAt: now
|
||||
}
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* 제안 문맥을 그래프에서 끌어온다.
|
||||
*
|
||||
* 1) 접두 꼬리를 포함하는 노드를 찾고 그 뒤에 실제로 이어 쓴 텍스트를 뽑는다.
|
||||
* 2) 그 노드들의 follows 이웃과 용어를 공유하는 노드를 순위화해 관련 문장으로 준다.
|
||||
*/
|
||||
retrieveContext(prefix: string, limit = 4): GraphContext {
|
||||
const tail = prefixTail(prefix)
|
||||
if (tail.length < 4) return { continuations: [], related: [] }
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const pattern = `%${tail.replace(/[%_\\]/gu, '')}%`
|
||||
|
||||
const anchorRows = db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.where(sql`${personalPhrases.phrase} LIKE ${pattern}`)
|
||||
.orderBy(desc(personalPhrases.lastUsedAt))
|
||||
.limit(20)
|
||||
.all()
|
||||
|
||||
if (anchorRows.length === 0) return { continuations: [], related: [] }
|
||||
|
||||
const anchorTexts = anchorRows.map((row) => row.phrase)
|
||||
const continuations = continuationsFrom(anchorTexts, tail, 3)
|
||||
|
||||
const anchorTerms = new Set<string>()
|
||||
for (const row of anchorRows) {
|
||||
for (const term of parseTerms(row.terms)) anchorTerms.add(term)
|
||||
}
|
||||
|
||||
const candidates: RelatedCandidate[] = []
|
||||
const seen = new Set<string>(anchorTexts)
|
||||
|
||||
// (a) follows 이웃 — 가장 강한 관계
|
||||
const edgeRows = db
|
||||
.select()
|
||||
.from(phraseEdges)
|
||||
.where(
|
||||
and(inArray(phraseEdges.fromPhrase, anchorTexts), eq(phraseEdges.kind, 'follows'))
|
||||
)
|
||||
.orderBy(desc(phraseEdges.weight))
|
||||
.limit(40)
|
||||
.all()
|
||||
|
||||
for (const edge of edgeRows) {
|
||||
if (seen.has(edge.toPhrase)) continue
|
||||
seen.add(edge.toPhrase)
|
||||
candidates.push({
|
||||
text: edge.toPhrase,
|
||||
terms: [],
|
||||
weight: edge.weight * 2,
|
||||
lastUsedAt: null
|
||||
})
|
||||
}
|
||||
|
||||
// (b) 용어 공유 — 최근 노드를 훑어 유사도가 높은 것
|
||||
const recentNodes = db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.orderBy(desc(personalPhrases.lastUsedAt))
|
||||
.limit(TERM_SCAN_LIMIT)
|
||||
.all()
|
||||
|
||||
for (const node of recentNodes) {
|
||||
if (seen.has(node.phrase)) continue
|
||||
const terms = parseTerms(node.terms)
|
||||
if (terms.length === 0) continue
|
||||
const similarity = jaccard([...anchorTerms], terms)
|
||||
if (similarity < SHARES_TERMS_MIN_SIMILARITY) continue
|
||||
seen.add(node.phrase)
|
||||
candidates.push({
|
||||
text: node.phrase,
|
||||
terms,
|
||||
weight: 0,
|
||||
lastUsedAt: node.lastUsedAt
|
||||
})
|
||||
}
|
||||
|
||||
const related = rankRelated([...anchorTerms], candidates, limit)
|
||||
return { continuations, related }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`그래프 문맥 조회 실패: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return { continuations: [], related: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/** 용어 공유 엣지를 백필한다 (기존 노드들 사이 관계 보강). */
|
||||
runMaintenance(limit = MAINTENANCE_NODE_LIMIT): number {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const nodes = db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.orderBy(desc(personalPhrases.lastUsedAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
let created = 0
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const termsA = parseTerms(nodes[i].terms)
|
||||
if (termsA.length === 0) continue
|
||||
for (let j = i + 1; j < nodes.length; j += 1) {
|
||||
const termsB = parseTerms(nodes[j].terms)
|
||||
if (termsB.length === 0) continue
|
||||
if (jaccard(termsA, termsB) < SHARES_TERMS_MIN_SIMILARITY) continue
|
||||
this._upsertEdge(nodes[i].phrase, nodes[j].phrase, 'shares_terms')
|
||||
created += 1
|
||||
if (created >= 200) return created
|
||||
}
|
||||
}
|
||||
if (created > 0) logger.info(`그래프 유지보수: shares_terms 엣지 ${created}개 생성`)
|
||||
return created
|
||||
} catch (error) {
|
||||
logger.warn(`그래프 유지보수 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
getStats(days = 30): PersonalGraphStats {
|
||||
const empty: PersonalGraphStats = {
|
||||
nodes: 0,
|
||||
followsEdges: 0,
|
||||
sharesTermsEdges: 0,
|
||||
topEdges: [],
|
||||
recentNodes: []
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const since = Date.now() - days * 86400000
|
||||
|
||||
const nodeCount = db
|
||||
.select({ value: sql<number>`count(*)` })
|
||||
.from(personalPhrases)
|
||||
.get()
|
||||
const follows = db
|
||||
.select({ value: sql<number>`count(*)` })
|
||||
.from(phraseEdges)
|
||||
.where(eq(phraseEdges.kind, 'follows'))
|
||||
.get()
|
||||
const shares = db
|
||||
.select({ value: sql<number>`count(*)` })
|
||||
.from(phraseEdges)
|
||||
.where(eq(phraseEdges.kind, 'shares_terms'))
|
||||
.get()
|
||||
|
||||
const topEdges = db
|
||||
.select({
|
||||
from: phraseEdges.fromPhrase,
|
||||
to: phraseEdges.toPhrase,
|
||||
kind: phraseEdges.kind,
|
||||
weight: phraseEdges.weight
|
||||
})
|
||||
.from(phraseEdges)
|
||||
.orderBy(desc(phraseEdges.weight))
|
||||
.limit(12)
|
||||
.all()
|
||||
|
||||
const recentNodes = db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.where(gte(personalPhrases.createdAt, since))
|
||||
.orderBy(desc(personalPhrases.lastUsedAt))
|
||||
.limit(20)
|
||||
.all()
|
||||
|
||||
return {
|
||||
nodes: Number(nodeCount?.value ?? 0),
|
||||
followsEdges: Number(follows?.value ?? 0),
|
||||
sharesTermsEdges: Number(shares?.value ?? 0),
|
||||
topEdges,
|
||||
recentNodes: recentNodes.map((row) => ({
|
||||
text: row.phrase,
|
||||
terms: parseTerms(row.terms),
|
||||
count: row.count,
|
||||
appName: row.appName
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`그래프 통계 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
/** 특정 텍스트 주변의 그래프를 조회한다 (설정/지식베이스 UI). */
|
||||
query(text: string, limit = 12): PersonalGraphQuery {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
const needle = text.trim()
|
||||
if (needle.length < 2) return { anchors: [], neighbors: [] }
|
||||
|
||||
const pattern = `%${needle.replace(/[%_\\]/gu, '')}%`
|
||||
const anchors = db
|
||||
.select()
|
||||
.from(personalPhrases)
|
||||
.where(sql`${personalPhrases.phrase} LIKE ${pattern}`)
|
||||
.orderBy(desc(personalPhrases.lastUsedAt))
|
||||
.limit(6)
|
||||
.all()
|
||||
|
||||
if (anchors.length === 0) return { anchors: [], neighbors: [] }
|
||||
|
||||
const anchorTexts = anchors.map((row) => row.phrase)
|
||||
const neighbors = db
|
||||
.select({
|
||||
text: phraseEdges.toPhrase,
|
||||
kind: phraseEdges.kind,
|
||||
weight: phraseEdges.weight
|
||||
})
|
||||
.from(phraseEdges)
|
||||
.where(inArray(phraseEdges.fromPhrase, anchorTexts))
|
||||
.orderBy(desc(phraseEdges.weight))
|
||||
.limit(limit)
|
||||
.all()
|
||||
|
||||
return {
|
||||
anchors: anchors.map((row) => ({
|
||||
text: row.phrase,
|
||||
terms: parseTerms(row.terms),
|
||||
count: row.count
|
||||
})),
|
||||
neighbors
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`그래프 조회 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return { anchors: [], neighbors: [] }
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
try {
|
||||
const db = getDatabase()
|
||||
db.delete(phraseEdges).run()
|
||||
logger.info('개인 그래프 엣지 전체 삭제')
|
||||
} catch (error) {
|
||||
logger.warn(`그래프 삭제 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** DB 의 terms 컬럼(JSON)을 안전하게 파싱한다. */
|
||||
function parseTerms(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((value): value is string => typeof value === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
let instance: PersonalGraphService | null = null
|
||||
|
||||
export function getPersonalGraphService(): PersonalGraphService {
|
||||
if (!instance) instance = new PersonalGraphService()
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetPersonalGraphServiceForTests(): void {
|
||||
instance = null
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ $title = $sb.ToString()
|
|||
'-NonInteractive',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-Command', psScript,
|
||||
], { timeout: 3000 })
|
||||
], { timeout: 3000, windowsHide: true })
|
||||
|
||||
const lines = stdout.trim().split('\n')
|
||||
const appName = lines[0]?.trim() || null
|
||||
|
|
|
|||
1199
apps/desktop/src/main/services/SuggestionService.ts
Normal file
1199
apps/desktop/src/main/services/SuggestionService.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -146,7 +146,7 @@ class TTSPlaybackService extends EventEmitter {
|
|||
'-NonInteractive',
|
||||
'-Command',
|
||||
script,
|
||||
], { stdio: 'pipe' })
|
||||
], { stdio: 'pipe', windowsHide: true })
|
||||
|
||||
this._bindProcessHandlers(resolve, reject)
|
||||
})
|
||||
|
|
|
|||
214
apps/desktop/src/main/services/UiaContextService.ts
Normal file
214
apps/desktop/src/main/services/UiaContextService.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// src/main/services/UiaContextService.ts
|
||||
//
|
||||
// 사이드카의 UIA 브리지(`GET /uia/focus`)를 감싸는 유일한 창구.
|
||||
//
|
||||
// 사이드카인가:
|
||||
// Win32 케어렛 API(GetGUIThreadInfo)는 Chromium/Electron 에서 동작하지 않고,
|
||||
// Node UIA 바인딩(selection-hook / xa11y)은 케어 rect 나 IsPassword 를 노출하지
|
||||
// 않는다. 이미 배포 중인 Python 사이드카에 UIA 브리지를 두는 것이 검증된 유일한 경로다.
|
||||
//
|
||||
// 안전 규칙:
|
||||
// - 비밀번호 필드는 브리지가 fail-closed 로 차단하지만, 여기서도 한 번 더 막는다.
|
||||
// - 읽기 실패/타임아웃은 텍스트를 쓰지 않는다 (available=false).
|
||||
// - 실패 후에는 백오프를 걸어 매 키입력마다 사이드카를 두드리지 않는다.
|
||||
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import {
|
||||
emptyFocusSnapshot,
|
||||
type FocusSnapshot,
|
||||
type FocusTextSource,
|
||||
type UiRect
|
||||
} from '@d3ro/core/input-intelligence'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
|
||||
const logger = getLogger('UiaContextService')
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 1500
|
||||
/**
|
||||
* 실패 후 재시도 대기 (ms).
|
||||
*
|
||||
* 최초 실패는 대부분 "사이드카가 아직 포트를 열지 않음" 이다 — 실측: 부팅 0.87초 후 첫 실패,
|
||||
* 사이드카 준비는 1.1초 후였다. 20초를 통째로 백오프하면 앱을 켠 직후의 입력이 전부 죽으므로
|
||||
* 일시 실패는 짧게 시작해 배수로 늘리고, 브리지 자체가 없는 경우(미지원 플랫폼)만 길게 잡는다.
|
||||
*/
|
||||
const BACKOFF_STEPS_MS = [1500, 3000, 6000, 12000, 30000] as const
|
||||
/** 영구 실패(브리지 없음) 백오프 */
|
||||
const BACKOFF_PERMANENT_MS = 5 * 60 * 1000
|
||||
|
||||
interface UiaBridgePayload {
|
||||
available?: boolean
|
||||
reason?: string
|
||||
isPassword?: boolean
|
||||
isEditable?: boolean
|
||||
isComposing?: boolean
|
||||
hasSelection?: boolean
|
||||
controlType?: string | null
|
||||
controlName?: string | null
|
||||
className?: string | null
|
||||
textSource?: string
|
||||
text?: string
|
||||
caretOffset?: number | null
|
||||
caretRect?: UiRect | null
|
||||
elementRect?: UiRect | null
|
||||
windowTitle?: string | null
|
||||
processId?: number | null
|
||||
capturedAt?: number
|
||||
}
|
||||
|
||||
class UiaContextService {
|
||||
private _backoffUntil = 0
|
||||
/** 연속 실패 수 — 백오프 단계 계산용 */
|
||||
private _failureStreak = 0
|
||||
private _lastReason = ''
|
||||
private _inFlight: Promise<FocusSnapshot> | null = null
|
||||
private _last: FocusSnapshot | null = null
|
||||
private _lastSuccessAt = 0
|
||||
|
||||
get lastSnapshot(): FocusSnapshot | null {
|
||||
return this._last
|
||||
}
|
||||
|
||||
get lastSuccessAt(): number {
|
||||
return this._lastSuccessAt
|
||||
}
|
||||
|
||||
/** 브리지 사용 가능 여부 (백오프 상태면 false). */
|
||||
isAvailable(now = Date.now()): boolean {
|
||||
return now >= this._backoffUntil
|
||||
}
|
||||
|
||||
get lastReason(): string {
|
||||
return this._lastReason
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 포커스 입력 요소 스냅샷.
|
||||
*
|
||||
* 동시 호출은 하나의 요청으로 합친다 — 키 입력마다 부르므로 중복 방지가 중요하다.
|
||||
*/
|
||||
async getSnapshot(now = Date.now()): Promise<FocusSnapshot> {
|
||||
if (!this.isAvailable(now)) {
|
||||
return emptyFocusSnapshot(this._lastReason || 'backoff', now)
|
||||
}
|
||||
if (this._inFlight) return this._inFlight
|
||||
|
||||
this._inFlight = this._fetch(now)
|
||||
try {
|
||||
return await this._inFlight
|
||||
} finally {
|
||||
this._inFlight = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 테스트/설정 변경 시 상태 초기화. */
|
||||
reset(): void {
|
||||
this._backoffUntil = 0
|
||||
this._failureStreak = 0
|
||||
this._lastReason = ''
|
||||
}
|
||||
|
||||
private async _fetch(now: number): Promise<FocusSnapshot> {
|
||||
let baseUrl: string
|
||||
try {
|
||||
baseUrl = await getLocalSTTService().ensureSidecar()
|
||||
} catch (error) {
|
||||
return this._fail(
|
||||
`sidecar-unavailable:${error instanceof Error ? error.message : String(error)}`,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
let payload: UiaBridgePayload
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/uia/focus`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
})
|
||||
if (!response.ok) {
|
||||
return this._fail(`http-${response.status}`, now)
|
||||
}
|
||||
payload = (await response.json()) as UiaBridgePayload
|
||||
} catch (error) {
|
||||
return this._fail(
|
||||
`request-failed:${error instanceof Error ? error.message : String(error)}`,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
if (!payload.available) {
|
||||
// 브리지가 없는 환경(미지원 플랫폼/미설치)은 길게 백오프한다.
|
||||
return this._fail(payload.reason ?? 'unavailable', now)
|
||||
}
|
||||
|
||||
const snapshot: FocusSnapshot = {
|
||||
available: true,
|
||||
isPassword: payload.isPassword === true,
|
||||
isEditable: payload.isEditable === true,
|
||||
isComposing: payload.isComposing === true,
|
||||
hasSelection: payload.hasSelection === true,
|
||||
controlType: payload.controlType ?? undefined,
|
||||
controlName: payload.controlName ?? undefined,
|
||||
className: payload.className ?? undefined,
|
||||
textSource: normalizeTextSource(payload.textSource),
|
||||
text: typeof payload.text === 'string' ? payload.text : '',
|
||||
caretOffset: typeof payload.caretOffset === 'number' ? payload.caretOffset : null,
|
||||
caretRect: payload.caretRect ?? null,
|
||||
elementRect: payload.elementRect ?? null,
|
||||
windowTitle: payload.windowTitle ?? null,
|
||||
appName: null,
|
||||
processId: typeof payload.processId === 'number' ? payload.processId : null,
|
||||
capturedAt: typeof payload.capturedAt === 'number' ? payload.capturedAt : now
|
||||
}
|
||||
|
||||
// 2차 방어: 비밀번호로 판정되면 텍스트를 즉시 버린다 (fail-closed).
|
||||
if (snapshot.isPassword) {
|
||||
snapshot.text = ''
|
||||
snapshot.textSource = 'none'
|
||||
snapshot.caretOffset = null
|
||||
}
|
||||
|
||||
this._last = snapshot
|
||||
this._lastSuccessAt = now
|
||||
this._backoffUntil = 0
|
||||
this._lastReason = ''
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private _fail(reason: string, now: number): FocusSnapshot {
|
||||
this._lastReason = reason
|
||||
|
||||
// 영구적 사용 불가(미지원 플랫폼/미설치)는 길게, 일시 오류는 단계적으로.
|
||||
const permanent = reason === 'uiautomation-missing' || reason.startsWith('bridge-import')
|
||||
const waitMs = permanent
|
||||
? BACKOFF_PERMANENT_MS
|
||||
: BACKOFF_STEPS_MS[Math.min(this._failureStreak, BACKOFF_STEPS_MS.length - 1)]
|
||||
|
||||
this._failureStreak += 1
|
||||
this._backoffUntil = now + waitMs
|
||||
logger.warn(
|
||||
`UIA 스냅샷 사용 불가 (${reason}) — ${waitMs}ms 백오프 (연속 ${this._failureStreak}회)`
|
||||
)
|
||||
return emptyFocusSnapshot(reason, now)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTextSource(value: string | undefined): FocusTextSource {
|
||||
if (value === 'value' || value === 'text' || value === 'legacy') return value
|
||||
return 'none'
|
||||
}
|
||||
|
||||
let instance: UiaContextService | null = null
|
||||
|
||||
export function getUiaContextService(): UiaContextService {
|
||||
if (!instance) instance = new UiaContextService()
|
||||
return instance
|
||||
}
|
||||
|
||||
export function resetUiaContextServiceForTests(): void {
|
||||
instance = null
|
||||
}
|
||||
|
||||
/** 진단용 — D3ROError 로 감싼 브리지 오류 생성. */
|
||||
export function createUiaBridgeError(reason: string): D3ROError {
|
||||
return new D3ROError(ErrorCode.UiaBridgeUnavailable, `UIA bridge unavailable: ${reason}`)
|
||||
}
|
||||
|
|
@ -258,7 +258,7 @@ class VoiceActionService extends EventEmitter {
|
|||
private _openApp(appName: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cmd = `start "" "${appName}"`
|
||||
exec(cmd, { shell: 'cmd.exe' }, (err) => {
|
||||
exec(cmd, { shell: 'cmd.exe', windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
@ -277,7 +277,7 @@ class VoiceActionService extends EventEmitter {
|
|||
$wshell = New-Object -ComObject WScript.Shell
|
||||
$wshell.SendKeys([char]175)
|
||||
`
|
||||
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
|
||||
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
@ -288,7 +288,7 @@ class VoiceActionService extends EventEmitter {
|
|||
$wshell = New-Object -ComObject WScript.Shell
|
||||
$wshell.SendKeys([char]174)
|
||||
`
|
||||
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
|
||||
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
@ -299,7 +299,7 @@ class VoiceActionService extends EventEmitter {
|
|||
$wshell = New-Object -ComObject WScript.Shell
|
||||
$wshell.SendKeys([char]173)
|
||||
`
|
||||
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
|
||||
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
@ -318,7 +318,7 @@ class VoiceActionService extends EventEmitter {
|
|||
$wshell = New-Object -ComObject WScript.Shell
|
||||
$wshell.SendKeys('${sendKeysStr}')
|
||||
`
|
||||
exec(`powershell -NoProfile -Command "${script}"`, (err) => {
|
||||
exec(`powershell -NoProfile -Command "${script}"`, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
@ -336,7 +336,7 @@ class VoiceActionService extends EventEmitter {
|
|||
|
||||
private _runCommand(command: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(command, { timeout: 10000 }, (err) => {
|
||||
exec(command, { timeout: 10000, windowsHide: true }, (err) => {
|
||||
if (err) reject(err)
|
||||
else resolve()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ class VoiceConversationService extends EventEmitter {
|
|||
private _audioBuffers: Buffer[] = []
|
||||
private _audioListenerBound = false
|
||||
private _audioLevelListenerBound = false
|
||||
private _responseAbortController: AbortController | null = null
|
||||
|
||||
get state(): ConversationState {
|
||||
return this._state
|
||||
|
|
@ -105,6 +106,7 @@ class VoiceConversationService extends EventEmitter {
|
|||
if (!this._isActive) return
|
||||
|
||||
this._stopListening()
|
||||
this._responseAbortController?.abort()
|
||||
getTTSPlaybackService().stop()
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
|
||||
|
|
@ -128,9 +130,10 @@ class VoiceConversationService extends EventEmitter {
|
|||
* 현재 LLM 응답 또는 TTS 재생을 취소.
|
||||
*/
|
||||
cancelResponse(): void {
|
||||
this._responseAbortController?.abort()
|
||||
getPremiumLLMService().cancelGeneration()
|
||||
getTTSPlaybackService().stop()
|
||||
if (this._isActive) {
|
||||
if (this._isActive && this._state !== 'listening') {
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
}
|
||||
|
|
@ -238,6 +241,8 @@ class VoiceConversationService extends EventEmitter {
|
|||
const language = (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||
const result = await sttService.transcribe(audioBuffer, { language, vadFilter: true })
|
||||
|
||||
if (!this._isActive) return
|
||||
|
||||
if (!result.text || result.text.trim().length === 0) {
|
||||
// Bug 13: VAD가 전체 오디오를 무음 판정한 경우에도 사용자 피드백.
|
||||
this._emitError('stt', 'No speech detected. Check microphone and try again.')
|
||||
|
|
@ -248,6 +253,7 @@ class VoiceConversationService extends EventEmitter {
|
|||
|
||||
await this._processUserMessage(result.text.trim())
|
||||
} catch (err) {
|
||||
if (!this._isActive) return
|
||||
logger.error('STT failed in conversation:', err)
|
||||
this._emitError('stt', err instanceof Error ? err.message : 'STT failed')
|
||||
this._setState('listening')
|
||||
|
|
@ -256,6 +262,13 @@ class VoiceConversationService extends EventEmitter {
|
|||
}
|
||||
|
||||
private async _processUserMessage(text: string): Promise<void> {
|
||||
if (this._responseAbortController) {
|
||||
throw new D3ROError(ErrorCode.ConversationLLMFailed, 'Conversation response is already in progress')
|
||||
}
|
||||
|
||||
const responseAbortController = new AbortController()
|
||||
this._responseAbortController = responseAbortController
|
||||
|
||||
// 사용자 메시지 추가
|
||||
const userMsg: ConversationMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
|
|
@ -271,7 +284,7 @@ class VoiceConversationService extends EventEmitter {
|
|||
|
||||
try {
|
||||
// LLM 백엔드 선택: premium 설정 + 사용 가능 → PremiumLLM, 아니면 LocalLLM
|
||||
const { generator: chatGenerator, backend } = await this._createChatStream()
|
||||
const { generator: chatGenerator, backend } = await this._createChatStream(responseAbortController.signal)
|
||||
logger.info(`Conversation LLM backend: ${backend}`)
|
||||
|
||||
const assistantMsgId = crypto.randomUUID()
|
||||
|
|
@ -280,6 +293,8 @@ class VoiceConversationService extends EventEmitter {
|
|||
let sentenceBuffer = ''
|
||||
|
||||
for await (const token of chatGenerator) {
|
||||
if (!this._isCurrentResponse(responseAbortController)) return
|
||||
|
||||
accumulated += token
|
||||
|
||||
// 렌더러에 델타 전송
|
||||
|
|
@ -308,6 +323,8 @@ class VoiceConversationService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
if (!this._isCurrentResponse(responseAbortController)) return
|
||||
|
||||
// 남은 텍스트도 TTS 큐에 추가
|
||||
if (sentenceBuffer.trim()) {
|
||||
ttsSentences.push(sentenceBuffer.trim())
|
||||
|
|
@ -331,12 +348,16 @@ class VoiceConversationService extends EventEmitter {
|
|||
|
||||
// TTS 재생
|
||||
if (ttsSentences.length > 0) {
|
||||
if (!this._isCurrentResponse(responseAbortController)) return
|
||||
|
||||
this._setState('speaking')
|
||||
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED, {})
|
||||
|
||||
const ttsService = getTTSPlaybackService()
|
||||
await ttsService.speakSentences(ttsSentences)
|
||||
|
||||
if (!this._isCurrentResponse(responseAbortController)) return
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED, {})
|
||||
|
||||
// 응답 완료 chime (자동 listening 재진입 직전)
|
||||
|
|
@ -349,12 +370,18 @@ class VoiceConversationService extends EventEmitter {
|
|||
this._startListening()
|
||||
}
|
||||
} catch (err) {
|
||||
if (responseAbortController.signal.aborted) return
|
||||
|
||||
logger.error('LLM chat failed in conversation:', err)
|
||||
this._emitError('llm', err instanceof Error ? err.message : 'LLM failed')
|
||||
if (this._isActive) {
|
||||
this._setState('listening')
|
||||
this._startListening()
|
||||
}
|
||||
} finally {
|
||||
if (this._responseAbortController === responseAbortController) {
|
||||
this._responseAbortController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +389,7 @@ class VoiceConversationService extends EventEmitter {
|
|||
* LLM 백엔드 선택 + chatStream 생성.
|
||||
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback.
|
||||
*/
|
||||
private async _createChatStream(): Promise<{
|
||||
private async _createChatStream(signal: AbortSignal): Promise<{
|
||||
generator: AsyncGenerator<string, string>
|
||||
backend: 'local' | 'premium'
|
||||
}> {
|
||||
|
|
@ -389,7 +416,21 @@ class VoiceConversationService extends EventEmitter {
|
|||
'Local LLM (Ollama) is not available',
|
||||
)
|
||||
}
|
||||
return { generator: local.chatStream(chatMessages), backend: 'local' }
|
||||
return {
|
||||
generator: local.chatStream(chatMessages, {
|
||||
signal,
|
||||
maxTokens: 512,
|
||||
timeoutMs: 60_000,
|
||||
keepAlive: '2m',
|
||||
}),
|
||||
backend: 'local',
|
||||
}
|
||||
}
|
||||
|
||||
private _isCurrentResponse(responseAbortController: AbortController): boolean {
|
||||
return this._isActive
|
||||
&& this._responseAbortController === responseAbortController
|
||||
&& !responseAbortController.signal.aborted
|
||||
}
|
||||
|
||||
private _buildChatMessages(): Array<{ role: string; content: string }> {
|
||||
|
|
|
|||
69
apps/desktop/src/main/services/global-input-hook.ts
Normal file
69
apps/desktop/src/main/services/global-input-hook.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// src/main/services/global-input-hook.ts
|
||||
// uiohook 글로벌 후킹의 소유권을 참조 카운트로 관리한다.
|
||||
//
|
||||
// uiohook 은 프로세스 전역 싱글턴이다. KeyBindingService InputTelemetryService 가
|
||||
// 각자 start()/stop() 을 부르면 두 번째 start() 는 무시되거나, 먼저 끝난 쪽의 stop() 이
|
||||
// 상대방 수신까지 죽인다. 그래서 "후킹이 켜져 있어야 하는 소비자 수"를 세고
|
||||
// 0 이 될 때만 실제로 춘다.
|
||||
|
||||
import { uIOhook } from 'uiohook-napi'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('GlobalInputHook')
|
||||
|
||||
let refCount = 0
|
||||
let running = false
|
||||
|
||||
/**
|
||||
* 글로벌 후킹을 (필요하면) 시작하고 해제 함수를 돌려준다.
|
||||
*
|
||||
* 반드시 반환된 함수를 호출해야 한다. 시작에 실패하면 예외를 던지고 카운트를 되돌린다.
|
||||
*/
|
||||
export function acquireGlobalInputHook(): () => void {
|
||||
refCount += 1
|
||||
|
||||
if (!running) {
|
||||
try {
|
||||
uIOhook.start()
|
||||
running = true
|
||||
logger.info('uiohook started (global input hook active)')
|
||||
} catch (error) {
|
||||
refCount = Math.max(0, refCount - 1)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
refCount = Math.max(0, refCount - 1)
|
||||
|
||||
if (refCount === 0 && running) {
|
||||
try {
|
||||
uIOhook.stop()
|
||||
logger.info('uiohook stopped (no consumer left)')
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlobalInputHookRunning(): boolean {
|
||||
return running
|
||||
}
|
||||
|
||||
export function getGlobalInputHookRefCount(): number {
|
||||
return refCount
|
||||
}
|
||||
|
||||
/** 테스트 전용 — 모듈 상태 초기화. */
|
||||
export function resetGlobalInputHookForTests(): void {
|
||||
refCount = 0
|
||||
running = false
|
||||
}
|
||||
|
|
@ -143,3 +143,98 @@ export function resolveSystemPrompt(
|
|||
}
|
||||
|
||||
export { BASE_SYSTEM_PROMPTS }
|
||||
|
||||
// ============================================================
|
||||
// 다음 문장 제안 (ghost text) — 프롬프트 정본
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 추론 모델이 `thinking` 블록을 지 않게 하는 prefix.
|
||||
*
|
||||
* `LocalLLMService.processText` 가 같은 목적으로 쓰는 값과 같다. 제안 경로는
|
||||
* `streamGenerate` 를 직접 부르므로 여기서 직접 붙인다.
|
||||
*/
|
||||
export const SUGGESTION_NO_THINK_PREFIX = '/no_think'
|
||||
|
||||
/**
|
||||
* 제안 시스템 프롬프트.
|
||||
*
|
||||
* 지시문은 **시스템 프롬프트에만** 둔다. 지시문이 처리 대상 텍스트 자리에 들어가
|
||||
* 지시문 자체를 다듬어 돌려주던 회귀(밋 9c2b4d4)와 같은 부류의 사고를 막는다.
|
||||
*/
|
||||
const SUGGESTION_SYSTEM_PROMPT = `사용자가 지금 입력창에 글을 쓰는 중입니다. 사용자가 마지막으로 쓴 글 뒤에 이어질 다음 문장을 제안하세요.
|
||||
|
||||
규칙:
|
||||
- 사용자가 언어와 같은 언어로 쓰세요.
|
||||
- 사용자의 말투와 문체를 유지하세요. 존댓말/반말, 격식/비격식을 바꾸지 마세요.
|
||||
- 이미 나온 단어를 되풀이하지 말고 이어지는 내용만 쓰세요.
|
||||
- 후보를 한 줄에 하나씩, 번호나 따옴표, 설명 없이 출력하세요.
|
||||
- 각 후보는 한 문장, 최대 {{maxChars}}자입니다.
|
||||
- 확신이 없으면 짧게 쓰세요. 리말, 요약, 빈 줄을 출력하지 마세요.`
|
||||
|
||||
export interface SuggestionPromptInput {
|
||||
/** 어렛 앞까지의 텍스트 */
|
||||
prefix: string
|
||||
/** 활성 앱/창 (맥락 트) */
|
||||
appName?: string | null
|
||||
windowTitle?: string | null
|
||||
/** 사용자가 자주 쓰는 표현 (개인화 힌트) */
|
||||
phraseHints?: readonly string[]
|
||||
/**
|
||||
* 과거에 같은 꼬리 뒤에 실제로 이어 쓴 문장 (개인 기억).
|
||||
*
|
||||
* 자주 쓰는 표현보다 훨씬 강한 문맥 신호다 — 사용자 자신의 실제 이어쓰기다.
|
||||
*/
|
||||
continuationHints?: readonly string[]
|
||||
/** 후보 개수 */
|
||||
candidates?: number
|
||||
/** 후보당 최대 길이 */
|
||||
maxChars?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 제안 요청용 (systemPrompt, text) 를 만든다.
|
||||
*
|
||||
* 시스템 프롬프트에 지시문, 사용자 메시지에는 **지금까지 쓴 텍스트와 맥락**만 는다.
|
||||
*/
|
||||
export function buildSuggestionPrompt(input: SuggestionPromptInput): {
|
||||
systemPrompt: string
|
||||
text: string
|
||||
} {
|
||||
const candidates = Math.max(1, Math.min(input.candidates ?? 3, 5))
|
||||
const maxChars = Math.max(20, Math.min(input.maxChars ?? 160, 400))
|
||||
|
||||
const sections: string[] = ['[지금까지 텍스트]', input.prefix]
|
||||
|
||||
if (input.phraseHints && input.phraseHints.length > 0) {
|
||||
sections.push('', '[자주 쓰는 표현]')
|
||||
for (const hint of input.phraseHints.slice(0, 5)) {
|
||||
sections.push(`- ${hint}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.appName || input.windowTitle) {
|
||||
sections.push('', '[현재 앱]')
|
||||
const app = [input.appName, input.windowTitle].filter(Boolean).join(' — ')
|
||||
sections.push(app)
|
||||
}
|
||||
|
||||
if (input.continuationHints && input.continuationHints.length > 0) {
|
||||
sections.push('', '[과거에 사용자가 비슷한 문장 뒤에 실제로 이어 쓴 내용]')
|
||||
for (const hint of input.continuationHints.slice(0, 3)) {
|
||||
sections.push(`- ${hint}`)
|
||||
}
|
||||
sections.push('위 내용은 문체와 맥락 참고용입니다. 그대로 복사하지 말고 이어질 문장을 새로 쓰세요.')
|
||||
}
|
||||
|
||||
sections.push('', `이어질 다음 문장 ${candidates}개를 한 줄씩 출력하세요.`)
|
||||
|
||||
const systemPrompt = `${SUGGESTION_NO_THINK_PREFIX}\n${SUGGESTION_SYSTEM_PROMPT.replace(
|
||||
'{{maxChars}}',
|
||||
String(maxChars)
|
||||
)}`
|
||||
|
||||
return { systemPrompt, text: sections.join('\n') }
|
||||
}
|
||||
|
||||
export { SUGGESTION_SYSTEM_PROMPT }
|
||||
|
|
|
|||
180
apps/desktop/src/main/utils/win32-foreground.ts
Normal file
180
apps/desktop/src/main/utils/win32-foreground.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
// src/main/utils/win32-foreground.ts
|
||||
//
|
||||
// koffi(FFI)로 포그라운드 창 정보를 읽는다 — 창 제목 · 프로세스 · 실행 파일 · 창 rect.
|
||||
//
|
||||
// 왜 koffi 인가 (조사 결과):
|
||||
// - `uiohook-napi` 는 키/마우스만 주고 포그라운드 창 정보는 없다.
|
||||
// - `get-windows`(active-win 후속)는 ESM 전용 + 설치 시 node-pre-gyp 다운로드가
|
||||
// 필요한데 이 저장소는 install script 를 허용하지 않는다 (빌드가 깨진다).
|
||||
// - `koffi` 3.3.1 은 N-API 8 prebuild 를 optional dep 로 배포해 install script 없이
|
||||
// 동작한다. User32/Kernel32 호출만 쓰므로 COM vtable 을 다 필요도 없다.
|
||||
//
|
||||
// 실패(비 Windows, 모듈 로드 실패)는 예외 대신 null 로 돌려준다 — 호출자는
|
||||
// 앱 이름 없이도 텔레메트리를 계속 수집할 수 있어야 한다.
|
||||
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type { UiRect } from '@d3ro/core/input-intelligence'
|
||||
|
||||
const logger = getLogger('win32-foreground')
|
||||
|
||||
export interface ForegroundWindowInfo {
|
||||
/** HWND (숫자로 정규화) */
|
||||
hwnd: number
|
||||
title: string
|
||||
processId: number
|
||||
/** 실행 파일명 (예: chrome.exe). 알 수 없으면 '' */
|
||||
appName: string
|
||||
/** 실행 파일 전체 경로 */
|
||||
appPath: string | null
|
||||
/** 창 rect (스크린 좌표) */
|
||||
bounds: UiRect | null
|
||||
}
|
||||
|
||||
interface KoffiRect {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
type AnyFunc = (...args: unknown[]) => unknown
|
||||
|
||||
interface Win32Api {
|
||||
koffi: { decode: (buffer: Buffer, type: string, length: number) => string }
|
||||
GetForegroundWindow: AnyFunc
|
||||
GetWindowTextW: AnyFunc
|
||||
GetWindowThreadProcessId: AnyFunc
|
||||
GetWindowRect: AnyFunc
|
||||
OpenProcess: AnyFunc
|
||||
QueryFullProcessImageNameW: AnyFunc
|
||||
CloseHandle: AnyFunc
|
||||
}
|
||||
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
const TITLE_BUFFER_CHARS = 512
|
||||
const PATH_BUFFER_CHARS = 1024
|
||||
|
||||
let api: Win32Api | null = null
|
||||
let loadFailed = false
|
||||
|
||||
function loadApi(): Win32Api | null {
|
||||
if (api) return api
|
||||
if (loadFailed) return null
|
||||
if (process.platform !== 'win32') {
|
||||
loadFailed = true
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const koffi = require('koffi') as {
|
||||
load: (lib: string) => { func: (signature: string) => AnyFunc }
|
||||
struct: (name: string, fields: Record<string, string>) => unknown
|
||||
decode: (buffer: Buffer, type: string, length: number) => string
|
||||
}
|
||||
const user32 = koffi.load('user32.dll')
|
||||
const kernel32 = koffi.load('kernel32.dll')
|
||||
koffi.struct('RECT', { left: 'long', top: 'long', right: 'long', bottom: 'long' })
|
||||
|
||||
api = {
|
||||
koffi: { decode: koffi.decode },
|
||||
GetForegroundWindow: user32.func('void *GetForegroundWindow()'),
|
||||
GetWindowTextW: user32.func(
|
||||
'int GetWindowTextW(void *hWnd, _Out_ uint16_t *lpString, int nMaxCount)'
|
||||
),
|
||||
GetWindowThreadProcessId: user32.func(
|
||||
'uint32_t GetWindowThreadProcessId(void *hWnd, _Out_ uint32_t *lpdwProcessId)'
|
||||
),
|
||||
GetWindowRect: user32.func('bool GetWindowRect(void *hWnd, _Out_ RECT *rect)'),
|
||||
OpenProcess: kernel32.func('void *OpenProcess(uint32_t access, bool inherit, uint32_t pid)'),
|
||||
QueryFullProcessImageNameW: kernel32.func(
|
||||
'bool QueryFullProcessImageNameW(void *hProcess, uint32_t flags, _Out_ uint16_t *lpExeName, _Inout_ uint32_t *size)'
|
||||
),
|
||||
CloseHandle: kernel32.func('bool CloseHandle(void *handle)')
|
||||
}
|
||||
return api
|
||||
} catch (error) {
|
||||
loadFailed = true
|
||||
logger.warn(
|
||||
`koffi/Win32 FFI 사용 불가 — 포그라운드 정보 없이 계속 (${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
})`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 포그라운드 창 정보. Windows 가 아니거나 FFI 로드 실패면 null. */
|
||||
export function getForegroundWindowInfo(): ForegroundWindowInfo | null {
|
||||
const a = loadApi()
|
||||
if (!a) return null
|
||||
|
||||
try {
|
||||
const hwndRaw = a.GetForegroundWindow() as bigint | number | null
|
||||
if (hwndRaw === null) return null
|
||||
const hwnd = Number(hwndRaw)
|
||||
if (!Number.isFinite(hwnd) || hwnd === 0) return null
|
||||
|
||||
const titleBuffer = Buffer.alloc(TITLE_BUFFER_CHARS * 2)
|
||||
const titleLength = Number(a.GetWindowTextW(hwnd, titleBuffer, TITLE_BUFFER_CHARS) as number)
|
||||
const title =
|
||||
titleLength > 0
|
||||
? a.koffi.decode(titleBuffer, 'char16_t', titleLength).replace(/\0.*$/su, '')
|
||||
: ''
|
||||
|
||||
const pidBuffer = Buffer.alloc(4)
|
||||
a.GetWindowThreadProcessId(hwnd, pidBuffer)
|
||||
const processId = pidBuffer.readUInt32LE(0)
|
||||
|
||||
const rect: KoffiRect = { left: 0, top: 0, right: 0, bottom: 0 }
|
||||
const rectOk = a.GetWindowRect(hwnd, rect) as boolean
|
||||
const bounds: UiRect | null = rectOk
|
||||
? {
|
||||
x: Number(rect.left),
|
||||
y: Number(rect.top),
|
||||
width: Number(rect.right) - Number(rect.left),
|
||||
height: Number(rect.bottom) - Number(rect.top)
|
||||
}
|
||||
: null
|
||||
|
||||
let appPath: string | null = null
|
||||
const processHandle = a.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, processId) as
|
||||
| bigint
|
||||
| number
|
||||
| null
|
||||
if (processHandle) {
|
||||
try {
|
||||
const sizeBuffer = Buffer.alloc(4)
|
||||
sizeBuffer.writeUInt32LE(PATH_BUFFER_CHARS, 0)
|
||||
const pathBuffer = Buffer.alloc(PATH_BUFFER_CHARS * 2)
|
||||
const pathOk = a.QueryFullProcessImageNameW(
|
||||
processHandle,
|
||||
0,
|
||||
pathBuffer,
|
||||
sizeBuffer
|
||||
) as boolean
|
||||
if (pathOk) {
|
||||
const length = sizeBuffer.readUInt32LE(0)
|
||||
appPath = a.koffi.decode(pathBuffer, 'char16_t', length).replace(/\0.*$/su, '')
|
||||
}
|
||||
} finally {
|
||||
a.CloseHandle(processHandle)
|
||||
}
|
||||
}
|
||||
|
||||
const appName = appPath ? (appPath.split(/[\\/]/u).pop() ?? '') : ''
|
||||
|
||||
return { hwnd, title, processId, appName, appPath, bounds }
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`포그라운드 창 조회 실패: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 테스트 전용 — 지연 로드 상태 초기화. */
|
||||
export function resetWin32ForegroundForTests(): void {
|
||||
api = null
|
||||
loadFailed = false
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { BrowserWindow, shell, screen, ipcMain, Menu, clipboard } from 'electron
|
|||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { WINDOW_SIZE } from '@d3ro/core/constants'
|
||||
import { anchorFloatingPanel } from '@d3ro/core/input-intelligence'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import { getIsQuitting } from '../lifecycle'
|
||||
|
|
@ -59,6 +60,19 @@ function getPopupI18nStrings(): Record<string, string> {
|
|||
errorDefault: t('popup.error.default'),
|
||||
// caption-overlay
|
||||
captionLoading: t('popup.caption.loading'),
|
||||
// suggestion-overlay
|
||||
suggestionHintAccept: t('popup.suggestion.hintAccept'),
|
||||
suggestionHintNext: t('popup.suggestion.hintNext'),
|
||||
suggestionHintDismiss: t('popup.suggestion.hintDismiss'),
|
||||
suggestionWarming: t('popup.suggestion.warming'),
|
||||
suggestionHintGenerating: t('popup.suggestion.hintGenerating'),
|
||||
suggestionLoading: t('popup.suggestion.loading'),
|
||||
suggestionSourceModel: t('popup.suggestion.sourceModel'),
|
||||
suggestionSourceMemory: t('popup.suggestion.sourceMemory'),
|
||||
suggestionContinuations: t('popup.suggestion.continuations'),
|
||||
suggestionRelated: t('popup.suggestion.related'),
|
||||
suggestionPhrases: t('popup.suggestion.phrases'),
|
||||
suggestionAppPhrases: t('popup.suggestion.appPhrases'),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +177,7 @@ let resultPopupWindow: BrowserWindow | null = null
|
|||
let historyPopupWindow: BrowserWindow | null = null
|
||||
let commandPopupWindow: BrowserWindow | null = null
|
||||
let captionOverlayWindow: BrowserWindow | null = null
|
||||
let suggestionOverlayWindow: BrowserWindow | null = null
|
||||
|
||||
// ── 메인 윈도우 ───────────────────────────────────────
|
||||
|
||||
|
|
@ -706,7 +721,177 @@ export function sendToCaptionOverlay(channel: string, data: unknown): void {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 프리로딩 ──────────────────────────────────────────
|
||||
// ── SuggestionOverlay 업 (입력 인텔리전스) ──────────
|
||||
|
||||
/**
|
||||
* 다음 문장 제안 ghost text 오버레이.
|
||||
*
|
||||
* IME 후보창과 같은 관례를 따른다: 케어/포커스 요소 바로 아래에 붙고,
|
||||
* 포커스를 막지 않으며(focusable:false) 기본은 클릭 통과다.
|
||||
* (KeyType.Windows 의 WS_EX_TRANSPARENT|WS_EX_NOACTIVATE 오버레이와 동일한 전략)
|
||||
*/
|
||||
const SUGGESTION_OVERLAY_WIDTH = 460
|
||||
/** 후보 5개 + 스크롤을 담을 높이 (목록은 내부 스크롤) */
|
||||
const SUGGESTION_OVERLAY_HEIGHT = 258
|
||||
|
||||
function applySuggestionOverlayMouseMode(win: BrowserWindow): void {
|
||||
const interactive = configGet('suggestionOverlayInteractive') !== false
|
||||
try {
|
||||
if (interactive) {
|
||||
// 클릭을 받아 수락할 수 있게 한다. focusable:false 라 대상 앱 포커스는 유지된다.
|
||||
win.setIgnoreMouseEvents(false)
|
||||
} else {
|
||||
win.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`SuggestionOverlay 마우스 모드 적용 실패: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createSuggestionOverlayWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: SUGGESTION_OVERLAY_WIDTH,
|
||||
height: SUGGESTION_OVERLAY_HEIGHT,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/popup.js'),
|
||||
sandbox: false,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
applySuggestionOverlayMouseMode(win)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/popups/suggestion-overlay/index.html`)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/popups/suggestion-overlay/index.html'))
|
||||
}
|
||||
|
||||
attachPopupLifecycle(win, 'suggestion-overlay')
|
||||
|
||||
win.on('closed', () => {
|
||||
suggestionOverlayWindow = null
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function getSuggestionOverlayWindow(): BrowserWindow {
|
||||
if (!suggestionOverlayWindow || suggestionOverlayWindow.isDestroyed()) {
|
||||
suggestionOverlayWindow = createSuggestionOverlayWindow()
|
||||
logger.info('SuggestionOverlay window created')
|
||||
}
|
||||
return suggestionOverlayWindow
|
||||
}
|
||||
|
||||
/** 제안 오버레이 표시 — 커(케어 → 요소 → 커서) 기준 배치. */
|
||||
export function showSuggestionOverlay(payload: {
|
||||
candidates: Array<{ text: string; rank: number }>
|
||||
activeIndex: number
|
||||
/** 후보 도착 전(생성 중) — 팝업이 로딩 행을 보여준다 */
|
||||
generating?: boolean
|
||||
/** 모델 적재 중 — 팝업이 "준비 중" 을 보여준다 */
|
||||
warmingUp?: boolean
|
||||
/** 스트리밍 중 부분 텍스트 */
|
||||
partialText?: string | null
|
||||
provenance?: {
|
||||
mode: 'local-model' | 'local-memory'
|
||||
continuationCount: number
|
||||
relatedCount: number
|
||||
phraseCount: number
|
||||
appPhraseCount: number
|
||||
} | null
|
||||
anchor: { x: number; y: number; width: number; height: number } | null
|
||||
appName: string | null
|
||||
}): void {
|
||||
const win = getSuggestionOverlayWindow()
|
||||
const cursor = screen.getCursorScreenPoint()
|
||||
const anchorPoint = payload.anchor
|
||||
? { x: payload.anchor.x, y: payload.anchor.y + payload.anchor.height }
|
||||
: cursor
|
||||
const display = screen.getDisplayNearestPoint(anchorPoint)
|
||||
|
||||
const position = anchorFloatingPanel(
|
||||
payload.anchor,
|
||||
cursor,
|
||||
{ width: SUGGESTION_OVERLAY_WIDTH, height: SUGGESTION_OVERLAY_HEIGHT },
|
||||
display.workArea
|
||||
)
|
||||
|
||||
win.setBounds({
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: SUGGESTION_OVERLAY_WIDTH,
|
||||
height: SUGGESTION_OVERLAY_HEIGHT
|
||||
})
|
||||
|
||||
sendToPopupWindow(win, IPC_CHANNELS.POPUP_SUGGESTION.SHOW, {
|
||||
candidates: payload.candidates,
|
||||
activeIndex: payload.activeIndex,
|
||||
generating: payload.generating === true,
|
||||
warmingUp: payload.warmingUp === true,
|
||||
partialText: payload.partialText ?? null,
|
||||
appName: payload.appName,
|
||||
provenance: payload.provenance ?? null,
|
||||
_i18n: getPopupI18nStrings()
|
||||
})
|
||||
|
||||
presentPopup(win, 'screen-saver')
|
||||
}
|
||||
|
||||
export function updateSuggestionOverlay(payload: {
|
||||
candidates: Array<{ text: string; rank: number }>
|
||||
activeIndex: number
|
||||
generating?: boolean
|
||||
warmingUp?: boolean
|
||||
partialText?: string | null
|
||||
provenance?: {
|
||||
mode: 'local-model' | 'local-memory'
|
||||
continuationCount: number
|
||||
relatedCount: number
|
||||
phraseCount: number
|
||||
appPhraseCount: number
|
||||
} | null
|
||||
}): void {
|
||||
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
|
||||
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.UPDATE, payload)
|
||||
}
|
||||
}
|
||||
|
||||
export function hideSuggestionOverlay(): void {
|
||||
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
|
||||
sendToPopupWindow(suggestionOverlayWindow, IPC_CHANNELS.POPUP_SUGGESTION.HIDE, {})
|
||||
suggestionOverlayWindow.hide()
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuggestionOverlayVisible(): boolean {
|
||||
return !!(
|
||||
suggestionOverlayWindow &&
|
||||
!suggestionOverlayWindow.isDestroyed() &&
|
||||
suggestionOverlayWindow.isVisible()
|
||||
)
|
||||
}
|
||||
|
||||
/** 설정(클릭 허용) 변경 시 즉시 반영. */
|
||||
export function applySuggestionOverlayConfig(): void {
|
||||
if (suggestionOverlayWindow && !suggestionOverlayWindow.isDestroyed()) {
|
||||
applySuggestionOverlayMouseMode(suggestionOverlayWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// ─ 프리로딩 ──────────────────────────────────────────
|
||||
|
||||
export function preloadPopupWindows(): void {
|
||||
getRecordingTipWindow()
|
||||
|
|
@ -716,7 +901,7 @@ export function preloadPopupWindows(): void {
|
|||
logger.info('Popup windows preloaded')
|
||||
}
|
||||
|
||||
// ── 테마 재주입 (설정에서 테마 변경 시 호출) ──────────
|
||||
// ── 테마 재주입 (설정에서 테마 변경 시 호출) ───────────
|
||||
|
||||
/**
|
||||
* 현재 살아있는 팝업 윈도우에 테마 CSS를 재주입.
|
||||
|
|
@ -729,6 +914,7 @@ export function reapplyThemeToAllPopups(): void {
|
|||
historyPopupWindow,
|
||||
commandPopupWindow,
|
||||
captionOverlayWindow,
|
||||
suggestionOverlayWindow,
|
||||
]
|
||||
for (const win of popupWindows) {
|
||||
if (win && !win.isDestroyed()) {
|
||||
|
|
|
|||
|
|
@ -882,6 +882,114 @@ const electronAPI = {
|
|||
getSubscriptionStatus: () =>
|
||||
invoke<import('@d3ro/core/types').SubscriptionStatusResult>(IPC_CHANNELS.PAYMENT.GET_SUBSCRIPTION_STATUS),
|
||||
},
|
||||
|
||||
// ── Input telemetry (입력 수집 동의 · 리포트) ───────────
|
||||
inputTelemetry: {
|
||||
getState: () =>
|
||||
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_STATE
|
||||
),
|
||||
setEnabled: (params: { enabled: boolean }) =>
|
||||
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED,
|
||||
params
|
||||
),
|
||||
setPaused: (params: { paused: boolean }) =>
|
||||
invoke<import('@d3ro/core/input-intelligence').InputTelemetryState>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED,
|
||||
params
|
||||
),
|
||||
getSummary: (params?: { days?: number }) =>
|
||||
invoke<import('@d3ro/core/input-intelligence').InputInsightsSummary>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_SUMMARY,
|
||||
params
|
||||
),
|
||||
getPrivacyReceipt: () =>
|
||||
invoke<import('@d3ro/core/input-intelligence').InputPrivacyReceipt>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_PRIVACY_RECEIPT
|
||||
),
|
||||
getPhrases: (params?: { limit?: number }) =>
|
||||
invoke<import('@d3ro/core/input-intelligence').PersonalPhrase[]>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_PHRASES,
|
||||
params
|
||||
),
|
||||
deletePhrase: (params: { id: string }) =>
|
||||
invoke<boolean>(IPC_CHANNELS.INPUT_TELEMETRY.DELETE_PHRASE, params),
|
||||
clearAll: () => invoke<void>(IPC_CHANNELS.INPUT_TELEMETRY.CLEAR_ALL),
|
||||
getGraph: () =>
|
||||
invoke<import('@d3ro/core/personal-graph').PersonalGraphStats>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.GET_GRAPH
|
||||
),
|
||||
queryGraph: (params: { text: string; limit?: number }) =>
|
||||
invoke<import('@d3ro/core/personal-graph').PersonalGraphQuery>(
|
||||
IPC_CHANNELS.INPUT_TELEMETRY.QUERY_GRAPH,
|
||||
params
|
||||
),
|
||||
onStateChanged: (
|
||||
cb: (state: import('@d3ro/core/input-intelligence').InputTelemetryState) => void
|
||||
): Unsubscribe => on(IPC_CHANNELS.INPUT_TELEMETRY.STATE_CHANGED, cb),
|
||||
onActivity: (
|
||||
cb: (payload: {
|
||||
totals: import('@d3ro/core/input-intelligence').InputActivityBucket
|
||||
appName: string | null
|
||||
}) => void
|
||||
): Unsubscribe => on(IPC_CHANNELS.INPUT_TELEMETRY.ACTIVITY, cb),
|
||||
},
|
||||
|
||||
// ── 다음 문장 제안 (ghost text) ────────────────────────
|
||||
suggestion: {
|
||||
getState: () =>
|
||||
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
|
||||
IPC_CHANNELS.SUGGESTION.GET_STATE
|
||||
),
|
||||
setConfig: (params: {
|
||||
enabled?: boolean
|
||||
modelId?: string | null
|
||||
triggerDelayMs?: number
|
||||
minPrefixChars?: number
|
||||
maxRequestsPerMinute?: number
|
||||
dailyBudget?: number
|
||||
overlayInteractive?: boolean
|
||||
learnTypedText?: boolean
|
||||
excludedApps?: string[]
|
||||
requestTimeoutMs?: number
|
||||
}) =>
|
||||
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
|
||||
IPC_CHANNELS.SUGGESTION.SET_CONFIG,
|
||||
params
|
||||
),
|
||||
requestNow: () => invoke<{ ok: boolean; reason?: string }>(IPC_CHANNELS.SUGGESTION.REQUEST_NOW),
|
||||
accept: (params?: { index?: number }) =>
|
||||
invoke<{ ok: boolean; reason?: string }>(IPC_CHANNELS.SUGGESTION.ACCEPT, params),
|
||||
next: () =>
|
||||
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
|
||||
IPC_CHANNELS.SUGGESTION.NEXT
|
||||
),
|
||||
prev: () =>
|
||||
invoke<import('@d3ro/core/input-intelligence').SuggestionState>(
|
||||
IPC_CHANNELS.SUGGESTION.PREV
|
||||
),
|
||||
dismiss: () => invoke<void>(IPC_CHANNELS.SUGGESTION.DISMISS),
|
||||
getHistory: (params?: { limit?: number }) =>
|
||||
invoke<
|
||||
Array<{
|
||||
id: string
|
||||
appName: string | null
|
||||
prefixText: string
|
||||
suggestionText: string
|
||||
model: string | null
|
||||
latencyMs: number | null
|
||||
accepted: boolean
|
||||
createdAt: number
|
||||
}>
|
||||
>(IPC_CHANNELS.SUGGESTION.GET_HISTORY, params),
|
||||
onUpdated: (cb: (state: import('@d3ro/core/input-intelligence').SuggestionState) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.SUGGESTION.UPDATED, cb),
|
||||
onCleared: (cb: (payload: { reason: string }) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.SUGGESTION.CLEARED, cb),
|
||||
onStateChanged: (cb: (state: import('@d3ro/core/input-intelligence').SuggestionState) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, cb),
|
||||
},
|
||||
} as const
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/SettingsModal.tsx
|
||||
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
|
|
@ -22,19 +22,21 @@ import {
|
|||
Button,
|
||||
Paper,
|
||||
} from '@mui/material'
|
||||
import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
|
||||
import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, TextCursorInput } from 'lucide-react'
|
||||
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import { LicenseTab } from './LicenseTab'
|
||||
import { CloudSyncSection } from './CloudSyncSection'
|
||||
import { STTTab } from './STTTab'
|
||||
import { InputConsentPanel } from './input-insights/InputConsentPanel'
|
||||
import { KeyBindingField } from './keybinding/KeyBindingField'
|
||||
import { asTranslationKey } from './keybinding/translation-key'
|
||||
import { useI18n, LOCALE_META } from '@d3ro/i18n'
|
||||
import type { Locale } from '@d3ro/i18n'
|
||||
import type { KeyBindingActionGroup } from '@d3ro/core/keybinding'
|
||||
import { KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
|
||||
import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding'
|
||||
import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
|
||||
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
|
|
@ -57,9 +59,10 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
|
|||
const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> = {
|
||||
voice: 'keybinding.ui.sectionVoice',
|
||||
window: 'keybinding.ui.sectionWindow',
|
||||
input: 'keybinding.ui.sectionInput',
|
||||
}
|
||||
|
||||
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window']
|
||||
const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input']
|
||||
|
||||
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
|
|
@ -67,6 +70,11 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const keybindingMap = useKeyBindingMap()
|
||||
const keybindingIssues = useMemo(
|
||||
() => (keybindingMap === null ? [] : auditKeyBindingMap(keybindingMap)),
|
||||
[keybindingMap]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialTab !== undefined) {
|
||||
|
|
@ -229,6 +237,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.stt')} />
|
||||
<Tab label={t('settings.tabs.llm')} />
|
||||
<Tab label={t('input.tab')} icon={<TextCursorInput size={14} />} iconPosition="start" />
|
||||
<Tab label={t('license.nav')} icon={<Lock size={14} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.cloud') ?? 'Cloud'} icon={<Cloud size={14} />} iconPosition="start" />
|
||||
<Tab label={t('settings.tabs.about')} />
|
||||
|
|
@ -259,6 +268,45 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${keybindingIssues.length === 0 ? d3roPalette.border.subtle : d3roPalette.status.warning}`,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5
|
||||
}}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Typography sx={{ fontSize: d3roTypo.label.size, color: d3roPalette.text.secondary }}>
|
||||
{keybindingIssues.length === 0
|
||||
? t('keybinding.ui.auditClear')
|
||||
: t('keybinding.ui.auditIssues', { count: keybindingIssues.length })}
|
||||
</Typography>
|
||||
{keybindingIssues.slice(0, 3).map((issue) => {
|
||||
const action = KEYBINDING_ACTIONS.find((entry) => entry.id === issue.actionId)
|
||||
const actionLabel = action ? t(asTranslationKey(action.labelKey)) : issue.actionId
|
||||
const conflictLabels = issue.conflictActionIds
|
||||
.map((actionId) => {
|
||||
const conflict = KEYBINDING_ACTIONS.find((entry) => entry.id === actionId)
|
||||
return conflict ? t(asTranslationKey(conflict.labelKey)) : actionId
|
||||
})
|
||||
.join(', ')
|
||||
return (
|
||||
<Typography key={`${issue.actionId}-${issue.bindingIndex}-${issue.kind}`} sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.text.secondary }}>
|
||||
{issue.kind === 'invalid'
|
||||
? t('keybinding.ui.auditInvalid', {
|
||||
action: actionLabel,
|
||||
reason: issue.reasonKey ? t(asTranslationKey(issue.reasonKey)) : t('keybinding.ui.auditUnknown')
|
||||
})
|
||||
: t('keybinding.ui.auditConflict', { action: actionLabel, conflicts: conflictLabels })}
|
||||
</Typography>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{ACTION_GROUP_ORDER.map((group) => {
|
||||
const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group)
|
||||
if (actions.length === 0) return null
|
||||
|
|
@ -718,16 +766,20 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
|
|||
|
||||
{/* ── 라이선스 탭 ────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<InputConsentPanel />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<LicenseTab />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── Cloud Sync 탭 ──────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={5}>
|
||||
<TabPanel value={activeTab} index={6}>
|
||||
<CloudSyncSection />
|
||||
</TabPanel>
|
||||
|
||||
{/* ── 정보 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={6}>
|
||||
<TabPanel value={activeTab} index={7}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
D3RO-VOICE
|
||||
|
|
|
|||
163
apps/desktop/src/renderer/components/input-insights/BarChart.tsx
Normal file
163
apps/desktop/src/renderer/components/input-insights/BarChart.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// src/renderer/components/input-insights/BarChart.tsx
|
||||
// 입력 통계용 막대 그래프.
|
||||
//
|
||||
// 이전 구현은 flex 비율 + % 높이만 써서, 데이터가 1~2일치일 때 막대 하나가 화면을
|
||||
// 가득 채우는 사각형이 됐고 축/라벨도 없어 "그래프"로 읽을 수 없었다. 그래서
|
||||
// - 막대 폭을 고정 상한(24px)으로 두어 데이터가 적어도 거대해지지 않게 하고
|
||||
// - 값 축(최대값)과 라벨 행을 항상 함께 그리며
|
||||
// - 값이 0 뿐이면 그래프 대신 빈 상태 문구를 보여준다.
|
||||
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
|
||||
export interface BarDatum {
|
||||
/** x축 라벨 */
|
||||
label: string
|
||||
value: number
|
||||
/** 툴팁/접근성 텍스트 */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface BarChartProps {
|
||||
data: readonly BarDatum[]
|
||||
/** 그래프 높이 (px) */
|
||||
height?: number
|
||||
/** 값 포맷 (기본: 정수) */
|
||||
formatValue?: (value: number) => string
|
||||
/** 라벨을 몇 개마다 표시할지 */
|
||||
labelEvery?: number
|
||||
accent?: string
|
||||
}
|
||||
|
||||
export function BarChart({
|
||||
data,
|
||||
height = 110,
|
||||
formatValue = (value) => value.toLocaleString(),
|
||||
labelEvery = 1,
|
||||
accent = d3roPalette.accent.main
|
||||
}: BarChartProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const max = Math.max(1, ...data.map((item) => item.value))
|
||||
const hasData = data.some((item) => item.value > 0)
|
||||
|
||||
if (data.length === 0 || !hasData) {
|
||||
return (
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.inactive, py: 2 }}>
|
||||
{t('input.chart.noData')}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.chart.max', { value: formatValue(max) })}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: '2px',
|
||||
height,
|
||||
px: 0.25,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`
|
||||
}}
|
||||
>
|
||||
{data.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.label}-${index}`}
|
||||
title={item.hint ?? `${item.label}: ${formatValue(item.value)}`}
|
||||
sx={{
|
||||
flex: '0 1 24px',
|
||||
minWidth: item.value > 0 ? '5px' : '2px',
|
||||
height: `${Math.max(2, (item.value / max) * 100)}%`,
|
||||
borderRadius: `${d3roRadius.xs} ${d3roRadius.xs} 0 0`,
|
||||
bgcolor: item.value > 0 ? accent : d3roPalette.border.subtle,
|
||||
opacity: item.value > 0 ? 0.85 : 1,
|
||||
transition: 'opacity 120ms ease-out',
|
||||
'&:hover': { opacity: 1 }
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: '2px', px: 0.25 }}>
|
||||
{data.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.label}-label-${index}`}
|
||||
sx={{ flex: '0 1 24px', minWidth: '5px', textAlign: 'center', overflow: 'hidden' }}
|
||||
>
|
||||
{index % labelEvery === 0 ? (
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('nano'),
|
||||
color: d3roPalette.text.inactive,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
/** 값 비중을 가로 막대로 보여주는 목록 행 (앱 비중 등). */
|
||||
export function ShareBar({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
right
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
total: number
|
||||
right?: string
|
||||
}): React.ReactElement {
|
||||
const percent = total > 0 ? value / total : 0
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('small'),
|
||||
color: d3roPalette.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, whiteSpace: 'nowrap' }}>
|
||||
{right ?? `${Math.round(percent * 100)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${Math.max(2, Math.round(percent * 100))}%`,
|
||||
height: '100%',
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
opacity: 0.8
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,555 @@
|
|||
// src/renderer/components/input-insights/InputConsentPanel.tsx
|
||||
// 입력 인텔리전스 — 동의 · 정책 · 실시간 진단.
|
||||
//
|
||||
// 상세 통계(요약/키보드/마우스/앱/문구)는 지식 베이스 화면의 "입력 인사이트" 탭에 있다.
|
||||
// 이 패널은 "무엇을 수집하고, 언제 멈추고, 어떻게 지우는가" 만 다룬다.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { LLMModel } from '@d3ro/core/types'
|
||||
import { useInputInsights } from '../../hooks/useInputInsights'
|
||||
|
||||
const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const
|
||||
|
||||
const HINT_SX = {
|
||||
color: d3roPalette.text.secondary,
|
||||
fontSize: d3roTypo.small.size,
|
||||
lineHeight: d3roTypo.small.line
|
||||
} as const
|
||||
|
||||
export function InputConsentPanel(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const { telemetry, suggestion, receipt, receiptUnavailable, refresh } = useInputInsights(7)
|
||||
|
||||
const [excludedApps, setExcludedApps] = useState('')
|
||||
const [triggerDelayMs, setTriggerDelayMs] = useState('')
|
||||
const [minPrefixChars, setMinPrefixChars] = useState('')
|
||||
const [requestTimeoutMs, setRequestTimeoutMs] = useState('')
|
||||
const [models, setModels] = useState<LLMModel[]>([])
|
||||
const [feedback, setFeedback] = useState<{ message: string; tone: 'success' | 'error' } | null>(null)
|
||||
|
||||
const enabled = telemetry?.enabled ?? false
|
||||
const paused = telemetry?.paused ?? false
|
||||
const suggestionEnabled = suggestion?.enabled ?? false
|
||||
|
||||
useEffect(() => {
|
||||
if (!telemetry) return
|
||||
setExcludedApps(telemetry.excludedApps.join(', '))
|
||||
}, [telemetry])
|
||||
|
||||
// 설정 파일에 굳은 실제 값을 그대로 보여준다 (하드코딩 기본값을 표시하면
|
||||
// 사용자가 보는 값과 동작이 어긋난다 — 실측: 트리거가 3008ms 로 저장돼 있었다).
|
||||
useEffect(() => {
|
||||
if (!suggestion) return
|
||||
setTriggerDelayMs(String(suggestion.triggerDelayMs))
|
||||
setMinPrefixChars(String(suggestion.minPrefixChars))
|
||||
setRequestTimeoutMs(String(suggestion.requestTimeoutMs))
|
||||
}, [suggestion])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.electronAPI.llm.getModels().then((result) => {
|
||||
if (!cancelled && result.success) setModels(result.data)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const flash = useCallback((message: string, tone: 'success' | 'error' = 'success'): void => {
|
||||
setFeedback({ message, tone })
|
||||
setTimeout(() => setFeedback(null), 2500)
|
||||
}, [])
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (next: boolean): Promise<void> => {
|
||||
await window.electronAPI.inputTelemetry.setEnabled({ enabled: next })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handlePause = useCallback(
|
||||
async (next: boolean): Promise<void> => {
|
||||
await window.electronAPI.inputTelemetry.setPaused({ paused: next })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleLearn = useCallback(
|
||||
async (next: boolean): Promise<void> => {
|
||||
await window.electronAPI.suggestion.setConfig({ learnTypedText: next })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleSuggestionToggle = useCallback(
|
||||
async (next: boolean): Promise<void> => {
|
||||
await window.electronAPI.suggestion.setConfig({ enabled: next })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleOverlayInteractive = useCallback(
|
||||
async (next: boolean): Promise<void> => {
|
||||
await window.electronAPI.suggestion.setConfig({ overlayInteractive: next })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleModel = useCallback(
|
||||
async (modelId: string): Promise<void> => {
|
||||
await window.electronAPI.suggestion.setConfig({ modelId: modelId || null })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const handleExcludedAppsBlur = useCallback(async (): Promise<void> => {
|
||||
const apps = excludedApps
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: apps })
|
||||
if (result.success) {
|
||||
await refresh()
|
||||
flash(t('input.feedback.excludedSaved'))
|
||||
} else {
|
||||
flash(t('input.feedback.excludedFailed'), 'error')
|
||||
}
|
||||
}, [excludedApps, refresh, t, flash])
|
||||
|
||||
const handleAddRecommendedApp = useCallback(
|
||||
async (appName: string): Promise<void> => {
|
||||
const candidates = excludedApps
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.concat(appName)
|
||||
const nextApps = candidates.filter(
|
||||
(entry, index) => candidates.findIndex((candidate) => candidate.toLowerCase() === entry.toLowerCase()) === index
|
||||
)
|
||||
const result = await window.electronAPI.suggestion.setConfig({ excludedApps: nextApps })
|
||||
if (result.success) {
|
||||
setExcludedApps(nextApps.join(', '))
|
||||
await refresh()
|
||||
flash(t('input.feedback.recommendationSaved', { app: appName }))
|
||||
} else {
|
||||
flash(t('input.feedback.recommendationFailed', { app: appName }), 'error')
|
||||
}
|
||||
},
|
||||
[excludedApps, flash, refresh, t]
|
||||
)
|
||||
|
||||
const handleClearAll = useCallback(async (): Promise<void> => {
|
||||
const result = await window.electronAPI.inputTelemetry.clearAll()
|
||||
if (result.success) {
|
||||
await refresh()
|
||||
flash(t('input.feedback.cleared'))
|
||||
} else {
|
||||
flash(t('input.feedback.clearFailed'), 'error')
|
||||
}
|
||||
}, [refresh, t, flash])
|
||||
|
||||
const snapshot = telemetry?.lastSnapshot ?? null
|
||||
const exclusionRecommendation = telemetry?.exclusionRecommendation ?? null
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* ── 동의 ─────────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<Typography variant="overline" sx={OVERLINE_SX}>
|
||||
{t('input.consent.title')}
|
||||
</Typography>
|
||||
<Typography sx={HINT_SX}>{t('input.consent.description')}</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.privacy.title')}
|
||||
</Typography>
|
||||
<Typography sx={HINT_SX}>{t('input.privacy.localOnly')}</Typography>
|
||||
<Typography sx={HINT_SX}>{t('input.privacy.rawKeys')}</Typography>
|
||||
{receipt ? (
|
||||
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 1, rowGap: 0.25 }}>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityRetention')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.activityDays })}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamplesRetention')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.typingSamplesDays })}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestionRetention')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.days', { days: receipt.retention.suggestionDays })}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.learnedRetention')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{t('input.privacy.untilDeleted')}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.activityBuckets')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.activityBuckets.toLocaleString()}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.typingSamples')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.typingSamples.toLocaleString()}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.personalPhrases')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.personalPhrases.toLocaleString()}</Typography>
|
||||
<Typography component="dt" sx={HINT_SX}>{t('input.privacy.suggestions')}</Typography>
|
||||
<Typography component="dd" sx={{ ...HINT_SX, m: 0 }}>{receipt.counts.suggestions.toLocaleString()}</Typography>
|
||||
</Box>
|
||||
) : receiptUnavailable ? (
|
||||
<Typography sx={HINT_SX} role="status" aria-live="polite">{t('input.privacy.unavailable')}</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch size="small" checked={enabled} onChange={(e) => void handleConsent(e.target.checked)} />
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.consent.collect')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography sx={{ ...HINT_SX, pl: 6 }}>
|
||||
{telemetry?.running ? t('input.consent.running') : t('input.consent.stopped')}
|
||||
</Typography>
|
||||
|
||||
{/* 실시간 진단 — "왜 제안이 안 뜨는지" 를 사용자가 직접 볼 수 있게 한다. */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.diagnostics.title')}
|
||||
</Typography>
|
||||
<Typography sx={HINT_SX}>
|
||||
{snapshot
|
||||
? t('input.diagnostics.app', { app: snapshot.appName ?? t('input.diagnostics.unknownApp') })
|
||||
: t('input.diagnostics.noSnapshot')}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
...HINT_SX,
|
||||
color:
|
||||
snapshot?.editable && !snapshot.isPassword
|
||||
? d3roPalette.status.success
|
||||
: d3roPalette.status.warning
|
||||
}}
|
||||
>
|
||||
{snapshot?.isPassword
|
||||
? t('input.diagnostics.password')
|
||||
: snapshot?.editable
|
||||
? t('input.diagnostics.readable', {
|
||||
source: snapshot.textSource,
|
||||
length: snapshot.textLength
|
||||
})
|
||||
: t('input.diagnostics.notReadable')}
|
||||
</Typography>
|
||||
{snapshot?.composing ? (
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.diagnostics.composing')}
|
||||
</Typography>
|
||||
) : null}
|
||||
{snapshot?.caretFallback && snapshot.editable ? (
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.diagnostics.caretFallback')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{exclusionRecommendation ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.status.warning}`,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 0.75
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.secondary }} aria-live="polite">
|
||||
{t('input.exclusion.recommendation', {
|
||||
app: exclusionRecommendation.appName,
|
||||
samples: exclusionRecommendation.samples,
|
||||
reason:
|
||||
exclusionRecommendation.reason === 'repeated-empty'
|
||||
? t('input.exclusion.reason.repeated-empty')
|
||||
: t('input.exclusion.reason.repeated-unreadable')
|
||||
})}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => void handleAddRecommendedApp(exclusionRecommendation.appName)}
|
||||
aria-label={t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
|
||||
sx={{ fontSize: d3roTypo.label.size }}
|
||||
>
|
||||
{t('input.exclusion.addButton', { app: exclusionRecommendation.appName })}
|
||||
</Button>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<FormControlLabel
|
||||
disabled={!enabled}
|
||||
control={<Switch size="small" checked={paused} onChange={(e) => void handlePause(e.target.checked)} />}
|
||||
label={
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.consent.pause')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
disabled={!enabled}
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={telemetry?.learnTypedText ?? false}
|
||||
onChange={(e) => void handleLearn(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.consent.learnText')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography sx={{ ...HINT_SX, pl: 6 }}>{t('input.consent.learnTextHint')}</Typography>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={!enabled}
|
||||
label={t('input.consent.excludedApps')}
|
||||
placeholder={t('input.consent.excludedAppsPlaceholder')}
|
||||
value={excludedApps}
|
||||
onChange={(e) => setExcludedApps(e.target.value)}
|
||||
onBlur={() => void handleExcludedAppsBlur()}
|
||||
helperText={t('input.consent.excludedAppsHint')}
|
||||
InputProps={{ sx: { fontSize: d3roTypo.compact.size } }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Trash2 size={14} />}
|
||||
onClick={() => void handleClearAll()}
|
||||
sx={{ fontSize: d3roTypo.label.size }}
|
||||
>
|
||||
{t('input.consent.clearAll')}
|
||||
</Button>
|
||||
{feedback ? (
|
||||
<Typography
|
||||
sx={{ ...HINT_SX, color: feedback.tone === 'success' ? d3roPalette.status.success : d3roPalette.status.danger }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{feedback.message}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.insights.statsMovedHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* ── 제안 정책 ─────────────────────────────────── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<Typography variant="overline" sx={OVERLINE_SX}>
|
||||
{t('input.suggestion.title')}
|
||||
</Typography>
|
||||
<Typography sx={HINT_SX}>{t('input.suggestion.description')}</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={suggestionEnabled}
|
||||
onChange={(e) => void handleSuggestionToggle(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.suggestion.enabled')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Typography sx={HINT_SX}>
|
||||
{suggestion?.modelAvailable ? t('input.suggestion.modelReady') : t('input.suggestion.modelMissing')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
disabled={!suggestionEnabled}
|
||||
label={t('input.suggestion.delay')}
|
||||
value={triggerDelayMs}
|
||||
onChange={(e) => setTriggerDelayMs(e.target.value)}
|
||||
onBlur={() =>
|
||||
void window.electronAPI.suggestion
|
||||
.setConfig({ triggerDelayMs: Number(triggerDelayMs) })
|
||||
.then(() => refresh())
|
||||
}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
disabled={!suggestionEnabled}
|
||||
label={t('input.suggestion.minPrefix')}
|
||||
value={minPrefixChars}
|
||||
onChange={(e) => setMinPrefixChars(e.target.value)}
|
||||
onBlur={() =>
|
||||
void window.electronAPI.suggestion
|
||||
.setConfig({ minPrefixChars: Number(minPrefixChars) })
|
||||
.then(() => refresh())
|
||||
}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
disabled={!suggestionEnabled}
|
||||
label={t('input.suggestion.timeout')}
|
||||
value={requestTimeoutMs}
|
||||
onChange={(e) => setRequestTimeoutMs(e.target.value)}
|
||||
onBlur={() =>
|
||||
void window.electronAPI.suggestion
|
||||
.setConfig({ requestTimeoutMs: Number(requestTimeoutMs) })
|
||||
.then(() => refresh())
|
||||
}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
<Select
|
||||
size="small"
|
||||
disabled={!suggestionEnabled}
|
||||
value={suggestion?.modelId ?? ''}
|
||||
onChange={(e) => void handleModel(String(e.target.value))}
|
||||
displayEmpty
|
||||
sx={{ minWidth: 200, fontSize: d3roTypo.compact.size }}
|
||||
>
|
||||
<MenuItem value="">{t('input.suggestion.modelDefault')}</MenuItem>
|
||||
{models.map((model) => (
|
||||
<MenuItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
disabled={!suggestionEnabled}
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={suggestion?.overlayInteractive ?? true}
|
||||
onChange={(e) => void handleOverlayInteractive(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.suggestion.overlayInteractive')}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 상태 표시 — 스위치로 두면 설정처럼 보여서 "왜 못 켜지?" 가 된다. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.suggestion.onScreenLabel')}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: d3roRadius.xs,
|
||||
bgcolor: suggestion?.visible ? d3roPalette.accent.dim : d3roPalette.bg.inset,
|
||||
border: `1px solid ${suggestion?.visible ? d3roPalette.accent.main : d3roPalette.border.subtle}`
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('micro'),
|
||||
color: suggestion?.visible ? d3roPalette.accent.main : d3roPalette.text.inactive
|
||||
}}
|
||||
>
|
||||
{suggestion?.visible ? t('input.suggestion.onScreen') : t('input.suggestion.offScreen')}
|
||||
</Typography>
|
||||
</Box>
|
||||
{suggestion?.generating ? (
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.status.warning }}>
|
||||
{t('input.suggestion.generating')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Typography sx={HINT_SX}>{t('input.suggestion.keyHint')}</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={!suggestionEnabled}
|
||||
onClick={() => void window.electronAPI.suggestion.requestNow()}
|
||||
sx={{ fontSize: d3roTypo.label.size }}
|
||||
>
|
||||
{t('input.suggestion.requestNow')}
|
||||
</Button>
|
||||
<Typography sx={HINT_SX}>
|
||||
{t('input.suggestion.usage', {
|
||||
requests: suggestion?.requestsToday ?? 0,
|
||||
budget: suggestion?.dailyBudget ?? 0
|
||||
})}
|
||||
</Typography>
|
||||
{suggestion?.lastLatencyMs !== null && suggestion?.lastLatencyMs !== undefined ? (
|
||||
<Typography sx={HINT_SX}>
|
||||
{t('input.suggestion.latency', { ms: suggestion.lastLatencyMs })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{suggestion?.lastSkipReason ? (
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.suggestion.lastSkip', { reason: suggestion.lastSkipReason })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,701 @@
|
|||
// src/renderer/components/input-insights/InputInsightsView.tsx
|
||||
// 입력 인사이트 — 지식 베이스 안에 들어가는 상세 통계 화면.
|
||||
//
|
||||
// 종류별 inner tab 으로 나눈다: 요약 / 키보드 / 마우스 / 앱 / 문구·제안.
|
||||
// 모든 수치는 수집된 로컬 집계(input_activity)와 제안 이력(suggestions)에서 나온다.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { d3roPalette, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { BarChart, ShareBar, type BarDatum } from './BarChart'
|
||||
import { useInputInsights } from '../../hooks/useInputInsights'
|
||||
import type {
|
||||
PersonalGraphQuery,
|
||||
PersonalGraphStats
|
||||
} from '@d3ro/core/personal-graph'
|
||||
|
||||
const OVERLINE_SX = { color: d3roPalette.text.label, letterSpacing: '1.5px' } as const
|
||||
|
||||
const HINT_SX = {
|
||||
color: d3roPalette.text.secondary,
|
||||
fontSize: d3roTypo.small.size,
|
||||
lineHeight: d3roTypo.small.line
|
||||
} as const
|
||||
|
||||
interface TileProps {
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
emphasis?: boolean
|
||||
}
|
||||
|
||||
function StatTile({ label, value, unit, emphasis = false }: TileProps): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
flex: '1 1 132px',
|
||||
minWidth: 132,
|
||||
p: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${emphasis ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
|
||||
bgcolor: emphasis ? d3roPalette.accent.dim : d3roPalette.bg.inset,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>{label}</Typography>
|
||||
<Typography sx={{ ...typoSx('value'), color: d3roPalette.text.primary }}>
|
||||
{value}
|
||||
{unit ? (
|
||||
<Box component="span" sx={{ ml: 0.5, ...typoSx('small'), color: d3roPalette.text.inactive }}>
|
||||
{unit}
|
||||
</Box>
|
||||
) : null}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function TileRow({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>{children}</Box>
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
<Typography variant="overline" sx={OVERLINE_SX}>
|
||||
{title}
|
||||
</Typography>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
interface SuggestionHistoryRow {
|
||||
id: string
|
||||
appName: string | null
|
||||
prefixText: string
|
||||
suggestionText: string
|
||||
model: string | null
|
||||
latencyMs: number | null
|
||||
accepted: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function InputInsightsView(): React.ReactElement {
|
||||
const { t, formatRelativeDate } = useI18n()
|
||||
const [days, setDays] = useState(7)
|
||||
const [tab, setTab] = useState(0)
|
||||
const [history, setHistory] = useState<SuggestionHistoryRow[]>([])
|
||||
const [graph, setGraph] = useState<PersonalGraphStats | null>(null)
|
||||
const [graphQuery, setGraphQuery] = useState('')
|
||||
const [graphResult, setGraphResult] = useState<PersonalGraphQuery | null>(null)
|
||||
const { telemetry, summary, phrases, refresh } = useInputInsights(days)
|
||||
|
||||
const loadHistory = useCallback(async (): Promise<void> => {
|
||||
const result = await window.electronAPI.suggestion.getHistory({ limit: 20 })
|
||||
if (result.success) setHistory(result.data)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadHistory()
|
||||
}, [loadHistory])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void window.electronAPI.inputTelemetry.getGraph().then((result) => {
|
||||
if (!cancelled && result.success) setGraph(result.data)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [tab, phrases.length])
|
||||
|
||||
const runGraphQuery = useCallback(async (): Promise<void> => {
|
||||
const result = await window.electronAPI.inputTelemetry.queryGraph({ text: graphQuery })
|
||||
if (result.success) setGraphResult(result.data)
|
||||
}, [graphQuery])
|
||||
|
||||
const handleDeletePhrase = useCallback(
|
||||
async (id: string): Promise<void> => {
|
||||
await window.electronAPI.inputTelemetry.deletePhrase({ id })
|
||||
await refresh()
|
||||
},
|
||||
[refresh]
|
||||
)
|
||||
|
||||
const dailyKeys: BarDatum[] = (summary?.daily ?? []).map((day) => ({
|
||||
label: day.date.slice(5),
|
||||
value: day.keystrokes,
|
||||
hint: `${day.date} · ${day.keystrokes.toLocaleString()}`
|
||||
}))
|
||||
|
||||
const dailyWords: BarDatum[] = (summary?.daily ?? []).map((day) => ({
|
||||
label: day.date.slice(5),
|
||||
value: day.words,
|
||||
hint: `${day.date} · ${day.words.toLocaleString()}`
|
||||
}))
|
||||
|
||||
const dailyDistance: BarDatum[] = (summary?.daily ?? []).map((day) => ({
|
||||
label: day.date.slice(5),
|
||||
value: Math.round(day.mouseDistancePx / 96 / 0.0254),
|
||||
hint: `${day.date} · ${Math.round(day.mouseDistancePx)}px`
|
||||
}))
|
||||
|
||||
const hourlyKeys: BarDatum[] = (summary?.hourly ?? []).map((hour) => ({
|
||||
label: String(hour.hour),
|
||||
value: hour.keystrokes,
|
||||
hint: `${hour.hour}:00 · ${hour.keystrokes.toLocaleString()}`
|
||||
}))
|
||||
|
||||
const labelEvery = days > 14 ? 3 : days > 7 ? 2 : 1
|
||||
const totals = summary?.totals
|
||||
const averages = summary?.averages
|
||||
const suggestions = summary?.suggestions
|
||||
const friction = summary?.friction
|
||||
const flowWindows = summary?.flowWindows ?? []
|
||||
const suggestionApps = summary?.suggestionApps ?? []
|
||||
const frictionBandLabel =
|
||||
friction?.band === 'high'
|
||||
? t('input.friction.band.high')
|
||||
: friction?.band === 'watch'
|
||||
? t('input.friction.band.watch')
|
||||
: t('input.friction.band.steady')
|
||||
const topAppTotal = (summary?.topApps ?? []).reduce((sum, app) => sum + app.keystrokes, 0)
|
||||
|
||||
if (!telemetry?.enabled) {
|
||||
return (
|
||||
<Box sx={{ py: 4, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={HINT_SX}>{t('input.insights.disabledHint')}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ ...typoSx('label'), color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.insights.rangeLabel')}
|
||||
</Typography>
|
||||
{[7, 14, 30].map((value) => (
|
||||
<Box
|
||||
key={value}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() => setDays(value)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: d3roRadius.xs,
|
||||
border: `1px solid ${value === days ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
|
||||
bgcolor: value === days ? d3roPalette.accent.dim : 'transparent',
|
||||
color: value === days ? d3roPalette.accent.main : d3roPalette.text.inactive,
|
||||
font: 'inherit',
|
||||
...typoSx('micro')
|
||||
}}
|
||||
>
|
||||
{t('input.insights.rangeDays', { days: value })}
|
||||
</Box>
|
||||
))}
|
||||
<Typography sx={{ ...HINT_SX, ml: 'auto' }}>
|
||||
{t('input.insights.headerSummary', {
|
||||
days: summary?.days ?? days,
|
||||
keys: totals?.keystrokes ?? 0,
|
||||
apps: summary?.topApps.length ?? 0
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(_event, next: number) => setTab(next)}
|
||||
variant="scrollable"
|
||||
scrollButtons={false}
|
||||
sx={{
|
||||
minHeight: 36,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 36,
|
||||
py: 0.5,
|
||||
textTransform: 'none',
|
||||
color: d3roPalette.text.inactive,
|
||||
fontSize: d3roTypo.label.size,
|
||||
'&.Mui-selected': { color: d3roPalette.accent.main } as const
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }
|
||||
}}
|
||||
>
|
||||
<Tab label={t('input.insights.tabs.overview')} />
|
||||
<Tab label={t('input.insights.tabs.keyboard')} />
|
||||
<Tab label={t('input.insights.tabs.mouse')} />
|
||||
<Tab label={t('input.insights.tabs.apps')} />
|
||||
<Tab label={t('input.insights.tabs.phrases')} />
|
||||
<Tab label={t('input.insights.tabs.graph')} />
|
||||
</Tabs>
|
||||
|
||||
{/* ── 요약 ─────────────────────────────────────── */}
|
||||
{tab === 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TileRow>
|
||||
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
|
||||
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.chars')} value={(totals?.chars ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.activeMinutes')} value={String(averages?.activeMinutes ?? 0)} unit={t('input.unit.perDay')} />
|
||||
<StatTile label={t('input.insights.mouseDistance')} value={String(averages?.mouseDistanceMeters ?? 0)} unit={t('input.unit.perDayMeters')} />
|
||||
<StatTile label={t('input.insights.activeDays')} value={String(summary?.activeDays ?? 0)} />
|
||||
<StatTile label={t('input.insights.streak')} value={String(summary?.longestStreakDays ?? 0)} />
|
||||
<StatTile
|
||||
label={t('input.insights.peakDay')}
|
||||
value={summary?.peakDay ? summary.peakDay.date.slice(5) : '—'}
|
||||
unit={summary?.peakDay ? summary.peakDay.keystrokes.toLocaleString() : undefined}
|
||||
/>
|
||||
<StatTile label={t('input.insights.phrases')} value={String(summary?.phraseCount ?? 0)} />
|
||||
<StatTile
|
||||
label={t('input.insights.acceptRate')}
|
||||
value={suggestions ? `${Math.round(suggestions.acceptRate * 100)}` : '0'}
|
||||
unit={t('input.unit.percent')}
|
||||
/>
|
||||
</TileRow>
|
||||
|
||||
<Section title={t('input.insights.dailyTitle')}>
|
||||
<BarChart data={dailyKeys} labelEvery={labelEvery} />
|
||||
</Section>
|
||||
|
||||
<Section title={t('input.insights.perDayAverage')}>
|
||||
<TileRow>
|
||||
<StatTile label={t('input.insights.keystrokes')} value={(averages?.keystrokes ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
|
||||
<StatTile label={t('input.insights.words')} value={(averages?.words ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
|
||||
<StatTile label={t('input.insights.clicks')} value={(averages?.clicks ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
|
||||
<StatTile label={t('input.insights.backspaces')} value={(averages?.backspaces ?? 0).toLocaleString()} unit={t('input.unit.perDay')} />
|
||||
</TileRow>
|
||||
</Section>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* ── 키보드 ───────────────────────────────────── */}
|
||||
{tab === 1 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TileRow>
|
||||
<StatTile label={t('input.insights.keystrokes')} value={(totals?.keystrokes ?? 0).toLocaleString()} emphasis />
|
||||
<StatTile label={t('input.insights.wordChars')} value={(totals?.chars ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.words')} value={(totals?.words ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.sentences')} value={(totals?.sentences ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.backspaces')} value={(totals?.backspaces ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.shortcuts')} value={(totals?.shortcuts ?? 0).toLocaleString()} />
|
||||
</TileRow>
|
||||
|
||||
<Section title={t('input.insights.hourlyTitle')}>
|
||||
<BarChart data={hourlyKeys} labelEvery={2} />
|
||||
</Section>
|
||||
|
||||
<Section title={t('input.flow.title')}>
|
||||
<TileRow>
|
||||
<StatTile
|
||||
label={t('input.friction.title')}
|
||||
value={t('input.friction.value', { count: friction?.editsPer100Chars ?? 0 })}
|
||||
unit={frictionBandLabel}
|
||||
/>
|
||||
</TileRow>
|
||||
{flowWindows.length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.flow.empty')}</Typography>
|
||||
) : (
|
||||
flowWindows.map((window) => (
|
||||
<Box
|
||||
key={window.hour}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.75,
|
||||
alignItems: 'center',
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.inset
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.text.primary }}>
|
||||
{t('input.flow.hour', { hour: window.hour })}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.flow.window', {
|
||||
score: window.score,
|
||||
minutes: Math.round(window.activeMinutes),
|
||||
friction: Math.round(window.frictionRate * 100)
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('input.insights.wordsDailyTitle')}>
|
||||
<BarChart data={dailyWords} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
|
||||
</Section>
|
||||
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.insights.topHoursHint', {
|
||||
hours: (summary?.topHours ?? [])
|
||||
.slice(0, 3)
|
||||
.map((hour) => `${hour.hour}시`)
|
||||
.join(', ')
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* ── 마우스 ───────────────────────────────────── */}
|
||||
{tab === 2 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TileRow>
|
||||
<StatTile label={t('input.insights.clicks')} value={(totals?.clicks ?? 0).toLocaleString()} emphasis />
|
||||
<StatTile label={t('input.insights.doubleClicks')} value={(totals?.doubleClicks ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.scrollTicks')} value={(totals?.scrollTicks ?? 0).toLocaleString()} />
|
||||
<StatTile
|
||||
label={t('input.insights.mouseDistanceTotal')}
|
||||
value={((averages?.mouseDistanceMeters ?? 0) * days).toFixed(1)}
|
||||
unit={t('input.unit.meters')}
|
||||
/>
|
||||
</TileRow>
|
||||
|
||||
<Section title={t('input.insights.distanceDailyTitle')}>
|
||||
<BarChart data={dailyDistance} labelEvery={labelEvery} accent={d3roPalette.accent.light} />
|
||||
</Section>
|
||||
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.insights.mouseNote')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* ── 앱 ──────────────────────────────────────── */}
|
||||
{tab === 3 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography sx={HINT_SX}>{t('input.insights.appsHint')}</Typography>
|
||||
{(summary?.topApps ?? []).length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.insights.empty')}</Typography>
|
||||
) : (
|
||||
(summary?.topApps ?? []).map((app) => (
|
||||
<ShareBar
|
||||
key={app.appName}
|
||||
label={app.appName}
|
||||
value={app.keystrokes}
|
||||
total={topAppTotal}
|
||||
right={t('input.insights.appRow', {
|
||||
keystrokes: app.keystrokes,
|
||||
clicks: app.clicks
|
||||
})}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<Section title={t('input.appQuality.title')}>
|
||||
{suggestionApps.length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.appQuality.empty')}</Typography>
|
||||
) : (
|
||||
suggestionApps.map((app) => (
|
||||
<Box
|
||||
key={app.appName}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 0.75,
|
||||
alignItems: 'center',
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
bgcolor: d3roPalette.bg.inset
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
flex: '1 1 120px',
|
||||
minWidth: 0,
|
||||
...typoSx('compact'),
|
||||
color: d3roPalette.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{app.appName}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.secondary }}>
|
||||
{t('input.appQuality.row', {
|
||||
accepted: app.accepted,
|
||||
total: app.total,
|
||||
rate: Math.round(app.acceptRate * 100),
|
||||
latency: app.avgLatencyMs ?? '—'
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* ── 개인 그래프 ──────────────────────────────── */}
|
||||
{tab === 5 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography sx={HINT_SX}>{t('input.graph.description')}</Typography>
|
||||
|
||||
<TileRow>
|
||||
<StatTile label={t('input.graph.nodes')} value={String(graph?.nodes ?? 0)} emphasis />
|
||||
<StatTile label={t('input.graph.followsEdges')} value={String(graph?.followsEdges ?? 0)} />
|
||||
<StatTile label={t('input.graph.sharesEdges')} value={String(graph?.sharesTermsEdges ?? 0)} />
|
||||
</TileRow>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={graphQuery}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setGraphQuery(event.target.value)}
|
||||
onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') void runGraphQuery()
|
||||
}}
|
||||
placeholder={t('input.graph.searchPlaceholder')}
|
||||
label={t('input.graph.searchLabel')}
|
||||
/>
|
||||
<Button size="small" variant="outlined" onClick={() => void runGraphQuery()}>
|
||||
{t('input.graph.searchAction')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{graphResult && graphResult.anchors.length > 0 ? (
|
||||
<Section title={t('input.graph.neighbors')}>
|
||||
{graphResult.anchors.map((anchor) => (
|
||||
<Box key={anchor.text} sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Typography sx={{ ...typoSx('compact'), color: d3roPalette.accent.main }}>
|
||||
{anchor.text}
|
||||
</Typography>
|
||||
{graphResult.neighbors
|
||||
.slice(0, 4)
|
||||
.map((neighbor) => (
|
||||
<Typography
|
||||
key={`${anchor.text}-${neighbor.text}`}
|
||||
sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, pl: 1.5 }}
|
||||
>
|
||||
→ {neighbor.text} ({neighbor.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')} · {neighbor.weight})
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title={t('input.graph.topEdges')}>
|
||||
{(graph?.topEdges ?? []).length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.graph.empty')}</Typography>
|
||||
) : (
|
||||
(graph?.topEdges ?? []).map((edge) => (
|
||||
<Box
|
||||
key={`${edge.from}-${edge.to}-${edge.kind}`}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
|
||||
{edge.kind === 'follows' ? t('input.graph.kindFollows') : t('input.graph.kindShares')}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{edge.from} → {edge.to}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
maxWidth: '45%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...typoSx('micro'),
|
||||
color: d3roPalette.text.inactive
|
||||
}}
|
||||
>
|
||||
{edge.weight}×
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('input.graph.recentNodes')}>
|
||||
{(graph?.recentNodes ?? []).slice(0, 8).map((node) => (
|
||||
<Box key={node.text} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{node.text}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive }}>
|
||||
{node.count}× · {node.appName ?? '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Section>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* ── 문구 · 제안 ─────────────────────────────── */}
|
||||
{tab === 4 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Section title={t('input.insights.suggestions')}>
|
||||
<TileRow>
|
||||
<StatTile label={t('input.insights.suggestionsTotal')} value={(suggestions?.total ?? 0).toLocaleString()} />
|
||||
<StatTile label={t('input.insights.suggestionsAccepted')} value={(suggestions?.accepted ?? 0).toLocaleString()} />
|
||||
<StatTile
|
||||
label={t('input.insights.acceptRate')}
|
||||
value={String(Math.round((suggestions?.acceptRate ?? 0) * 100))}
|
||||
unit={t('input.unit.percent')}
|
||||
/>
|
||||
<StatTile
|
||||
label={t('input.insights.avgLatency')}
|
||||
value={suggestions?.avgLatencyMs === null || suggestions?.avgLatencyMs === undefined ? '—' : String(suggestions.avgLatencyMs)}
|
||||
unit={t('input.unit.ms')}
|
||||
/>
|
||||
</TileRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('input.insights.suggestionHistory')}>
|
||||
{history.length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.insights.noSuggestions')}</Typography>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<Box
|
||||
key={entry.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.25,
|
||||
px: 1.25,
|
||||
py: 1,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('micro'),
|
||||
color: entry.accepted ? d3roPalette.status.success : d3roPalette.text.inactive
|
||||
}}
|
||||
>
|
||||
{entry.accepted ? t('input.insights.accepted') : t('input.insights.notAccepted')}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.dimLabel }}>
|
||||
{entry.appName ?? '—'}
|
||||
</Typography>
|
||||
<Typography sx={{ ...typoSx('micro'), color: d3roPalette.text.inactive, ml: 'auto' }}>
|
||||
{entry.latencyMs === null ? '' : `${entry.latencyMs}ms`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
...typoSx('compact'),
|
||||
color: d3roPalette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{entry.prefixText.slice(-40)} → {entry.suggestionText}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Section title={t('input.phrases.title')}>
|
||||
<Typography sx={HINT_SX}>{t('input.phrases.description')}</Typography>
|
||||
{phrases.length === 0 ? (
|
||||
<Typography sx={HINT_SX}>{t('input.phrases.empty')}</Typography>
|
||||
) : (
|
||||
phrases.slice(0, 40).map((phrase) => (
|
||||
<Box
|
||||
key={phrase.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
...typoSx('compact'),
|
||||
color: d3roPalette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{phrase.phrase}
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
maxWidth: '45%',
|
||||
flexShrink: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...typoSx('micro'),
|
||||
color: d3roPalette.text.inactive
|
||||
}}
|
||||
>
|
||||
{t('input.phrases.metadata', {
|
||||
source: phrase.source,
|
||||
count: phrase.count,
|
||||
app: phrase.appName ?? t('input.phrases.appUnknown')
|
||||
})}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={t('input.phrases.delete')}
|
||||
onClick={() => void handleDeletePhrase(phrase.id)}
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Typography sx={{ ...HINT_SX, color: d3roPalette.text.inactive }}>
|
||||
{t('input.insights.samplesNote', { count: summary?.sampleCount ?? 0 })}{' '}
|
||||
{t('input.insights.collectedAt', { at: formatRelativeDate(telemetry.lastSnapshotAt || Date.now()) })}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
83
apps/desktop/src/renderer/hooks/useInputInsights.ts
Normal file
83
apps/desktop/src/renderer/hooks/useInputInsights.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// src/renderer/hooks/useInputInsights.ts
|
||||
// 입력 텔레메트리/제안 상태 + 주간 인사이트 로딩 훅.
|
||||
//
|
||||
// 동의(수집 여부)와는 무관하게 **설정값과 집계만** 읽는다. 수집이 꺼져 있으면
|
||||
// 집계는 비어 있고 UI 는 "꺼" 상태를 보여준다.
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type {
|
||||
InputInsightsSummary,
|
||||
InputPrivacyReceipt,
|
||||
InputTelemetryState,
|
||||
PersonalPhrase,
|
||||
SuggestionState
|
||||
} from '@d3ro/core/input-intelligence'
|
||||
|
||||
export interface UseInputInsightsResult {
|
||||
telemetry: InputTelemetryState | null
|
||||
suggestion: SuggestionState | null
|
||||
summary: InputInsightsSummary | null
|
||||
receipt: InputPrivacyReceipt | null
|
||||
receiptUnavailable: boolean
|
||||
phrases: PersonalPhrase[]
|
||||
loading: boolean
|
||||
refresh: (days?: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function useInputInsights(days = 7): UseInputInsightsResult {
|
||||
const [telemetry, setTelemetry] = useState<InputTelemetryState | null>(null)
|
||||
const [suggestion, setSuggestion] = useState<SuggestionState | null>(null)
|
||||
const [summary, setSummary] = useState<InputInsightsSummary | null>(null)
|
||||
const [receipt, setReceipt] = useState<InputPrivacyReceipt | null>(null)
|
||||
const [receiptUnavailable, setReceiptUnavailable] = useState(false)
|
||||
const [phrases, setPhrases] = useState<PersonalPhrase[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(
|
||||
async (rangeDays = days): Promise<void> => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [stateResult, suggestionResult, summaryResult, phrasesResult, receiptResult] = await Promise.allSettled([
|
||||
window.electronAPI.inputTelemetry.getState(),
|
||||
window.electronAPI.suggestion.getState(),
|
||||
window.electronAPI.inputTelemetry.getSummary({ days: rangeDays }),
|
||||
window.electronAPI.inputTelemetry.getPhrases({ limit: 50 }),
|
||||
window.electronAPI.inputTelemetry.getPrivacyReceipt()
|
||||
])
|
||||
if (stateResult.status === 'fulfilled' && stateResult.value.success) setTelemetry(stateResult.value.data)
|
||||
if (suggestionResult.status === 'fulfilled' && suggestionResult.value.success) setSuggestion(suggestionResult.value.data)
|
||||
if (summaryResult.status === 'fulfilled' && summaryResult.value.success) setSummary(summaryResult.value.data)
|
||||
if (phrasesResult.status === 'fulfilled' && phrasesResult.value.success) setPhrases(phrasesResult.value.data)
|
||||
if (receiptResult.status === 'fulfilled' && receiptResult.value.success) {
|
||||
setReceipt(receiptResult.value.data)
|
||||
setReceiptUnavailable(false)
|
||||
} else {
|
||||
setReceipt(null)
|
||||
setReceiptUnavailable(true)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[days]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void refresh(days)
|
||||
}, [refresh, days])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubTelemetry = window.electronAPI.inputTelemetry.onStateChanged((state) => {
|
||||
setTelemetry(state)
|
||||
})
|
||||
const unsubSuggestion = window.electronAPI.suggestion.onStateChanged((state) => {
|
||||
setSuggestion(state)
|
||||
})
|
||||
return () => {
|
||||
unsubTelemetry()
|
||||
unsubSuggestion()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { telemetry, suggestion, summary, receipt, receiptUnavailable, phrases, loading, refresh }
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey
|
|||
import { BindingKeycaps } from '../components/keybinding/Keycap'
|
||||
import { useKeyBindingMap } from '../hooks/useKeyBindingMap'
|
||||
import { FileDropZone } from '../components/FileDropZone'
|
||||
import { useInputInsights } from '../hooks/useInputInsights'
|
||||
import type { StatsSummary, HistoryEntry, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
|
||||
|
||||
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
|
||||
|
|
@ -103,6 +104,7 @@ export function DashboardPage(): React.ReactElement {
|
|||
const [llmModel, setLlmModel] = useState<string | null>(null)
|
||||
const bindingMap = useKeyBindingMap()
|
||||
const dictationBinding = bindingMap?.dictation[0] ?? null
|
||||
const inputInsights = useInputInsights(7)
|
||||
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
|
@ -642,6 +644,57 @@ export function DashboardPage(): React.ReactElement {
|
|||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ── 5b. 입력 인사이트 (주간) ──────────────────── */}
|
||||
{inputInsights.telemetry?.enabled ? (
|
||||
<MetalCard sx={{ p: 2.75, mt: 3 }}>
|
||||
<CardHeader
|
||||
title={t('input.insights.title', { days: inputInsights.summary?.days ?? 7 })}
|
||||
icon={<BarChart3 size={16} />}
|
||||
right={
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{t('input.insights.keystrokes')} {formatNumber(inputInsights.summary?.totals.keystrokes ?? 0)}
|
||||
</PhosphorText>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{[
|
||||
{
|
||||
label: t('input.insights.clicks'),
|
||||
value: formatNumber(inputInsights.summary?.totals.clicks ?? 0),
|
||||
},
|
||||
{
|
||||
label: t('input.insights.words'),
|
||||
value: formatNumber(inputInsights.summary?.totals.words ?? 0),
|
||||
},
|
||||
{
|
||||
label: t('input.insights.sentences'),
|
||||
value: formatNumber(inputInsights.summary?.totals.sentences ?? 0),
|
||||
},
|
||||
{
|
||||
label: t('input.insights.mouseDistance'),
|
||||
value: `${inputInsights.summary?.averages.mouseDistanceMeters ?? 0}${t('input.insights.perDayMeters')}`,
|
||||
},
|
||||
{
|
||||
label: t('input.insights.phrases'),
|
||||
value: formatNumber(inputInsights.summary?.phraseCount ?? 0),
|
||||
},
|
||||
].map((item) => (
|
||||
<Box
|
||||
key={item.label}
|
||||
sx={{ flex: '1 1 140px', minWidth: 140, px: 1.5, py: 1, borderRadius: d3roRadius.xs }}
|
||||
>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{item.label}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="value" sx={{ color: d3roPalette.text.primary }}>
|
||||
{item.value}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
) : null}
|
||||
|
||||
{/* ── 6. 파일 전사 드롭존 ──────────────────────────── */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<FileDropZone />
|
||||
|
|
|
|||
|
|
@ -2,16 +2,21 @@
|
|||
// Local RAG Knowledge Base & Semantic Memory Intelligence
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
|
||||
import { Box, TextField, IconButton, Tooltip, LinearProgress, Tab, Tabs } from '@mui/material'
|
||||
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles } from 'lucide-react'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { InputInsightsView } from '../components/input-insights/InputInsightsView'
|
||||
import { isImeComposingEvent } from '../utils/keyboard'
|
||||
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'
|
||||
|
||||
type KnowledgeView = 'kb' | 'insights'
|
||||
|
||||
export function KnowledgeBasePage(): React.ReactElement {
|
||||
// 이 화면은 두 축을 가진다: 수집된 지식 문서(RAG)와 자동 수집된 입력 데이터 통계.
|
||||
const [view, setView] = useState<KnowledgeView>('kb')
|
||||
const { t } = useI18n()
|
||||
const [documents, setDocuments] = useState<RAGDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -128,6 +133,32 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
}
|
||||
/>
|
||||
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={(_event, next: KnowledgeView) => setView(next)}
|
||||
sx={{
|
||||
mb: 2.5,
|
||||
minHeight: 38,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 38,
|
||||
py: 0.5,
|
||||
textTransform: 'none',
|
||||
color: d3roPalette.text.inactive,
|
||||
fontSize: d3roTypo.label.size,
|
||||
'&.Mui-selected': { color: d3roPalette.accent.main }
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }
|
||||
}}
|
||||
>
|
||||
<Tab value="kb" label={t('input.kbView.knowledge')} />
|
||||
<Tab value="insights" label={t('input.kbView.insights')} />
|
||||
</Tabs>
|
||||
|
||||
{view === 'insights' ? (
|
||||
<InputInsightsView />
|
||||
) : (
|
||||
<>
|
||||
{/* Indexing Progress Card */}
|
||||
{indexProgress && (
|
||||
<MetalCard sx={{ mb: 2.5, p: 2.5 }}>
|
||||
|
|
@ -411,6 +442,8 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<title>Suggestion</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<div id="panel" class="suggestion-panel">
|
||||
<div class="header">
|
||||
<div id="status" class="status" hidden>
|
||||
<span id="spinner" class="spinner" aria-hidden="true"></span>
|
||||
<span id="statusText" class="status-text"></span>
|
||||
</div>
|
||||
<button id="close" class="close" type="button" title="Close">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
|
||||
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="candidates" class="candidates"></div>
|
||||
<div id="provenance" class="provenance" aria-live="polite"></div>
|
||||
<div id="hints" class="hints"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->
|
||||
<script type="module" src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
200
apps/desktop/src/renderer/popups/suggestion-overlay/script.js
Normal file
200
apps/desktop/src/renderer/popups/suggestion-overlay/script.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// Suggestion Overlay 팝업 스크립트
|
||||
// 입력 인텔리전스: 다음 문장 제안(ghost text)을 커서 옆에 보여준다.
|
||||
//
|
||||
// 상태 세 가지를 화면에 드러낸다:
|
||||
// warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중")
|
||||
// generating - 토큰이 만들어지는 중 (스피너 + 도착한 부분 텍스트)
|
||||
// 후보 도착 - 최대 5개, 목록은 스크롤 가능
|
||||
//
|
||||
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/닫기)은 전역 키바인딩이
|
||||
// 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다.
|
||||
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
var candidatesContainer = document.getElementById('candidates')
|
||||
var provenanceContainer = document.getElementById('provenance')
|
||||
var hintsContainer = document.getElementById('hints')
|
||||
var statusRow = document.getElementById('status')
|
||||
var statusText = document.getElementById('statusText')
|
||||
var panel = document.getElementById('panel')
|
||||
var closeButton = document.getElementById('close')
|
||||
|
||||
/** @type {string[]} */
|
||||
var candidates = []
|
||||
var activeIndex = 0
|
||||
var generating = false
|
||||
var warmingUp = false
|
||||
var partialText = null
|
||||
var provenance = null
|
||||
var i18nStrings = {}
|
||||
var generatingSince = 0
|
||||
var tickTimer = null
|
||||
|
||||
function render() {
|
||||
candidatesContainer.textContent = ''
|
||||
|
||||
var busy = warmingUp || generating
|
||||
if (statusRow) statusRow.hidden = !busy
|
||||
if (statusText) {
|
||||
if (warmingUp) {
|
||||
statusText.textContent = i18nStrings.suggestionWarming || '...'
|
||||
} else if (generating) {
|
||||
// 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다.
|
||||
var seconds = generatingSince
|
||||
? Math.max(1, Math.round((Date.now() - generatingSince) / 1000))
|
||||
: 1
|
||||
var template = i18nStrings.suggestionGenerating || i18nStrings.suggestionLoading || '...'
|
||||
statusText.textContent = template.replace('{{seconds}}', String(seconds))
|
||||
} else {
|
||||
statusText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
// 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌).
|
||||
if (partialText) {
|
||||
var streaming = document.createElement('div')
|
||||
streaming.className = 'suggestion-item streaming'
|
||||
streaming.textContent = partialText
|
||||
candidatesContainer.appendChild(streaming)
|
||||
}
|
||||
renderHints()
|
||||
renderProvenance()
|
||||
return
|
||||
}
|
||||
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
var item = document.createElement('button')
|
||||
item.type = 'button'
|
||||
item.className = i === activeIndex ? 'suggestion-item active' : 'suggestion-item'
|
||||
item.setAttribute('data-index', String(i))
|
||||
item.textContent = candidates[i]
|
||||
item.addEventListener('click', onItemClick)
|
||||
candidatesContainer.appendChild(item)
|
||||
}
|
||||
|
||||
renderHints()
|
||||
renderProvenance()
|
||||
}
|
||||
|
||||
function renderProvenance() {
|
||||
if (!provenanceContainer) return
|
||||
provenanceContainer.textContent = ''
|
||||
if (!provenance) return
|
||||
|
||||
var parts = []
|
||||
var sourceLabel = provenance.mode === 'local-memory'
|
||||
? i18nStrings.suggestionSourceMemory
|
||||
: i18nStrings.suggestionSourceModel
|
||||
if (sourceLabel) parts.push(sourceLabel)
|
||||
var counts = [
|
||||
['continuationCount', 'suggestionContinuations'],
|
||||
['relatedCount', 'suggestionRelated'],
|
||||
['phraseCount', 'suggestionPhrases'],
|
||||
['appPhraseCount', 'suggestionAppPhrases']
|
||||
]
|
||||
for (var i = 0; i < counts.length; i++) {
|
||||
var count = provenance[counts[i][0]] || 0
|
||||
var countLabel = i18nStrings[counts[i][1]]
|
||||
if (count > 0 && countLabel) parts.push(countLabel + ' ' + count)
|
||||
}
|
||||
provenanceContainer.textContent = parts.join(' · ')
|
||||
}
|
||||
|
||||
function renderHints() {
|
||||
hintsContainer.textContent = ''
|
||||
if (candidates.length <= 1 && !generating) return
|
||||
|
||||
var hint = document.createElement('span')
|
||||
hint.className = 'hint'
|
||||
if (candidates.length > 0) {
|
||||
var nextLabel = i18nStrings.suggestionHintNext || 'Next'
|
||||
hint.textContent = nextLabel + ' ' + (activeIndex + 1) + '/' + candidates.length
|
||||
} else {
|
||||
hint.textContent = i18nStrings.suggestionHintGenerating || '…'
|
||||
}
|
||||
hintsContainer.appendChild(hint)
|
||||
}
|
||||
|
||||
function onItemClick(event) {
|
||||
var target = event.currentTarget
|
||||
var index = Number(target.getAttribute('data-index'))
|
||||
if (!window.popupAPI) return
|
||||
window.popupAPI.send('suggestionPopup:accept', index)
|
||||
}
|
||||
|
||||
function applyPayload(payload) {
|
||||
if (!payload) return
|
||||
if (payload._i18n) i18nStrings = payload._i18n
|
||||
candidates = (payload.candidates || []).map(function (candidate) {
|
||||
return candidate && candidate.text ? candidate.text : String(candidate)
|
||||
})
|
||||
activeIndex = payload.activeIndex || 0
|
||||
if (payload.generating !== undefined) generating = payload.generating === true
|
||||
if (payload.warmingUp !== undefined) warmingUp = payload.warmingUp === true
|
||||
if (payload.partialText !== undefined) partialText = payload.partialText || null
|
||||
if (payload.provenance !== undefined) provenance = payload.provenance || null
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
if (tickTimer) return
|
||||
tickTimer = setInterval(function () {
|
||||
if (!generating && !warmingUp) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
generatingSince = 0
|
||||
return
|
||||
}
|
||||
render()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handleShow(payload) {
|
||||
applyPayload(payload)
|
||||
if (payload && payload.generating) generatingSince = Date.now()
|
||||
startTick()
|
||||
if (panel) panel.classList.add('visible')
|
||||
render()
|
||||
}
|
||||
|
||||
function handleUpdate(payload) {
|
||||
var wasGenerating = generating
|
||||
applyPayload(payload)
|
||||
if (generating && !wasGenerating) generatingSince = Date.now()
|
||||
startTick()
|
||||
render()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (panel) panel.classList.remove('visible')
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer)
|
||||
tickTimer = null
|
||||
}
|
||||
generatingSince = 0
|
||||
generating = false
|
||||
warmingUp = false
|
||||
partialText = null
|
||||
provenance = null
|
||||
candidates = []
|
||||
activeIndex = 0
|
||||
candidatesContainer.textContent = ''
|
||||
if (provenanceContainer) provenanceContainer.textContent = ''
|
||||
hintsContainer.textContent = ''
|
||||
}
|
||||
|
||||
if (closeButton) {
|
||||
// 마우스로도 닫을 수 있어야 한다 (키보드만 있으면 불편하다는 피드백).
|
||||
closeButton.addEventListener('click', function () {
|
||||
handleHide()
|
||||
if (window.popupAPI) window.popupAPI.send('suggestionPopup:dismiss')
|
||||
})
|
||||
}
|
||||
|
||||
if (window.popupAPI) {
|
||||
window.popupAPI.on('suggestionPopup:show', handleShow)
|
||||
window.popupAPI.on('suggestionPopup:update', handleUpdate)
|
||||
window.popupAPI.on('suggestionPopup:hide', handleHide)
|
||||
}
|
||||
})()
|
||||
208
apps/desktop/src/renderer/popups/suggestion-overlay/style.css
Normal file
208
apps/desktop/src/renderer/popups/suggestion-overlay/style.css
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/* Suggestion Overlay — 입력 인텔리전스 ghost text
|
||||
*
|
||||
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
|
||||
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--d3-bg-result: #111a30;
|
||||
--d3-border-result: rgba(148, 180, 255, 0.12);
|
||||
--d3-text-result: #eef2fb;
|
||||
--d3-text-secondary: #93a4c8;
|
||||
--d3-accent-main: #3b82f6;
|
||||
--d3-accent-dim: rgba(59, 130, 246, 0.14);
|
||||
--d3-shadow-popup: 0 8px 32px rgba(3, 7, 18, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
-webkit-app-region: no-drag;
|
||||
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.suggestion-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--d3-bg-result);
|
||||
border: 1px solid var(--d3-border-result);
|
||||
box-shadow: var(--d3-shadow-popup);
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity 90ms ease-out, transform 90ms ease-out;
|
||||
}
|
||||
|
||||
.suggestion-panel.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.close {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--d3-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 90ms ease-out, color 90ms ease-out;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-result);
|
||||
}
|
||||
|
||||
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--d3-accent-dim);
|
||||
border-top-color: var(--d3-accent-main);
|
||||
animation: d3-spin 720ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes d3-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
|
||||
.candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 176px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar-thumb {
|
||||
background: var(--d3-accent-dim);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.candidates::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--d3-text-result);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.suggestion-item.active {
|
||||
background: var(--d3-accent-dim);
|
||||
color: var(--d3-text-result);
|
||||
}
|
||||
|
||||
.suggestion-item.loading {
|
||||
color: var(--d3-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
|
||||
.suggestion-item.streaming {
|
||||
color: var(--d3-text-result);
|
||||
opacity: 0.85;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.suggestion-item.streaming::after {
|
||||
content: '\258C';
|
||||
animation: d3-caret-blink 1s steps(1) infinite;
|
||||
color: var(--d3-accent-main);
|
||||
}
|
||||
|
||||
.provenance {
|
||||
min-height: 15px;
|
||||
padding: 0 8px;
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes d3-caret-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px 2px;
|
||||
color: var(--d3-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
white-space: nowrap;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue