d3ro-voice/src/renderer/pages/CommandsPage.tsx
Yun Chan ea48168b1e 실시간 UI 갱신: 세션 완료/명령어 변경 시 렌더러 자동 리로드
- bootstrap: notifyRenderer('app:dataChanged') 이벤트 발행
  - session-completed: Dashboard 통계 + History 갱신
  - command:selected: CMD 페이지 활성 명령어 갱신
- preload: app.onDataChanged 이벤트 리스너 추가
- DashboardPage/CommandsPage/HistoryPage: onDataChanged 구독하여 자동 loadData()
2026-04-05 13:31:57 +09:00

223 lines
8.9 KiB
TypeScript

// src/renderer/pages/CommandsPage.tsx
// 커스텀 LLM 명령어 관리: 클릭하면 활성 명령어로 설정 → 다음 녹음 시 적용
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Typography } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import { MetalCard, PhosphorText, Led } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme'
interface CustomInstruction {
id: string
name: string
description: string
prompt: string
icon: string
isBuiltin: boolean
order: number
}
export function CommandsPage(): React.ReactElement {
const [instructions, setInstructions] = useState<CustomInstruction[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [editId, setEditId] = useState<string | null>(null)
const [formName, setFormName] = useState('')
const [formDesc, setFormDesc] = useState('')
const [formPrompt, setFormPrompt] = useState('')
const [activeId, setActiveId] = useState<string | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
const [instrResult, configResult] = await Promise.all([
window.electronAPI.instruction.getAll(),
window.electronAPI.config.getAll(),
])
if (instrResult.success) {
setInstructions(instrResult.data as CustomInstruction[])
}
if (configResult.success) {
const cfg = configResult.data as Record<string, unknown>
setActiveId((cfg['activeInstructionId'] as string) ?? null)
}
setLoading(false)
}, [])
useEffect(() => {
loadData()
const unsub = window.electronAPI.app.onDataChanged((data) => {
if (data.type === 'command-changed' && data.activeId) {
setActiveId(data.activeId)
}
loadData()
})
return unsub
}, [loadData])
const handleActivate = (id: string) => {
const newId = activeId === id ? null : id // 토글: 같은 거 누르면 해제
setActiveId(newId)
// ConfigService에 저장 + defaultLLMAction을 'custom'으로 변경
if (newId) {
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: newId as never })
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' })
} else {
window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@shared/types').AppConfig, value: '' as never })
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
}
}
const openAdd = () => {
setEditId(null)
setFormName('')
setFormDesc('')
setFormPrompt('{{text}}를 다듬어주세요.')
setDialogOpen(true)
}
const openEdit = (inst: CustomInstruction) => {
setEditId(inst.id)
setFormName(inst.name)
setFormDesc(inst.description)
setFormPrompt(inst.prompt)
setDialogOpen(true)
}
const handleSave = async () => {
if (!formName.trim()) return
if (editId) {
await window.electronAPI.instruction.update({
id: editId,
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
} else {
await window.electronAPI.instruction.create({
name: formName.trim(),
description: formDesc.trim(),
prompt: formPrompt.trim(),
})
}
setDialogOpen(false)
loadData()
}
const handleDelete = async (id: string) => {
await window.electronAPI.instruction.delete({ id })
if (activeId === id) {
setActiveId(null)
window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' })
}
loadData()
}
const activeInstruction = instructions.find((i) => i.id === activeId)
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
LLM INSTRUCTIONS {instructions.length} COMMANDS
</PhosphorText>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
ADD
</Button>
</Box>
{/* 활성 명령어 표시 */}
<Box sx={{ mb: 3, p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: 'inset 0 1px 4px rgba(0,0,0,0.3)' }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '1px', mb: 0.5 }}>
ACTIVE COMMAND
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '13px', color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled }}>
{activeInstruction ? `${activeInstruction.name}` : '없음 — 명령어를 클릭하여 활성화'}
</Typography>
</Box>
{loading ? (
<PhosphorText variant="dim">LOADING...</PhosphorText>
) : instructions.length === 0 ? (
<MetalCard>
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">NO COMMANDS CLICK ADD TO CREATE</PhosphorText>
</Box>
</MetalCard>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{instructions.map((inst) => {
const isActive = inst.id === activeId
return (
<Box
key={inst.id}
onClick={() => handleActivate(inst.id)}
sx={{
cursor: 'pointer',
borderRadius: '22px',
border: isActive ? `2px solid ${d3roPalette.accent.amber}` : '2px solid transparent',
transition: 'border-color 0.15s ease',
}}
>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
{isActive ? (
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
) : (
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
)}
<Box>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary }}>
{inst.name}
</Box>
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>
{inst.description}
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => openEdit(inst)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{!inst.isBuiltin && (
<IconButton
size="small"
onClick={() => handleDelete(inst.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</Box>
</Box>
</MetalCard>
</Box>
)
})}
</Box>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
{editId ? '명령어 편집' : '명령어 추가'}
</DialogTitle>
<DialogContent>
<TextField label="이름" value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label="설명" value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
<TextField label="프롬프트 템플릿" value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText="{{text}}는 전사된 텍스트로 치환됩니다" />
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}></Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}></Button>
</DialogActions>
</Dialog>
</Box>
)
}