// src/renderer/pages/CommandsPage.tsx // Custom LLM Commands, Voice Keywords & Multi-Step LLM Pipeline Chains import React, { useState, useEffect, useCallback, useRef } from 'react' import { Box, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Tooltip, Typography, IconButton, } from '@mui/material' import { Plus, Trash2, Pencil, CircleCheck, Play, Zap, ArrowRight, Sparkles, CheckCircle2, Mic, Cpu, ClipboardCopy, TriangleAlert, } from 'lucide-react' import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge, } from '@d3ro/ui/components/ds' import { PageHeader, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import { TemplateSection } from '../components/TemplateSection' interface CustomInstruction { id: string name: string description: string prompt: string icon: string isBuiltin: boolean order: number } export function CommandsPage(): React.ReactElement { const { t } = useI18n() const [instructions, setInstructions] = useState([]) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [editId, setEditId] = useState(null) const [formName, setFormName] = useState('') const [formDesc, setFormDesc] = useState('') const [formPrompt, setFormPrompt] = useState('') const [activeId, setActiveId] = useState(null) const [saving, setSaving] = useState(false) const isSavingRef = useRef(false) const isDeletingRef = useRef(false) // Interactive Pipeline Test State const [testInput, setTestInput] = useState('') const [testResult, setTestResult] = useState(null) const [testError, setTestError] = useState(null) const [testing, setTesting] = useState(false) 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 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) if (newId) { window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@d3ro/core/types').AppConfig, value: newId as never, }) window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'custom' }) } else { window.electronAPI.config.set({ key: 'activeInstructionId' as keyof import('@d3ro/core/types').AppConfig, value: '' as never, }) window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' }) } } const openAdd = () => { if (dialogOpen) return setEditId(null) setFormName('') setFormDesc('') setFormPrompt(t('commands.defaultPrompt')) setDialogOpen(true) } const openEdit = (inst: CustomInstruction) => { if (dialogOpen) return setEditId(inst.id) setFormName(inst.name) setFormDesc(inst.description) setFormPrompt(inst.prompt) setDialogOpen(true) } const handleSave = async () => { if (!formName.trim() || saving || isSavingRef.current) return isSavingRef.current = true setSaving(true) try { 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() } finally { setSaving(false) isSavingRef.current = false } } const handleDelete = async (id: string) => { if (isDeletingRef.current) return isDeletingRef.current = true try { await window.electronAPI.instruction.delete({ id }) if (activeId === id) { setActiveId(null) window.electronAPI.config.set({ key: 'defaultLLMAction', value: 'none' }) } loadData() } finally { isDeletingRef.current = false } } // 이 벤치는 실제 받아쓰기와 같은 경로(llm.process → processText)를 탄다. // 지시문 원문을 customPrompt로 넘기면 메인 프로세스가 플레이스홀더 치환과 // 시스템 프롬프트 배치를 처리한다 — 치환 규칙을 렌더러에 복제하지 않는다. const handleRunPipelineTest = async () => { if (!testInput.trim()) return setTesting(true) setTestResult(null) setTestError(null) try { const activeInst = instructions.find((i) => i.id === activeId) const res = await window.electronAPI.llm.process( activeInst ? { text: testInput.trim(), action: 'custom', customPrompt: activeInst.prompt } : { text: testInput.trim(), action: 'refine' }, ) if (res.success) { setTestResult(res.data.processedText) } else { setTestError(res.error.message || t('popup.error.default')) } } catch (error) { setTestError(error instanceof Error ? error.message : t('popup.error.default')) } finally { setTesting(false) } } const activeInstruction = instructions.find((i) => i.id === activeId) return ( }> {t('commands.add')} } /> {/* ── 1. Active Command Hero Cockpit & Pipeline Architecture ── */} {t('commands.activeCommand').toUpperCase()} {activeInstruction ? activeInstruction.name : t('commands.none')} {activeInstruction ? 'PIPELINE ACTIVE' : 'RAW DICTATION MODE'} {/* Visual Pipeline Flow Schematic */} {/* Step 1 */} STEP 01 faster-whisper {/* Step 2 */} STEP 02 Phonetic Fix {/* Step 3 */} STEP 03 {activeInstruction ? activeInstruction.name : 'No LLM Polish'} {/* Step 4 */} STEP 04 Active Window {/* ── 2. Commands Grid ──────────────────────────────────────── */} AVAILABLE AI INSTRUCTIONS ({instructions.length}) {loading ? ( {t('common.loading')} ) : instructions.length === 0 ? ( ) : ( {instructions.map((inst) => { const isActive = inst.id === activeId return ( handleActivate(inst.id)} sx={{ cursor: 'pointer', borderRadius: d3roRadius.card, border: `1px solid ${isActive ? d3roPalette.accent.main : 'transparent'}`, transition: 'all 0.2s ease', }} > {isActive ? ( ) : ( )} {inst.name} {inst.description} e.stopPropagation()}> openEdit(inst)} sx={{ p: 0.6, color: d3roPalette.text.inactive, bgcolor: d3roPalette.glass.raised, border: `1px solid ${d3roPalette.glass.hairline}`, '&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.hairlineStrong }, }} > {!inst.isBuiltin && ( handleDelete(inst.id)} sx={{ p: 0.6, color: d3roPalette.text.inactive, bgcolor: d3roPalette.glass.raised, border: `1px solid ${d3roPalette.glass.hairline}`, '&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg }, }} > )} ) })} )} {/* ── 3. Interactive Pipeline Test Sandbox ───────────────────── */} PIPELINE TEST BENCH setTestInput(e.target.value)} placeholder="테스트할 음성 전사 문장을 입력하거나 마이크로 받아쓰기 해보세요..." size="small" fullWidth sx={{ '& .MuiOutlinedInput-root': { fontFamily: d3roFontSans, fontSize: '13px', bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.inner, }, }} /> {testing ? 'Processing...' : 'Run Pipeline'} {testResult && ( {testResult} )} {testError && ( {testError} )} {/* ── 4. Voice Keyword Triggers Section ─────────────────────── */} {/* Add / Edit Dialog */} setDialogOpen(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, borderRadius: d3roRadius.doubleBezelOuter, border: `1px solid ${d3roPalette.glass.hairlineStrong}`, boxShadow: d3roShadow.dialog, p: 1, }, }} > {editId ? t('commands.editTitle') : t('commands.addTitle')} setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1.5 }} /> setFormDesc(e.target.value)} fullWidth sx={{ mt: 2.5 }} /> setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2.5 }} helperText={t('commands.promptHelp')} /> setDialogOpen(false)}> {t('common.cancel')} {saving ? t('common.saving') || '저장 중...' : t('common.save')} ) } function VoiceKeywordsSection(): React.ReactElement { return ( VOICE KEYWORD TRIGGERS & FILLER FILTER ) }