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
|
|
@ -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