d3ro-voice/apps/desktop/src/renderer/pages/CommandsPage.tsx
Yun Chan 99f06c253c fix(llm): stop leaking instruction prompts instead of processed text
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.
2026-09-21 14:39:26 +09:00

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>
)
}