// 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([]) 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 => { await window.electronAPI.inputTelemetry.setEnabled({ enabled: next }) await refresh() }, [refresh] ) const handlePause = useCallback( async (next: boolean): Promise => { await window.electronAPI.inputTelemetry.setPaused({ paused: next }) await refresh() }, [refresh] ) const handleLearn = useCallback( async (next: boolean): Promise => { await window.electronAPI.suggestion.setConfig({ learnTypedText: next }) await refresh() }, [refresh] ) const handleSuggestionToggle = useCallback( async (next: boolean): Promise => { await window.electronAPI.suggestion.setConfig({ enabled: next }) await refresh() }, [refresh] ) const handleOverlayInteractive = useCallback( async (next: boolean): Promise => { await window.electronAPI.suggestion.setConfig({ overlayInteractive: next }) await refresh() }, [refresh] ) const handleModel = useCallback( async (modelId: string): Promise => { await window.electronAPI.suggestion.setConfig({ modelId: modelId || null }) await refresh() }, [refresh] ) const handleExcludedAppsBlur = useCallback(async (): Promise => { 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 => { 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 => { 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 ( {/* ── 동의 ─────────────────────────────────────── */} {t('input.consent.title')} {t('input.consent.description')} {t('input.privacy.title')} {t('input.privacy.localOnly')} {t('input.privacy.rawKeys')} {receipt ? ( {t('input.privacy.activityRetention')} {t('input.privacy.days', { days: receipt.retention.activityDays })} {t('input.privacy.typingSamplesRetention')} {t('input.privacy.days', { days: receipt.retention.typingSamplesDays })} {t('input.privacy.suggestionRetention')} {t('input.privacy.days', { days: receipt.retention.suggestionDays })} {t('input.privacy.learnedRetention')} {t('input.privacy.untilDeleted')} {t('input.privacy.activityBuckets')} {receipt.counts.activityBuckets.toLocaleString()} {t('input.privacy.typingSamples')} {receipt.counts.typingSamples.toLocaleString()} {t('input.privacy.personalPhrases')} {receipt.counts.personalPhrases.toLocaleString()} {t('input.privacy.suggestions')} {receipt.counts.suggestions.toLocaleString()} ) : receiptUnavailable ? ( {t('input.privacy.unavailable')} ) : null} void handleConsent(e.target.checked)} /> } label={ {t('input.consent.collect')} } /> {telemetry?.running ? t('input.consent.running') : t('input.consent.stopped')} {/* 실시간 진단 — "왜 제안이 안 뜨는지" 를 사용자가 직접 볼 수 있게 한다. */} {t('input.diagnostics.title')} {snapshot ? t('input.diagnostics.app', { app: snapshot.appName ?? t('input.diagnostics.unknownApp') }) : t('input.diagnostics.noSnapshot')} {snapshot?.isPassword ? t('input.diagnostics.password') : snapshot?.editable ? t('input.diagnostics.readable', { source: snapshot.textSource, length: snapshot.textLength }) : t('input.diagnostics.notReadable')} {snapshot?.composing ? ( {t('input.diagnostics.composing')} ) : null} {snapshot?.caretFallback && snapshot.editable ? ( {t('input.diagnostics.caretFallback')} ) : null} {exclusionRecommendation ? ( {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') })} ) : null} void handlePause(e.target.checked)} />} label={ {t('input.consent.pause')} } /> void handleLearn(e.target.checked)} /> } label={ {t('input.consent.learnText')} } /> {t('input.consent.learnTextHint')} setExcludedApps(e.target.value)} onBlur={() => void handleExcludedAppsBlur()} helperText={t('input.consent.excludedAppsHint')} InputProps={{ sx: { fontSize: d3roTypo.compact.size } }} /> {feedback ? ( {feedback.message} ) : null} {t('input.insights.statsMovedHint')} {/* ── 제안 정책 ─────────────────────────────────── */} {t('input.suggestion.title')} {t('input.suggestion.description')} void handleSuggestionToggle(e.target.checked)} /> } label={ {t('input.suggestion.enabled')} } /> {suggestion?.modelAvailable ? t('input.suggestion.modelReady') : t('input.suggestion.modelMissing')} setTriggerDelayMs(e.target.value)} onBlur={() => void window.electronAPI.suggestion .setConfig({ triggerDelayMs: Number(triggerDelayMs) }) .then(() => refresh()) } sx={{ width: 150 }} /> setMinPrefixChars(e.target.value)} onBlur={() => void window.electronAPI.suggestion .setConfig({ minPrefixChars: Number(minPrefixChars) }) .then(() => refresh()) } sx={{ width: 150 }} /> setRequestTimeoutMs(e.target.value)} onBlur={() => void window.electronAPI.suggestion .setConfig({ requestTimeoutMs: Number(requestTimeoutMs) }) .then(() => refresh()) } sx={{ width: 150 }} /> void handleOverlayInteractive(e.target.checked)} /> } label={ {t('input.suggestion.overlayInteractive')} } /> {/* 상태 표시 — 스위치로 두면 설정처럼 보여서 "왜 못 켜지?" 가 된다. */} {t('input.suggestion.onScreenLabel')} {suggestion?.visible ? t('input.suggestion.onScreen') : t('input.suggestion.offScreen')} {suggestion?.generating ? ( {t('input.suggestion.generating')} ) : null} {t('input.suggestion.keyHint')} {t('input.suggestion.usage', { requests: suggestion?.requestsToday ?? 0, budget: suggestion?.dailyBudget ?? 0 })} {suggestion?.lastLatencyMs !== null && suggestion?.lastLatencyMs !== undefined ? ( {t('input.suggestion.latency', { ms: suggestion.lastLatencyMs })} ) : null} {suggestion?.lastSkipReason ? ( {t('input.suggestion.lastSkip', { reason: suggestion.lastSkipReason })} ) : null} ) }