Running a custom instruction (translate, summarise, rewrite, explain code,
free prompt) inserted the instruction's own wording instead of the result.
Two faults stacked:
The instruction was passed as the text to process, leaving the system-prompt
argument empty. `BASE_SYSTEM_PROMPTS` has no `custom` key, so resolution fell
back to `refine` without saying so, and the model dutifully polished the
instruction it had been handed. The transcript never reached it.
And only `{{text}}` was substituted, which none of the five built-in
instructions use — they carry `{{targetLanguage}}`, `{{userPrompt}}`, or no
placeholder at all. The substitution was a no-op from the day it was written:
the presets landed ten hours before the code that expected them.
- Instruction prompts now go to the system-prompt argument and the transcript
to the text argument. Instructions that spell out `{{text}}` keep their old
meaning, so hand-written ones still work.
- `renderInstructionPrompt` resolves `{{text}}`, `{{userPrompt}}` and
`{{targetLanguage}}` in one place, and warns by name when a placeholder is
left standing rather than letting it reach the model.
- `resolveSystemPrompt` no longer drops silently to `refine` for `custom`.
- Voice shortcuts no longer die at the `defaultLLMAction === 'none'` gate; an
explicitly named instruction outranks the default. Without one, `none` still
passes the transcript through untouched.
- `translate` receives its target language instead of relying on a default two
call frames away. It is still always English — `AppConfig` has no key for it,
and neither `language` (UI locale) nor `sttLanguage` (source language) can
stand in. Choosing a target language needs a setting and is not in this fix.
- Chains ran instructions with placeholders intact; they share the same
resolution now.
- The command screen's pipeline bench called `llm.generate`, which preload does
not expose, so every run threw and the catch showed the input back as if it
had succeeded. It uses `llm.process` now, over the same path production
takes, and a failure reads as a failure.
Present since the feature shipped: the custom-instruction path has never
worked. Plain actions (refine, summarise, grammar, expand) were unaffected and
are now covered by tests so they stay that way.
649 lines
24 KiB
TypeScript
649 lines
24 KiB
TypeScript
// 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<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 [saving, setSaving] = useState(false)
|
|
const isSavingRef = useRef(false)
|
|
const isDeletingRef = useRef(false)
|
|
|
|
// Interactive Pipeline Test State
|
|
const [testInput, setTestInput] = useState('')
|
|
const [testResult, setTestResult] = useState<string | null>(null)
|
|
const [testError, setTestError] = useState<string | null>(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<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)
|
|
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 (
|
|
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
|
|
<PageHeader
|
|
title={t('commands.title')}
|
|
count={t('commands.count', { count: instructions.length })}
|
|
action={
|
|
<PhysicalButton tone="accent" onClick={openAdd} size="small" trailingIcon={<Plus size={14} />}>
|
|
{t('commands.add')}
|
|
</PhysicalButton>
|
|
}
|
|
/>
|
|
|
|
{/* ── 1. Active Command Hero Cockpit & Pipeline Architecture ── */}
|
|
<DoubleBezelCard innerPadding={3} sx={{ mb: 3.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Box
|
|
sx={{
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: d3roRadius.inner,
|
|
bgcolor: activeInstruction ? d3roPalette.accent.dim : d3roPalette.bg.inset,
|
|
border: `1px solid ${activeInstruction ? d3roPalette.accent.main : d3roPalette.border.subtle}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: activeInstruction ? d3roPalette.accent.light : d3roPalette.text.disabled,
|
|
}}
|
|
>
|
|
<Zap size={22} />
|
|
</Box>
|
|
<Box>
|
|
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel, mb: 0.25 }}>
|
|
{t('commands.activeCommand').toUpperCase()}
|
|
</PhosphorText>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '18px',
|
|
fontWeight: 500,
|
|
color: activeInstruction ? d3roPalette.text.primary : d3roPalette.text.disabled,
|
|
}}
|
|
>
|
|
{activeInstruction ? activeInstruction.name : t('commands.none')}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<TactileBadge
|
|
ledColor={activeInstruction ? 'green' : 'off'}
|
|
ledPulse={Boolean(activeInstruction)}
|
|
tone={activeInstruction ? 'success' : 'default'}
|
|
>
|
|
{activeInstruction ? 'PIPELINE ACTIVE' : 'RAW DICTATION MODE'}
|
|
</TactileBadge>
|
|
</Box>
|
|
|
|
{/* Visual Pipeline Flow Schematic */}
|
|
<Box
|
|
sx={{
|
|
display: 'grid',
|
|
gridTemplateColumns: { xs: '1fr', md: 'repeat(4, 1fr)' },
|
|
gap: 1.5,
|
|
p: 2,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
}}
|
|
>
|
|
{/* Step 1 */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '50%',
|
|
bgcolor: d3roPalette.glass.raised,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: d3roPalette.accent.light,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Mic size={15} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
STEP 01
|
|
</PhosphorText>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', fontWeight: 600, color: d3roPalette.text.primary, whiteSpace: 'nowrap' }}>
|
|
faster-whisper
|
|
</Typography>
|
|
</Box>
|
|
<ArrowRight size={14} style={{ color: d3roPalette.text.disabled, opacity: 0.5 }} />
|
|
</Box>
|
|
|
|
{/* Step 2 */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '50%',
|
|
bgcolor: d3roPalette.glass.raised,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: d3roPalette.tag.green,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<CheckCircle2 size={15} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
STEP 02
|
|
</PhosphorText>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', fontWeight: 600, color: d3roPalette.text.primary, whiteSpace: 'nowrap' }}>
|
|
Phonetic Fix
|
|
</Typography>
|
|
</Box>
|
|
<ArrowRight size={14} style={{ color: d3roPalette.text.disabled, opacity: 0.5 }} />
|
|
</Box>
|
|
|
|
{/* Step 3 */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '50%',
|
|
bgcolor: activeInstruction ? d3roPalette.accent.dim : d3roPalette.glass.raised,
|
|
border: `1px solid ${activeInstruction ? d3roPalette.accent.main : d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: activeInstruction ? d3roPalette.accent.light : d3roPalette.text.disabled,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<Cpu size={15} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
STEP 03
|
|
</PhosphorText>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', fontWeight: 600, color: d3roPalette.text.primary, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{activeInstruction ? activeInstruction.name : 'No LLM Polish'}
|
|
</Typography>
|
|
</Box>
|
|
<ArrowRight size={14} style={{ color: d3roPalette.text.disabled, opacity: 0.5 }} />
|
|
</Box>
|
|
|
|
{/* Step 4 */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '50%',
|
|
bgcolor: d3roPalette.glass.raised,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: d3roPalette.text.primary,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<ClipboardCopy size={15} />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
STEP 04
|
|
</PhosphorText>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', fontWeight: 600, color: d3roPalette.text.primary, whiteSpace: 'nowrap' }}>
|
|
Active Window
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</DoubleBezelCard>
|
|
|
|
{/* ── 2. Commands Grid ──────────────────────────────────────── */}
|
|
<Box sx={{ mb: 4 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.75 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
|
|
AVAILABLE AI INSTRUCTIONS ({instructions.length})
|
|
</PhosphorText>
|
|
</Box>
|
|
|
|
{loading ? (
|
|
<Box sx={{ py: 6, textAlign: 'center' }}>
|
|
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
|
|
</Box>
|
|
) : instructions.length === 0 ? (
|
|
<EmptyStateCard message={t('commands.noCommands')} />
|
|
) : (
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 1.75 }}>
|
|
{instructions.map((inst) => {
|
|
const isActive = inst.id === activeId
|
|
return (
|
|
<Box
|
|
key={inst.id}
|
|
onClick={() => handleActivate(inst.id)}
|
|
sx={{
|
|
cursor: 'pointer',
|
|
borderRadius: d3roRadius.card,
|
|
border: `1px solid ${isActive ? d3roPalette.accent.main : 'transparent'}`,
|
|
transition: 'all 0.2s ease',
|
|
}}
|
|
>
|
|
<MetalCard sx={{ p: 2.5, height: '100%' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, flex: 1, minWidth: 0 }}>
|
|
<Box sx={{ pt: 0.25 }}>
|
|
{isActive ? (
|
|
<CircleCheck size={18} style={{ color: d3roPalette.accent.light }} />
|
|
) : (
|
|
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={7} />
|
|
)}
|
|
</Box>
|
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
<Box
|
|
sx={{
|
|
fontSize: d3roTypo.heading.size,
|
|
fontWeight: 500,
|
|
fontFamily: d3roFontSans,
|
|
color: isActive ? d3roPalette.accent.light : d3roPalette.text.primary,
|
|
mb: 0.5,
|
|
}}
|
|
>
|
|
{inst.name}
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
fontSize: d3roTypo.small.size,
|
|
color: d3roPalette.text.secondary,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{inst.description}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
|
<Tooltip title={t('common.edit')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => 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 },
|
|
}}
|
|
>
|
|
<Pencil size={14} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
{!inst.isBuiltin && (
|
|
<Tooltip title={t('common.delete')}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => 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 },
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</MetalCard>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* ── 3. Interactive Pipeline Test Sandbox ───────────────────── */}
|
|
<Box sx={{ mb: 4 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1.5, display: 'block' }}>
|
|
PIPELINE TEST BENCH
|
|
</PhosphorText>
|
|
<MetalCard sx={{ p: 2.75 }}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
|
<TextField
|
|
value={testInput}
|
|
onChange={(e) => setTestInput(e.target.value)}
|
|
placeholder="테스트할 음성 전사 문장을 입력하거나 마이크로 받아쓰기 해보세요..."
|
|
size="small"
|
|
fullWidth
|
|
sx={{
|
|
'& .MuiOutlinedInput-root': {
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '13px',
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: d3roRadius.inner,
|
|
},
|
|
}}
|
|
/>
|
|
<PhysicalButton
|
|
tone="accent"
|
|
onClick={handleRunPipelineTest}
|
|
disabled={!testInput.trim() || testing}
|
|
sx={{ height: 40, px: 2.5, whiteSpace: 'nowrap' }}
|
|
>
|
|
<Play size={14} style={{ marginRight: 4 }} />
|
|
{testing ? 'Processing...' : 'Run Pipeline'}
|
|
</PhysicalButton>
|
|
</Box>
|
|
|
|
{testResult && (
|
|
<Box
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 1.5,
|
|
}}
|
|
>
|
|
<Sparkles size={16} style={{ color: d3roPalette.accent.light, marginTop: 2, flexShrink: 0 }} />
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13.5px', color: d3roPalette.text.primary, lineHeight: 1.6 }}>
|
|
{testResult}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{testError && (
|
|
<Box
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: d3roPalette.status.dangerBg,
|
|
borderRadius: d3roRadius.inner,
|
|
border: `1px solid ${d3roPalette.status.danger}`,
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 1.5,
|
|
}}
|
|
>
|
|
<TriangleAlert size={16} style={{ color: d3roPalette.status.danger, marginTop: 2, flexShrink: 0 }} />
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13.5px', color: d3roPalette.status.danger, lineHeight: 1.6 }}>
|
|
{testError}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</MetalCard>
|
|
</Box>
|
|
|
|
{/* ── 4. Voice Keyword Triggers Section ─────────────────────── */}
|
|
<Box sx={{ mt: 4 }}>
|
|
<VoiceKeywordsSection />
|
|
</Box>
|
|
|
|
{/* Add / Edit Dialog */}
|
|
<Dialog
|
|
open={dialogOpen}
|
|
onClose={() => 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,
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle sx={{ fontWeight: 500, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
|
|
{editId ? t('commands.editTitle') : t('commands.addTitle')}
|
|
</DialogTitle>
|
|
<DialogContent sx={{ px: 2.5 }}>
|
|
<TextField
|
|
label={t('commands.name')}
|
|
value={formName}
|
|
onChange={(e) => setFormName(e.target.value)}
|
|
fullWidth
|
|
autoFocus
|
|
sx={{ mt: 1.5 }}
|
|
/>
|
|
<TextField
|
|
label={t('commands.description')}
|
|
value={formDesc}
|
|
onChange={(e) => setFormDesc(e.target.value)}
|
|
fullWidth
|
|
sx={{ mt: 2.5 }}
|
|
/>
|
|
<TextField
|
|
label={t('commands.promptTemplate')}
|
|
value={formPrompt}
|
|
onChange={(e) => setFormPrompt(e.target.value)}
|
|
fullWidth
|
|
multiline
|
|
rows={4}
|
|
sx={{ mt: 2.5 }}
|
|
helperText={t('commands.promptHelp')}
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
|
|
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
|
|
{t('common.cancel')}
|
|
</PhysicalButton>
|
|
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formName.trim() || saving}>
|
|
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
|
|
</PhysicalButton>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function VoiceKeywordsSection(): React.ReactElement {
|
|
return (
|
|
<Box sx={{ mt: 3 }}>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 1.5, display: 'block' }}>
|
|
VOICE KEYWORD TRIGGERS & FILLER FILTER
|
|
</PhosphorText>
|
|
<TemplateSection />
|
|
</Box>
|
|
)
|
|
}
|