feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -24,19 +24,22 @@ import {
Stack,
Paper,
} from '@mui/material'
import { X, Keyboard, Pencil, Mic, Lock, Cloud } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, Sparkles, CheckCircle2 } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey'
import { LicenseTab } from './LicenseTab'
import { CloudSyncSection } from './CloudSyncSection'
import { STTTab } from './STTTab'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice } from '@d3ro/core/types'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
interface SettingsModalProps {
open: boolean
initialTab?: number
onClose: () => void
}
@ -181,12 +184,18 @@ function VoiceModeCard({
)
}
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement {
const { t, locale, setLocale } = useI18n()
const [activeTab, setActiveTab] = useState(0)
const [activeTab, setActiveTab] = useState(initialTab)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
useEffect(() => {
if (open && initialTab !== undefined) {
setActiveTab(initialTab)
}
}, [open, initialTab])
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
@ -199,8 +208,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
// LLM 모델 목록
const [llmModels, setLlmModels] = useState<Array<{ id: string; name: string; parameterSize: string }>>([])
// 오디오 디바이스
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
@ -208,10 +215,32 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
const [micTesting, setMicTesting] = useState(false)
const [micLevel, setMicLevel] = useState(0)
// Ollama 상태
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
const [ollamaModels, setOllamaModels] = useState<LLMModel[]>([])
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaFeedback, setOllamaFeedback] = useState<string | null>(null)
const refreshOllama = useCallback(async () => {
setCheckingOllama(true)
try {
const res = await window.electronAPI.llm.checkConnection()
if (res.success && res.data) {
setOllamaModels(res.data.models)
}
const st = await window.electronAPI.llm.getStatus()
if (st.success) setOllamaStatus(st.data)
} finally {
setCheckingOllama(false)
}
}, [])
// 설정 로드
useEffect(() => {
if (!open) return
setLoading(true)
refreshOllama()
Promise.all([
window.electronAPI.config.getAll(),
@ -240,11 +269,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
if (selectedResult.success && selectedResult.data) setSelectedDeviceId(selectedResult.data)
})
// LLM 모델 목록 로드
window.electronAPI.llm.getModels().then((resp) => {
if (resp.success) setLlmModels(resp.data)
})
}, [open])
}, [open, refreshOllama])
const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
@ -618,185 +643,189 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>{t('settings.whisperModel')}</InputLabel>
<Select
label={t('settings.whisperModel')}
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">{t('settings.model.tiny')}</MenuItem>
<MenuItem value="base">{t('settings.model.base')}</MenuItem>
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
<MenuItem value="large-v3-turbo">{t('settings.model.largeTurbo')}</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
<Select
label={t('settings.sttLanguage')}
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.diarization')}
</Typography>
<TextField
label={t('settings.hfToken')}
type="password"
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
fullWidth
size="small"
helperText={t('settings.hfTokenHint')}
/>
<FormControlLabel
control={
<Switch
checked={(config as Record<string, unknown>)['diarizationEnabled'] as boolean ?? false}
onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.purple,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size }}>{t('settings.diarization')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.diarizationHint')}
</Typography>
</Box>
}
/>
</Box>
<STTTab config={config} updateConfig={updateConfig} />
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<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.llmBackend')}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* LLM 백엔드 선택 */}
<Box>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px', mb: 1, display: 'block' }}>
AI 처리 백엔드 선택
</Typography>
<FormControl size="small" fullWidth>
<Select
value={config.llmBackend ?? 'local'}
onChange={(e) => {
const val = e.target.value as 'local' | 'online'
updateConfig('llmBackend', val)
updateConfig('appUsageMode', val)
}}
>
<MenuItem value="local">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={16} color={d3roPalette.tag.green} />
<span>로컬 Ollama 엔진 (100% 무료 / 오프라인 독립 실행)</span>
</Box>
</MenuItem>
<MenuItem value="online">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={16} color={d3roPalette.tag.blue} />
<span>D3RO 온라인 클라우드 API</span>
</Box>
</MenuItem>
</Select>
</FormControl>
</Box>
<FormControl size="small">
<InputLabel>{t('settings.llmBackend')}</InputLabel>
<Select
label={t('settings.llmBackend')}
value={config.llmBackend ?? 'local'}
onChange={(e) => {
const next = e.target.value
updateConfig('llmBackend', next)
if (next === 'premium') {
// Premium 선택 시 항상 라이선스 모달 — 현재 티어/쿼터/업그레이드 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
<MenuItem value="local">{t('settings.backend.local')}</MenuItem>
<MenuItem value="premium">{t('settings.backend.premium')}</MenuItem>
</Select>
</FormControl>
{/* 로컬 Ollama 설정 */}
{config.llmBackend !== 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Ollama 연결 상태 카드 */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaStatus?.connectionState === 'connected'
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
설치된 모델: {ollamaModels.length}개 | 현재 활성: {config.llmModelId ?? 'gemma2:2b'}
</Typography>
</Box>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{config.llmBackend === 'premium'
? t('settings.backend.premiumHint')
: t('settings.backend.localHint')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
onClick={refreshOllama}
disabled={checkingOllama}
sx={{ fontSize: '11px', fontFamily: d3roFontMono }}
>
연결 확인
</Button>
{ollamaStatus?.connectionState !== 'connected' && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={async () => {
setStartingOllama(true)
setOllamaFeedback(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') setOllamaFeedback('Ollama가 이미 실행 중입니다.')
else if (res.data === 'starting') {
setOllamaFeedback('Ollama 서버를 시작했습니다.')
setTimeout(() => refreshOllama(), 2000)
} else if (res.data === 'not-installed') {
setOllamaFeedback('Ollama가 설치되어 있지 않습니다. 가이드를 열어 설치해 주세요.')
}
}
} finally {
setStartingOllama(false)
}
}}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
>
{startingOllama ? '실행 중' : 'Ollama 실행'}
</Button>
)}
<Button
size="small"
variant="text"
startIcon={<HelpCircle size={13} />}
onClick={() => window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))}
sx={{ fontSize: '11px', color: d3roPalette.accent.main }}
>
가이드/모델 다운로드
</Button>
</Box>
</Paper>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{ollamaFeedback && (
<Typography sx={{ fontSize: '11px', color: d3roPalette.tag.orange }}>
{ollamaFeedback}
</Typography>
)}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.conversationBackend')}
</Typography>
{/* 서버 URL & 모델 선택 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
size="small"
/>
<FormControl size="small">
<InputLabel>{t('settings.conversationBackend')}</InputLabel>
<Select
label={t('settings.conversationBackend')}
value={config.conversationBackend ?? 'local'}
onChange={(e) => {
const next = e.target.value
updateConfig('conversationBackend', next)
if (next === 'realtime') {
// Realtime은 로그인+구독 필요 — 라이선스 모달로 현재 티어 안내
window.dispatchEvent(new Event('d3ro:open-license-modal'))
}
}}
>
<MenuItem value="local">{t('settings.convBackend.local')}</MenuItem>
<MenuItem value="realtime">{t('settings.convBackend.realtime')}</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{config.conversationBackend === 'realtime'
? t('settings.convBackend.realtimeHint')
: t('settings.convBackend.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>
<FormControl size="small" fullWidth>
<InputLabel>활성 Ollama 모델</InputLabel>
<Select
label="활성 Ollama 모델"
value={config.llmModelId ?? 'gemma2:2b'}
onChange={(e) => {
const val = e.target.value
updateConfig('llmModelId', val)
window.electronAPI.llm.setModel({ modelId: val })
}}
>
{/* 설치된 모델 목록 */}
{ollamaModels.map((m) => (
<MenuItem key={m.id} value={m.name}>
{m.name} ({Math.round(m.sizeBytes / (1024 * 1024 * 1024) * 10) / 10} GB)
</MenuItem>
))}
{/* 설치된 모델 목록이 없거나 기본 추천 추가 */}
{ollamaModels.length === 0 && (
<MenuItem value="gemma2:2b">gemma2:2b (추천)</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'llama3.2:3b') && (
<MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'qwen2.5:3b') && (
<MenuItem value="qwen2.5:3b">qwen2.5:3b</MenuItem>
)}
</Select>
</FormControl>
</Box>
</Box>
)}
{/* 온라인 API 설정 */}
{config.llmBackend === 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
label="API 서버 URL"
value={config.onlineApiUrl ?? 'http://localhost:5000'}
onChange={(e) => updateConfig('onlineApiUrl', e.target.value)}
fullWidth
size="small"
/>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{t('settings.ollamaHint')}
계정 상태: {config.authToken ? `${config.userEmail ?? '로그인됨'} (온라인 API 가용)` : '로그인 필요'}
</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>
</>
</Box>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />