feat(desktop+server): Phase 3.2 Premium LLM — Anthropic Claude 프리미엄 파이프라인 + 모델별 쿼터 + SaaS UI
빅뱅 8/8 마지막 성공 기준 달성. Supabase Edge Function(llm-proxy)을 통해 Anthropic Claude를 호출하는 PremiumLLMService 신규 구현. 사용자가 Settings에서 Local/Premium 백엔드를 선택하면 VoiceModeService가 자동 분기하고, Premium 실패 시 Local로 silent fallback + 상단 중앙 배너 알림. 실측: Claude Haiku refine 1.6~3.2초 (이전 qwen3 42.9초 → 13~27배 빠름). 주요 변경: - PremiumLLMService 신규 (싱글톤+EventEmitter, processText/chatStream, Supabase functions.invoke 기반, _ensureAuth 가드) - llm-prompts.ts: SYSTEM_PROMPTS를 Local/Premium 공유 모듈로 추출 (resolveSystemPrompt 헬퍼) - VoiceModeService: _getLLMProcessor → _runProcessorWithFallback 라우터 + premium-llm-fallback 이벤트 - CloudSyncService: getAccessToken(async), getAnonKey, invokeFunction(auth 헤더 자동 처리, 에러 body 파싱) - IPC: LLM.PREMIUM_* 채널 6개 + preload API + llm-handlers 이벤트 전달 (safeSendToRenderer 헬퍼) - AppConfig.llmBackend: 'local' | 'premium' (기본 'local') - Settings UI: Backend 드롭다운 + Premium 선택 시 Ollama UI 숨김 + 라이선스 모달 자동 오픈 - AppLayout: 상단 중앙 Snackbar fallback 배너 (8초, warning filled) - LicenseModal: 라이선스 키 입력 제거 → SaaS 구독 관리 UI 전환 (Free/Pro/Pro+ 업그레이드 버튼, Payple 준비 중 스텁) - 등급 비교 표: featureLabel i18n 번역 수정 서버 (Supabase Edge Functions): - quota.ts: 모델별 쿼터 구조 (llm_haiku/sonnet/opus × free/pro/pro_plus), 주간/일간 기간 분리, modelToQuotaKey 매핑, consumeQuota baseLimit 파라미터화 - llm-proxy: 모델별 쿼터 체크 + 소비 (checkQuota → consumeQuota 원자적), verify_jwt=false (2026 sb_publishable_ 키 호환) - config.toml: llm-proxy verify_jwt = false - migration 20260412000001: tier team→pro_plus 통일, subscriptions.overage_credits 컬럼, consume_quota RPC (원자적 base→overage fallback) Tier/쿼터: - free: Haiku 250/주간, Sonnet/Opus 불가 - pro ₩9,900: Haiku 1500/일, Sonnet 300/일, Opus 50/일 - pro_plus ₩29,900: Haiku 무제한, Sonnet 1500/일, Opus 300/일 - api-client SubscriptionTier: team→pro_plus, overage_credits 필드 추가
This commit is contained in:
parent
d397bcbf57
commit
6e52c18e5b
23 changed files with 1111 additions and 311 deletions
|
|
@ -2,7 +2,7 @@
|
|||
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Tooltip } from '@mui/material'
|
||||
import { Alert, Box, Snackbar, Typography, Tooltip } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||
|
|
@ -62,6 +62,8 @@ export function AppLayout(): React.ReactElement {
|
|||
const [onboardingOpen, setOnboardingOpen] = useState(false)
|
||||
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
|
||||
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
|
||||
// Phase 3.2: Premium LLM fallback 배너 (상단 중앙, 8초, warning filled)
|
||||
const [fallbackMsg, setFallbackMsg] = useState<string | null>(null)
|
||||
|
||||
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
|
||||
useEffect(() => {
|
||||
|
|
@ -89,9 +91,19 @@ export function AppLayout(): React.ReactElement {
|
|||
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
|
||||
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
|
||||
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
|
||||
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
|
||||
setFallbackMsg(e.reason)
|
||||
})
|
||||
const unsubUpgradeReq = window.electronAPI.llm.premium.onUpgradeRequired(() => {
|
||||
setLicenseModalOpen(true)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubTier()
|
||||
unsubUpgrade()
|
||||
unsubFallback()
|
||||
unsubUpgradeReq()
|
||||
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -232,6 +244,18 @@ export function AppLayout(): React.ReactElement {
|
|||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
<LicenseModal open={licenseModalOpen} onClose={() => setLicenseModalOpen(false)} />
|
||||
<OnboardingModal open={onboardingOpen} onClose={() => setOnboardingOpen(false)} />
|
||||
|
||||
{/* Phase 3.2: Premium LLM fallback 배너 — 상단 중앙, 8초, warning filled */}
|
||||
<Snackbar
|
||||
open={fallbackMsg !== null}
|
||||
autoHideDuration={8000}
|
||||
onClose={() => setFallbackMsg(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="warning" variant="filled" onClose={() => setFallbackMsg(null)} sx={{ width: '100%' }}>
|
||||
{fallbackMsg}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import {
|
|||
DialogTitle,
|
||||
DialogContent,
|
||||
Box,
|
||||
TextField,
|
||||
IconButton,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
|
|
@ -21,7 +18,7 @@ import {
|
|||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
||||
import CancelIcon from '@mui/icons-material/Cancel'
|
||||
import { MetalCard, PhosphorText, Led, ScreenPanel, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@d3ro/core/types'
|
||||
|
|
@ -49,25 +46,11 @@ function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (key.length <= 8) return key
|
||||
return key.slice(0, 4) + '-****-****-' + key.slice(-4)
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '-'
|
||||
return new Date(timestamp).toLocaleDateString()
|
||||
}
|
||||
|
||||
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
|
||||
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
|
||||
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [activating, setActivating] = useState(false)
|
||||
const [activateMessage, setActivateMessage] = useState<string | null>(null)
|
||||
const [activateSuccess, setActivateSuccess] = useState(false)
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
window.electronAPI.license.getInfo().then((r) => {
|
||||
|
|
@ -84,9 +67,6 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
useEffect(() => {
|
||||
if (open) {
|
||||
loadData()
|
||||
setKeyInput('')
|
||||
setActivateMessage(null)
|
||||
setActivateSuccess(false)
|
||||
}
|
||||
}, [open, loadData])
|
||||
|
||||
|
|
@ -99,37 +79,13 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
return unsub
|
||||
}, [loadData])
|
||||
|
||||
const handleActivate = useCallback(async () => {
|
||||
if (!keyInput.trim()) return
|
||||
setActivating(true)
|
||||
setActivateMessage(null)
|
||||
try {
|
||||
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
|
||||
if (result.success) {
|
||||
setActivateSuccess(result.data.success)
|
||||
setActivateMessage(
|
||||
result.data.success
|
||||
? t('license.activated')
|
||||
: t('license.activateError', { message: result.data.message }),
|
||||
)
|
||||
if (result.data.success) {
|
||||
loadData()
|
||||
setKeyInput('')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setActivating(false)
|
||||
}
|
||||
}, [keyInput, t, loadData])
|
||||
const currentTier = licenseInfo?.tier ?? 'free'
|
||||
const isFree = currentTier === 'free'
|
||||
const isPro = currentTier === 'pro'
|
||||
|
||||
const handleDeactivate = useCallback(async () => {
|
||||
await window.electronAPI.license.deactivate()
|
||||
setActivateMessage(t('license.deactivated'))
|
||||
setActivateSuccess(false)
|
||||
loadData()
|
||||
}, [t, loadData])
|
||||
|
||||
const isFree = licenseInfo?.tier === 'free'
|
||||
const handleUpgrade = useCallback(() => {
|
||||
alert(t('license.paymentPending'))
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -164,9 +120,9 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3, overflow: 'auto' }}>
|
||||
{/* ---- Current Tier ---- */}
|
||||
<MetalCard>
|
||||
<MetalCard sx={{ overflow: 'visible' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
|
|
@ -178,92 +134,65 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ---- Activate / Info ---- */}
|
||||
<MetalCard>
|
||||
{isFree ? (
|
||||
// Free tier: show activation form
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<PhosphorText variant="meta">{t('license.activate')}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
placeholder={t('license.keyPlaceholder')}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={activating}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleActivate()
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
bgcolor: d3roPalette.bg.input,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<PhysicalButton
|
||||
onClick={handleActivate}
|
||||
disabled={activating || !keyInput.trim()}
|
||||
sx={{ minWidth: 100 }}
|
||||
>
|
||||
{activating ? (
|
||||
<CircularProgress size={16} sx={{ color: d3roPalette.accent.amber }} />
|
||||
) : (
|
||||
t('license.activate')
|
||||
)}
|
||||
{/* ---- Subscription Management ---- */}
|
||||
<MetalCard sx={{ overflow: 'visible' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<PhosphorText variant="meta">{t('license.subscribe')}</PhosphorText>
|
||||
|
||||
{isFree && (
|
||||
<>
|
||||
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="green" size={8} />
|
||||
<PhosphorText variant="compact">
|
||||
{t('license.upgrade')} — {t('license.proPlan')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
{activateMessage && (
|
||||
<PhosphorText
|
||||
variant="small"
|
||||
sx={{ color: activateSuccess ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
||||
>
|
||||
{activateMessage}
|
||||
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="green" size={8} />
|
||||
<PhosphorText variant="compact">
|
||||
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</PhysicalButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPro && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color="green" size={8} pulse />
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.green }}>
|
||||
{t('license.currentPlan')} — {t('license.proPlan')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<PhysicalButton onClick={handleUpgrade} sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Led color="green" size={8} />
|
||||
<PhosphorText variant="compact">
|
||||
{t('license.upgrade')} — {t('license.proPlusPlan')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</PhysicalButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentTier === 'pro_plus' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color="green" size={8} pulse />
|
||||
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.purple }}>
|
||||
{t('license.currentPlan')} — {t('license.proPlusPlan')}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
// Pro/Pro+: show license info
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<PhosphorText variant="meta">{t('license.keyLabel')}</PhosphorText>
|
||||
<ScreenPanel>
|
||||
<Box sx={{ px: 2, py: 1.5 }}>
|
||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
||||
{licenseInfo?.licenseKey ? maskKey(licenseInfo.licenseKey) : '-'}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('license.activatedAt')}</PhosphorText>
|
||||
<PhosphorText variant="compact">
|
||||
{formatDate(licenseInfo?.activatedAt ?? null)}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box>
|
||||
<PhosphorText variant="label">{t('license.machineId')}</PhosphorText>
|
||||
<PhosphorText variant="compact" sx={{ fontFamily: d3roFontMono }}>
|
||||
{licenseInfo?.machineId?.slice(0, 12) ?? '-'}...
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PhysicalButton
|
||||
onClick={handleDeactivate}
|
||||
sx={{ alignSelf: 'flex-start', mt: 1, color: d3roPalette.tag.red }}
|
||||
>
|
||||
{t('license.deactivate')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* ---- Daily Usage ---- */}
|
||||
{usageQuotas.length > 0 && (
|
||||
<MetalCard>
|
||||
<MetalCard sx={{ overflow: 'visible' }}>
|
||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{usageQuotas.map((q) => (
|
||||
|
|
@ -309,7 +238,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
|
||||
{/* ---- Tier Comparison ---- */}
|
||||
{tierComparison.length > 0 && (
|
||||
<MetalCard>
|
||||
<MetalCard sx={{ overflow: 'visible' }}>
|
||||
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
|
||||
<TableContainer>
|
||||
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
|
||||
|
|
@ -333,7 +262,7 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
|
|||
{tierComparison.map((row) => (
|
||||
<TableRow key={row.feature}>
|
||||
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
|
||||
{row.featureLabel}
|
||||
{t(row.featureLabel as Parameters<typeof t>[0])}
|
||||
</TableCell>
|
||||
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
|
||||
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
|
||||
|
|
|
|||
|
|
@ -693,43 +693,78 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.ollamaUrl')}
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.ollamaHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.llmModel')}
|
||||
{t('settings.llmBackend')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.llmModel')}</InputLabel>
|
||||
<InputLabel>{t('settings.llmBackend')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.llmModel')}
|
||||
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
|
||||
label={t('settings.llmBackend')}
|
||||
value={config.llmBackend ?? 'local'}
|
||||
onChange={(e) => {
|
||||
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
|
||||
const next = e.target.value
|
||||
updateConfig('llmBackend', next)
|
||||
if (next === 'premium') {
|
||||
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
|
||||
window.dispatchEvent(new Event('d3ro:open-license-modal'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{llmModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{model.name} ({model.parameterSize})
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="local">{t('settings.backend.local')}</MenuItem>
|
||||
<MenuItem value="premium">{t('settings.backend.premium')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{config.llmBackend === 'premium'
|
||||
? t('settings.backend.premiumHint')
|
||||
: t('settings.backend.localHint')}
|
||||
</Typography>
|
||||
|
||||
{config.llmBackend !== 'premium' && (
|
||||
<>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label={t('settings.ollamaUrl')}
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
||||
{t('settings.ollamaHint')}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
{t('settings.llmModel')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{t('settings.llmModel')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.llmModel')}
|
||||
value={(config as Record<string, unknown>)['llmModelId'] as string ?? ''}
|
||||
onChange={(e) => {
|
||||
updateConfig('llmModelId' as keyof AppConfig, e.target.value as never)
|
||||
}}
|
||||
>
|
||||
{llmModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
{model.name} ({model.parameterSize})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue