Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템

Phase 10 킬러 피처:
- MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB)
- VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종
- ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트
- ChainService: LLM 명령어 순차 실행 파이프라인
- CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백

VoiceModeService 파이프라인 통합:
- 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입

시스템 오디오 캡처:
- setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지)
- electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현

Phase 11 수익화:
- LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API
- Feature Gate: requireFeature/checkFeature/consumeFeature
- 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage)
- LicenseModal, ProBadge, UpgradePromptModal UI

디자인 보강:
- d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템
- ScreenPanel, ButtonGroup DS 컴포넌트 신규
- PhosphorText 4→13종 변형, MetalDial conic-gradient 광택
- 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard

기타:
- 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings)
- StatusBar 자막 LED + 효과음, 자막 로딩 UI
- LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged)
- 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
Yun Chan 2026-04-05 21:36:09 +09:00
parent 36d77ca224
commit a31f96bbb8
97 changed files with 11853 additions and 1143 deletions

View file

@ -1,14 +1,20 @@
// src/renderer/pages/CommandsPage.tsx
// 커스텀 LLM 명령어 관리: 클릭하면 활성 명령어로 설정 → 다음 녹음 시 적용
// 커스텀 LLM 명령어 관리 + Phase 10: 음성 키워드 + LLM 체인
// 3 섹션: 명령어 목록 / 음성 키워드 / LLM 체인
import { useState, useEffect, useCallback } from 'react'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Typography } from '@mui/material'
import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, TextField, Chip, Select, MenuItem, FormControl, InputLabel } 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'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import LinkIcon from '@mui/icons-material/Link'
import { MetalCard, PhosphorText, Led, ScreenPanel } from '../components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../theme'
import { useI18n } from '../i18n'
import type { VoiceCommandRule, VoiceCommandKeyword, KeywordMatchMode, LLMChain, ChainStep } from '@shared/types'
interface CustomInstruction {
id: string
@ -21,6 +27,7 @@ interface CustomInstruction {
}
export function CommandsPage(): React.ReactElement {
const { t } = useI18n()
const [instructions, setInstructions] = useState<CustomInstruction[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@ -58,9 +65,8 @@ export function CommandsPage(): React.ReactElement {
}, [loadData])
const handleActivate = (id: string) => {
const newId = activeId === id ? null : id // 토글: 같은 거 누르면 해제
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' })
@ -74,7 +80,7 @@ export function CommandsPage(): React.ReactElement {
setEditId(null)
setFormName('')
setFormDesc('')
setFormPrompt('{{text}}를 다듬어주세요.')
setFormPrompt(t('commands.defaultPrompt'))
setDialogOpen(true)
}
@ -119,103 +125,453 @@ export function CommandsPage(): React.ReactElement {
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>
<PageHeader
title={t('commands.title').toUpperCase()}
count={t('commands.count', { count: instructions.length }).toUpperCase()}
action={
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">
{t('commands.add').toUpperCase()}
</Button>
}
/>
{/* 활성 명령어 표시 */}
<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>
{/* 활성 명령어 — ScreenPanel로 극적 표시 */}
<ScreenPanel height={72}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, height: '100%' }}>
<Led color={activeInstruction ? 'amber' : 'off'} size={8} pulse={!!activeInstruction} />
<Box>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('commands.activeCommand').toUpperCase()}
</PhosphorText>
<PhosphorText
variant="value"
sx={{
fontSize: d3roTypo.heading.size,
color: activeInstruction ? d3roPalette.accent.amber : d3roPalette.text.disabled,
}}
>
{activeInstruction ? activeInstruction.name : t('commands.none')}
</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>
</ScreenPanel>
{/* 명령어 목록 */}
<Box sx={{ mt: 2 }}>
{loading ? (
<PhosphorText variant="dim">{t('common.loading').toUpperCase()}</PhosphorText>
) : instructions.length === 0 ? (
<EmptyStateCard message={t('commands.noCommands').toUpperCase()} />
) : (
<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: d3roTypo.body.size,
fontWeight: d3roTypo.heading.weight,
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.primary,
}}>
{inst.name}
</Box>
<Box sx={{
fontSize: d3roTypo.meta.size,
color: d3roPalette.text.inactive,
mt: 0.25,
}}>
{inst.description}
</Box>
</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 && (
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => handleDelete(inst.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
onClick={() => openEdit(inst)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<DeleteIcon sx={{ fontSize: 16 }} />
<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>
</Box>
</MetalCard>
</Box>
)
})}
</Box>
)}
</MetalCard>
</Box>
)
})}
</Box>
)}
</Box>
{/* ── 음성 키워드 섹션 ── */}
<VoiceKeywordsSection instructions={instructions} />
{/* ── LLM 체인 섹션 ── */}
<ChainSection instructions={instructions} />
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
{editId ? '명령어 편집' : '명령어 추가'}
{editId ? t('commands.editTitle') : t('commands.addTitle')}
</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}}는 전사된 텍스트로 치환됩니다" />
<TextField label={t('commands.name')} value={formName} onChange={(e) => setFormName(e.target.value)} fullWidth autoFocus sx={{ mt: 1 }} />
<TextField label={t('commands.description')} value={formDesc} onChange={(e) => setFormDesc(e.target.value)} fullWidth sx={{ mt: 2 }} />
<TextField label={t('commands.promptTemplate')} value={formPrompt} onChange={(e) => setFormPrompt(e.target.value)} fullWidth multiline rows={4} sx={{ mt: 2 }} helperText={t('commands.promptHelp')} />
</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>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim()}>{t('common.save')}</Button>
</DialogActions>
</Dialog>
</Box>
)
}
/* ────────────────────────────────────────────────────────────
음성 키워드 섹션: 명령어별 키워드 편집
──────────────────────────────────────────────────────────── */
function VoiceKeywordsSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
const { t } = useI18n()
const [rules, setRules] = useState<VoiceCommandRule[]>([])
const [editingRule, setEditingRule] = useState<string | null>(null)
const [keywordInput, setKeywordInput] = useState('')
const loadRules = useCallback(async () => {
const result = await window.electronAPI.voiceCommand.getAll()
if (result.success) setRules(result.data)
}, [])
useEffect(() => { loadRules() }, [loadRules])
const handleAddKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[]) => {
const trimmed = keywordInput.trim()
if (!trimmed) return
const updated: VoiceCommandKeyword[] = [...existing, { keyword: trimmed, matchMode: 'prefix' as KeywordMatchMode }]
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
setKeywordInput('')
loadRules()
}, [keywordInput, loadRules])
const handleRemoveKeyword = useCallback(async (instructionId: string, existing: VoiceCommandKeyword[], idx: number) => {
const updated = existing.filter((_, i) => i !== idx)
await window.electronAPI.voiceCommand.setKeywords({ instructionId, keywords: updated })
loadRules()
}, [loadRules])
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('voiceCommand.title').toUpperCase()}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
</Box>
{rules.length === 0 ? (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size }}>
{t('voiceCommand.noKeywords').toUpperCase()}
</PhosphorText>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{rules.map(rule => (
<MetalCard key={rule.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Led color={rule.enabled ? 'green' : 'off'} size={6} />
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight, minWidth: 100 }}>
{getInstructionName(rule.instructionId)}
</PhosphorText>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, flex: 1 }}>
{rule.keywords.map((kw, idx) => (
<Chip
key={`${kw.keyword}-${idx}`}
label={kw.keyword}
size="small"
onDelete={() => handleRemoveKeyword(rule.instructionId, rule.keywords, idx)}
sx={{
height: 20,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.orangeBg,
color: d3roPalette.tag.orange,
borderRadius: d3roRadius.small,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.orange, fontSize: 12 },
}}
/>
))}
{editingRule === rule.instructionId ? (
<Box
component="input"
value={keywordInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setKeywordInput(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); handleAddKeyword(rule.instructionId, rule.keywords) }
if (e.key === 'Escape') { setEditingRule(null); setKeywordInput('') }
}}
onBlur={() => { if (!keywordInput.trim()) setEditingRule(null) }}
autoFocus
placeholder={t('voiceCommand.keywordPlaceholder')}
sx={{
border: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
px: 1, py: 0.25,
borderRadius: d3roRadius.xs,
outline: 'none', width: 120,
'&:focus': { borderColor: d3roPalette.accent.amber },
}}
/>
) : (
<IconButton
size="small"
onClick={() => { setEditingRule(rule.instructionId); setKeywordInput('') }}
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.amber } }}
>
<AddIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
</Box>
)
}
/* ────────────────────────────────────────────────────────────
LLM 체인 섹션: 멀티 명령어 파이프라인 관리
──────────────────────────────────────────────────────────── */
function ChainSection({ instructions }: { instructions: CustomInstruction[] }): React.ReactElement {
const { t } = useI18n()
const [chains, setChains] = useState<LLMChain[]>([])
const [dialogOpen, setDialogOpen] = useState(false)
const [editChain, setEditChain] = useState<LLMChain | null>(null)
const [formName, setFormName] = useState('')
const [formSteps, setFormSteps] = useState<ChainStep[]>([])
const loadChains = useCallback(async () => {
const result = await window.electronAPI.chain.getAll()
if (result.success) setChains(result.data)
}, [])
useEffect(() => { loadChains() }, [loadChains])
const getInstructionName = (id: string) => instructions.find(i => i.id === id)?.name ?? id
const openAdd = () => {
setEditChain(null)
setFormName('')
setFormSteps([{ instructionId: instructions[0]?.id ?? '', inputSource: 'original' }])
setDialogOpen(true)
}
const openEdit = (chain: LLMChain) => {
setEditChain(chain)
setFormName(chain.name)
setFormSteps([...chain.steps])
setDialogOpen(true)
}
const handleSave = async () => {
if (!formName.trim() || formSteps.length === 0) return
const validSteps = formSteps.filter(s => s.instructionId)
if (validSteps.length === 0) return
if (editChain) {
await window.electronAPI.chain.update({ id: editChain.id, name: formName.trim(), steps: validSteps })
} else {
await window.electronAPI.chain.create({ name: formName.trim(), steps: validSteps })
}
setDialogOpen(false)
loadChains()
}
const handleDelete = async (id: string) => {
await window.electronAPI.chain.delete({ id })
loadChains()
}
const handleExecute = async (chainId: string) => {
const text = await navigator.clipboard.readText()
if (text) {
await window.electronAPI.chain.execute({ chainId, inputText: text })
}
}
const addStep = () => {
setFormSteps(prev => [...prev, {
instructionId: instructions[0]?.id ?? '',
inputSource: prev.length > 0 ? 'previous' : 'original',
}])
}
const removeStep = (idx: number) => {
setFormSteps(prev => prev.filter((_, i) => i !== idx))
}
const updateStep = (idx: number, field: keyof ChainStep, value: string) => {
setFormSteps(prev => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
}
return (
<Box sx={{ mt: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 2 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('chain.title').toUpperCase()}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<Button variant="outlined" startIcon={<LinkIcon />} onClick={openAdd} size="small" sx={{ fontSize: d3roTypo.micro.size }}>
{t('chain.add').toUpperCase()}
</Button>
</Box>
{chains.length === 0 ? (
<EmptyStateCard message={t('chain.noChains').toUpperCase()} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{chains.map(chain => (
<MetalCard key={chain.id}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ fontSize: d3roTypo.body.size, fontWeight: d3roTypo.heading.weight, color: d3roPalette.text.primary }}>
{chain.name}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, mt: 0.5 }}>
{chain.steps.map((step, idx) => (
<Box key={idx} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{idx > 0 && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size }}>→</PhosphorText>
)}
<Chip
label={getInstructionName(step.instructionId)}
size="small"
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.xs,
}}
/>
</Box>
))}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => handleExecute(chain.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.green } }}>
<PlayArrowIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => openEdit(chain)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(chain.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
</Box>
</MetalCard>
))}
</Box>
)}
{/* 체인 편집 다이얼로그 */}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>
{editChain ? t('chain.editTitle') : t('chain.addTitle')}
</DialogTitle>
<DialogContent>
<TextField
label={t('chain.name')}
value={formName}
onChange={e => setFormName(e.target.value)}
fullWidth autoFocus sx={{ mt: 1 }}
/>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mt: 3, mb: 1, display: 'block' }}>
{t('chain.steps').toUpperCase()}
</PhosphorText>
{formSteps.map((step, idx) => (
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1, alignItems: 'center' }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, minWidth: 24 }}>
{idx + 1}.
</PhosphorText>
<FormControl size="small" sx={{ flex: 1 }}>
<InputLabel>{t('chain.selectInstruction')}</InputLabel>
<Select
value={step.instructionId}
label={t('chain.selectInstruction')}
onChange={e => updateStep(idx, 'instructionId', e.target.value)}
>
{instructions.map(inst => (
<MenuItem key={inst.id} value={inst.id}>{inst.name}</MenuItem>
))}
</Select>
</FormControl>
{idx > 0 && (
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>{t('chain.inputSource')}</InputLabel>
<Select
value={step.inputSource}
label={t('chain.inputSource')}
onChange={e => updateStep(idx, 'inputSource', e.target.value)}
>
<MenuItem value="original">{t('chain.inputSource.original')}</MenuItem>
<MenuItem value="previous">{t('chain.inputSource.previous')}</MenuItem>
</Select>
</FormControl>
)}
<IconButton size="small" onClick={() => removeStep(idx)} disabled={formSteps.length <= 1}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
<Button onClick={addStep} startIcon={<AddIcon />} size="small" sx={{ mt: 1 }}>
{t('chain.addStep')}
</Button>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setDialogOpen(false)} sx={{ color: d3roPalette.text.inactive }}>{t('common.cancel')}</Button>
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formSteps.length === 0}>{t('common.save')}</Button>
</DialogActions>
</Dialog>
</Box>