feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
583
apps/desktop/src/renderer/pages/CommandsPage.tsx
Normal file
583
apps/desktop/src/renderer/pages/CommandsPage.tsx
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
// src/renderer/pages/CommandsPage.tsx
|
||||
// 커스텀 LLM 명령어 관리 + Phase 10: 음성 키워드 + LLM 체인
|
||||
// 3 섹션: 명령어 목록 / 음성 키워드 / LLM 체인
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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 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 { TemplateSection } from '../components/TemplateSection'
|
||||
import type { VoiceCommandRule, VoiceCommandKeyword, KeywordMatchMode, LLMChain, ChainStep } from '@shared/types'
|
||||
|
||||
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 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('@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(t('commands.defaultPrompt'))
|
||||
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 }}>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 활성 명령어 — 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>
|
||||
</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 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>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ── 음성 키워드 섹션 ── */}
|
||||
<VoiceKeywordsSection instructions={instructions} />
|
||||
|
||||
{/* ── LLM 체인 섹션 ── */}
|
||||
<ChainSection instructions={instructions} />
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>
|
||||
{editId ? t('commands.editTitle') : t('commands.addTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<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 }}>{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>
|
||||
|
||||
{/* ── Phase 12.3: 딕테이션 템플릿 ── */}
|
||||
<TemplateSection />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue