feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
496
apps/web/src/app/(app)/commands/page.tsx
Normal file
496
apps/web/src/app/(app)/commands/page.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Braces,
|
||||
CheckCircle2,
|
||||
ClipboardCopy,
|
||||
Cpu,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Square,
|
||||
Trash2,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { DoubleBezelCard, MetalCard, PhosphorText, PhysicalButton, TactileBadge, Led } from '@d3ro/ui/components/ds'
|
||||
import { d3roFontMono, d3roPalette } from '@d3ro/ui/theme'
|
||||
import { useAuth } from '@/components/providers/auth-provider'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import {
|
||||
bootstrapInstructionState,
|
||||
COMMAND_INPUT_MAX_CHARS,
|
||||
CommandClientError,
|
||||
createCustomInstruction,
|
||||
deleteCustomInstruction,
|
||||
executeInstruction,
|
||||
normalizeInstructionDraft,
|
||||
reorderCustomInstruction,
|
||||
setActiveInstruction,
|
||||
sortInstructions,
|
||||
updateCustomInstruction,
|
||||
type CustomInstruction,
|
||||
type InstructionDraft,
|
||||
type InstructionState
|
||||
} from '@/lib/command-client'
|
||||
|
||||
function commandMessage(error: unknown): string {
|
||||
if (error instanceof CommandClientError) {
|
||||
if (error.code === 'auth') return '세션이 만료되었거나 이 명령에 접근할 권한이 없습니다.'
|
||||
if (error.code === 'cancelled') return '명령 실행을 취소했습니다.'
|
||||
if (error.code === 'conflict') return '다른 기기에서 변경된 명령입니다. 새로고침한 뒤 다시 시도해 주세요.'
|
||||
if (error.code === 'duplicate') return '같은 이름의 명령이 이미 있습니다.'
|
||||
if (error.code === 'invalid-request') return '이름, 설명, 프롬프트 또는 입력 길이를 확인해 주세요.'
|
||||
if (error.code === 'not-found') return '명령을 찾을 수 없습니다.'
|
||||
if (error.code === 'quota-exceeded') return '오늘 사용할 수 있는 AI 쿼터를 모두 사용했습니다.'
|
||||
if (error.code === 'model-not-allowed') return '현재 요금제에서 사용할 수 없는 모델입니다.'
|
||||
if (error.code === 'provider-unavailable') return 'AI 제공자가 설정되지 않았거나 현재 응답할 수 없습니다.'
|
||||
if (error.code === 'timeout') return 'AI 응답 시간이 초과되었습니다.'
|
||||
if (error.code === 'network') return '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'
|
||||
if (error.code === 'invalid-response') return '서버 응답 형식이 올바르지 않습니다.'
|
||||
}
|
||||
return '명령을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
function webClient(): SupabaseClient {
|
||||
return getSupabaseBrowserClient() as unknown as SupabaseClient
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: InstructionDraft = { name: '', description: '', prompt: '' }
|
||||
|
||||
export default function CommandsPage(): React.ReactElement {
|
||||
const { user, session, loading: authLoading } = useAuth()
|
||||
const [state, setState] = useState<InstructionState | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mutatingIds, setMutatingIds] = useState<Set<string>>(() => new Set())
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<CustomInstruction | null>(null)
|
||||
const [draft, setDraft] = useState<InstructionDraft>(EMPTY_DRAFT)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testInput, setTestInput] = useState('')
|
||||
const [testOutput, setTestOutput] = useState<string | null>(null)
|
||||
const [testError, setTestError] = useState<string | null>(null)
|
||||
const [retryable, setRetryable] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const requestGeneration = useRef(0)
|
||||
const initializationRef = useRef<Promise<InstructionState> | null>(null)
|
||||
const activeInstruction = state?.instructions.find((instruction) => instruction.id === state.activeInstructionId) ?? null
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
const generation = ++requestGeneration.current
|
||||
if (!user) {
|
||||
if (!authLoading) {
|
||||
setState(null)
|
||||
setError('세션이 만료되었습니다. 다시 로그인해 주세요.')
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const client = webClient()
|
||||
let initialization = initializationRef.current
|
||||
if (!initialization) {
|
||||
initialization = (async (): Promise<InstructionState> => {
|
||||
let loaded = await bootstrapInstructionState(client, user.id)
|
||||
if (!loaded.activeInstructionId && loaded.instructions.length > 0) {
|
||||
const defaultInstruction = loaded.instructions.find((instruction) => instruction.builtinKey === 'translate_en') ?? loaded.instructions[0]
|
||||
const activated = await setActiveInstruction(client, user.id, defaultInstruction.id)
|
||||
loaded = { ...loaded, ...activated }
|
||||
}
|
||||
return loaded
|
||||
})()
|
||||
initializationRef.current = initialization
|
||||
}
|
||||
const loaded = await initialization
|
||||
if (generation === requestGeneration.current) setState(loaded)
|
||||
} catch (requestError) {
|
||||
if (generation === requestGeneration.current) {
|
||||
setState(null)
|
||||
setError(commandMessage(requestError))
|
||||
}
|
||||
} finally {
|
||||
initializationRef.current = null
|
||||
if (generation === requestGeneration.current) setLoading(false)
|
||||
}
|
||||
}, [authLoading, user])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => () => controllerRef.current?.abort(), [])
|
||||
|
||||
const markMutating = (id: string, active: boolean): void => {
|
||||
setMutatingIds((current) => {
|
||||
const next = new Set(current)
|
||||
if (active) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activate = async (instruction: CustomInstruction): Promise<void> => {
|
||||
if (!user || !state || mutatingIds.has(instruction.id) || state.activeInstructionId === instruction.id) return
|
||||
const previousId = state.activeInstructionId
|
||||
markMutating(instruction.id, true)
|
||||
setError(null)
|
||||
setState((current) => current ? { ...current, activeInstructionId: instruction.id } : current)
|
||||
try {
|
||||
const activated = await setActiveInstruction(webClient(), user.id, instruction.id)
|
||||
setState((current) => current ? { ...current, ...activated } : current)
|
||||
setTestOutput(null)
|
||||
setTestError(null)
|
||||
} catch (requestError) {
|
||||
setState((current) => current ? { ...current, activeInstructionId: previousId } : current)
|
||||
setError(commandMessage(requestError))
|
||||
} finally {
|
||||
markMutating(instruction.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAdd = (): void => {
|
||||
setEditing(null)
|
||||
setDraft(EMPTY_DRAFT)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (instruction: CustomInstruction): void => {
|
||||
if (instruction.builtinKey !== null) return
|
||||
setEditing(instruction)
|
||||
setDraft({ name: instruction.name, description: instruction.description, prompt: instruction.prompt })
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!user || !state || saving) return
|
||||
let normalized: InstructionDraft
|
||||
try {
|
||||
normalized = normalizeInstructionDraft(draft)
|
||||
} catch (requestError) {
|
||||
setError(commandMessage(requestError))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const original = editing
|
||||
const now = new Date().toISOString()
|
||||
const optimisticId = original?.id ?? crypto.randomUUID()
|
||||
const nextSortOrder = state.instructions.reduce((maximum, instruction) => Math.max(maximum, instruction.sortOrder), 0) + 10
|
||||
const optimistic: CustomInstruction = {
|
||||
id: optimisticId,
|
||||
userId: user.id,
|
||||
builtinKey: null,
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
prompt: normalized.prompt,
|
||||
icon: original?.icon ?? 'sparkles',
|
||||
sortOrder: original?.sortOrder ?? nextSortOrder,
|
||||
revision: (original?.revision ?? 0) + 1,
|
||||
createdAt: original?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
}
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
instructions: sortInstructions(original
|
||||
? current.instructions.map((instruction) => instruction.id === original.id ? optimistic : instruction)
|
||||
: [...current.instructions, optimistic])
|
||||
} : current)
|
||||
setDialogOpen(false)
|
||||
try {
|
||||
const saved = original
|
||||
? await updateCustomInstruction(webClient(), user.id, original, normalized)
|
||||
: await createCustomInstruction(webClient(), user.id, normalized, nextSortOrder)
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
instructions: sortInstructions([
|
||||
...current.instructions.filter((instruction) => instruction.id !== optimisticId && instruction.id !== saved.id),
|
||||
saved
|
||||
])
|
||||
} : current)
|
||||
} catch (requestError) {
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
instructions: sortInstructions([
|
||||
...current.instructions.filter((instruction) => instruction.id !== optimisticId),
|
||||
...(original ? [original] : [])
|
||||
])
|
||||
} : current)
|
||||
setError(commandMessage(requestError))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (instruction: CustomInstruction): Promise<void> => {
|
||||
if (!user || !state || instruction.builtinKey !== null || mutatingIds.has(instruction.id)) return
|
||||
if (!window.confirm(`“${instruction.name}” 명령을 삭제할까요?`)) return
|
||||
markMutating(instruction.id, true)
|
||||
setError(null)
|
||||
let nextActiveId = state.activeInstructionId
|
||||
try {
|
||||
if (state.activeInstructionId === instruction.id) {
|
||||
const fallback = state.instructions.find((candidate) => candidate.builtinKey === 'translate_en')
|
||||
if (!fallback) throw new CommandClientError('invalid-response', true)
|
||||
const activated = await setActiveInstruction(webClient(), user.id, fallback.id)
|
||||
nextActiveId = activated.activeInstructionId
|
||||
setState((current) => current ? { ...current, ...activated } : current)
|
||||
}
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
activeInstructionId: nextActiveId,
|
||||
instructions: current.instructions.filter((candidate) => candidate.id !== instruction.id)
|
||||
} : current)
|
||||
await deleteCustomInstruction(webClient(), user.id, instruction)
|
||||
} catch (requestError) {
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
activeInstructionId: nextActiveId,
|
||||
instructions: current.instructions.some((candidate) => candidate.id === instruction.id)
|
||||
? current.instructions
|
||||
: sortInstructions([...current.instructions, instruction])
|
||||
} : current)
|
||||
setError(commandMessage(requestError))
|
||||
} finally {
|
||||
markMutating(instruction.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const move = async (instruction: CustomInstruction, direction: 'up' | 'down'): Promise<void> => {
|
||||
if (!user || !state || instruction.builtinKey !== null || mutatingIds.has(instruction.id)) return
|
||||
const customs = state.instructions.filter((candidate) => candidate.builtinKey === null)
|
||||
const index = customs.findIndex((candidate) => candidate.id === instruction.id)
|
||||
const adjacent = customs[index + (direction === 'up' ? -1 : 1)]
|
||||
if (!adjacent) return
|
||||
const reorderedCustoms = [...customs]
|
||||
reorderedCustoms[index] = adjacent
|
||||
reorderedCustoms[index + (direction === 'up' ? -1 : 1)] = instruction
|
||||
const now = new Date().toISOString()
|
||||
const optimisticCustoms = reorderedCustoms.map((candidate, position) => ({
|
||||
...candidate,
|
||||
sortOrder: 1_000 + (position + 1) * 10,
|
||||
revision: candidate.revision + 1,
|
||||
updatedAt: now
|
||||
}))
|
||||
const previousInstructions = state.instructions
|
||||
markMutating(instruction.id, true)
|
||||
setError(null)
|
||||
setState((current) => current ? {
|
||||
...current,
|
||||
instructions: sortInstructions([
|
||||
...current.instructions.filter((candidate) => candidate.builtinKey !== null),
|
||||
...optimisticCustoms
|
||||
])
|
||||
} : current)
|
||||
try {
|
||||
const instructions = await reorderCustomInstruction(webClient(), user.id, instruction.id, direction)
|
||||
setState((current) => current ? { ...current, instructions } : current)
|
||||
} catch (requestError) {
|
||||
setState((current) => current ? { ...current, instructions: previousInstructions } : current)
|
||||
setError(commandMessage(requestError))
|
||||
} finally {
|
||||
markMutating(instruction.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
if (testing || !testInput.trim() || !activeInstruction) return
|
||||
if (!session?.access_token) {
|
||||
setTestError('세션이 만료되었습니다. 다시 로그인해 주세요.')
|
||||
setRetryable(false)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
controllerRef.current?.abort()
|
||||
controllerRef.current = controller
|
||||
setTesting(true)
|
||||
setTestOutput(null)
|
||||
setTestError(null)
|
||||
setRetryable(false)
|
||||
try {
|
||||
const output = await executeInstruction(activeInstruction.prompt, testInput, {
|
||||
accessToken: session.access_token,
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!controller.signal.aborted) setTestOutput(output)
|
||||
} catch (requestError) {
|
||||
setTestError(commandMessage(requestError))
|
||||
setRetryable(requestError instanceof CommandClientError && requestError.retryable)
|
||||
} finally {
|
||||
if (controllerRef.current === controller) {
|
||||
controllerRef.current = null
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || loading) {
|
||||
return <Box role="status" sx={{ minHeight: 360, display: 'grid', placeItems: 'center' }}><CircularProgress aria-label="명령 불러오는 중" /></Box>
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4 }}>
|
||||
<Alert severity="error" action={<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}>다시 시도</Button>}>
|
||||
{error ?? '명령을 불러오지 못했습니다.'}
|
||||
</Alert>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const customInstructions = state.instructions.filter((instruction) => instruction.builtinKey === null)
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 1080, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
|
||||
<DoubleBezelCard innerPadding={3} sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 2, mb: 3, flexWrap: 'wrap' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box sx={{ width: 44, height: 44, borderRadius: '12px', bgcolor: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.4)', display: 'grid', placeItems: 'center', color: 'var(--d3-accent-main)' }}><Zap size={22} /></Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 10, color: 'var(--d3-text-label)' }}>ACTIVE SYNCED INSTRUCTION</Typography>
|
||||
<Typography sx={{ fontSize: 18, fontWeight: 500, color: '#fff' }}>{activeInstruction?.name ?? '활성 명령 없음'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<TactileBadge ledColor="green" tone="success">SUPABASE SSOT · REV {state.settingsRevision}</TactileBadge>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(4, 1fr)' }, gap: 1.5, p: 2, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid #1a1a1c' }}>
|
||||
<PipelineStep icon={<Braces size={15} />} step="01" label="텍스트 입력" />
|
||||
<PipelineStep icon={<CheckCircle2 size={15} />} step="02" label="활성 명령 적용" />
|
||||
<PipelineStep icon={<Cpu size={15} />} step="03" label="llm-proxy" accent />
|
||||
<PipelineStep icon={<ClipboardCopy size={15} />} step="04" label="검증된 응답" last />
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
{error && <Alert severity="error" action={<Button color="inherit" size="small" startIcon={<RefreshCw size={14} />} onClick={() => void load()}>새로고침</Button>} sx={{ mb: 3 }}>{error}</Alert>}
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 2 }}>
|
||||
<PhosphorText variant="label">SYNCED COMMANDS ({state.instructions.length})</PhosphorText>
|
||||
<PhysicalButton tone="accent" size="small" onClick={openAdd} trailingIcon={<Plus size={14} />}>사용자 명령 추가</PhysicalButton>
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2, mb: 4 }}>
|
||||
{state.instructions.map((instruction) => {
|
||||
const active = instruction.id === state.activeInstructionId
|
||||
const busy = mutatingIds.has(instruction.id)
|
||||
const customIndex = customInstructions.findIndex((candidate) => candidate.id === instruction.id)
|
||||
return (
|
||||
<Box
|
||||
key={instruction.id}
|
||||
role="button"
|
||||
tabIndex={busy ? -1 : 0}
|
||||
aria-pressed={active}
|
||||
aria-label={`${instruction.name} 명령 활성화`}
|
||||
data-testid={`command-card-${instruction.id}`}
|
||||
onClick={() => { if (!busy) void activate(instruction) }}
|
||||
onKeyDown={(event) => {
|
||||
if (!busy && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault()
|
||||
void activate(instruction)
|
||||
}
|
||||
}}
|
||||
sx={{ cursor: busy ? 'default' : 'pointer', border: active ? '1px solid var(--d3-accent-main)' : '1px solid transparent', borderRadius: '16px' }}
|
||||
>
|
||||
<MetalCard sx={{ p: 2.5, height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Box sx={{ pt: 0.5 }}><Led color={active ? 'green' : 'amber'} size={8} /></Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 0.5 }}>
|
||||
<Typography sx={{ fontSize: 16, fontWeight: 500, color: active ? 'var(--d3-accent-main)' : '#fff' }}>{instruction.name}</Typography>
|
||||
<TactileBadge mono tone={instruction.builtinKey ? 'mono' : 'accent'}>{instruction.builtinKey ? 'BUILT-IN · READ ONLY' : 'CUSTOM'}</TactileBadge>
|
||||
{active && <TactileBadge mono tone="success">ACTIVE</TactileBadge>}
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: 13, color: 'var(--d3-text-label)', lineHeight: 1.5 }}>{instruction.description || '설명 없음'}</Typography>
|
||||
</Box>
|
||||
{instruction.builtinKey === null && (
|
||||
<Box sx={{ display: 'flex', gap: 0.1 }} onClick={(event) => event.stopPropagation()}>
|
||||
<Tooltip title="위로"><span><IconButton aria-label={`${instruction.name} 위로`} disabled={busy || customIndex <= 0} size="small" onClick={() => void move(instruction, 'up')}><ArrowUp size={14} /></IconButton></span></Tooltip>
|
||||
<Tooltip title="아래로"><span><IconButton aria-label={`${instruction.name} 아래로`} disabled={busy || customIndex === customInstructions.length - 1} size="small" onClick={() => void move(instruction, 'down')}><ArrowDown size={14} /></IconButton></span></Tooltip>
|
||||
<Tooltip title="편집"><span><IconButton aria-label={`${instruction.name} 편집`} disabled={busy} size="small" onClick={() => openEdit(instruction)}><Pencil size={14} /></IconButton></span></Tooltip>
|
||||
<Tooltip title="삭제"><span><IconButton aria-label={`${instruction.name} 삭제`} disabled={busy} size="small" onClick={() => void remove(instruction)}><Trash2 size={14} /></IconButton></span></Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block' }}>PIPELINE TEST BENCH</PhosphorText>
|
||||
<MetalCard sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, mb: 2, alignItems: 'flex-start', flexDirection: { xs: 'column', sm: 'row' } }}>
|
||||
<TextField
|
||||
value={testInput}
|
||||
onChange={(event) => setTestInput(event.target.value)}
|
||||
placeholder="처리할 텍스트를 입력하세요..."
|
||||
multiline
|
||||
minRows={3}
|
||||
fullWidth
|
||||
inputProps={{ 'aria-label': '명령 테스트 입력', maxLength: COMMAND_INPUT_MAX_CHARS }}
|
||||
helperText={`${testInput.length.toLocaleString()} / ${COMMAND_INPUT_MAX_CHARS.toLocaleString()} · ${activeInstruction?.name ?? '활성 명령 없음'}`}
|
||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'var(--d3-bg-inset)', color: 'var(--d3-text-secondary)', borderRadius: '12px' } }}
|
||||
/>
|
||||
{testing ? (
|
||||
<PhysicalButton tone="glass" onClick={() => controllerRef.current?.abort()}><Square size={14} style={{ marginRight: 4 }} /> 취소</PhysicalButton>
|
||||
) : (
|
||||
<PhysicalButton tone="accent" onClick={() => void run()} disabled={!testInput.trim() || !activeInstruction}><Play size={14} style={{ marginRight: 4 }} /> 실행</PhysicalButton>
|
||||
)}
|
||||
</Box>
|
||||
{testing && <PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>인증된 AI 응답을 기다리는 중...</PhosphorText>}
|
||||
{testError && <Alert severity="error" data-testid="command-error" action={retryable ? <Button color="inherit" size="small" startIcon={<RotateCcw size={14} />} onClick={() => void run()}>다시 시도</Button> : undefined}>{testError}</Alert>}
|
||||
{testOutput && (
|
||||
<Box data-testid="command-output" sx={{ p: 2.5, bgcolor: 'var(--d3-bg-inset)', borderRadius: '12px', border: '1px solid var(--d3-border-default)', display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Sparkles size={16} color="var(--d3-accent-main)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||
<Typography sx={{ fontSize: 14, color: 'var(--d3-text-secondary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>{testOutput}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => { if (!saving) setDialogOpen(false) }} maxWidth="sm" fullWidth PaperProps={{ sx: { bgcolor: 'var(--d3-bg-card)', border: '1px solid var(--d3-border-default)', borderRadius: '16px', p: 1 } }}>
|
||||
<DialogTitle sx={{ color: '#fff', fontWeight: 500 }}>{editing ? '사용자 명령 편집' : '사용자 명령 추가'}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<TextField label="명령 이름" value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} inputProps={{ maxLength: 80 }} autoFocus fullWidth size="small" sx={{ mt: 1 }} />
|
||||
<TextField label="명령 설명" value={draft.description} onChange={(event) => setDraft((current) => ({ ...current, description: event.target.value }))} inputProps={{ maxLength: 240 }} fullWidth size="small" />
|
||||
<TextField label="명령 프롬프트" value={draft.prompt} onChange={(event) => setDraft((current) => ({ ...current, prompt: event.target.value }))} inputProps={{ maxLength: 4_000 }} helperText="{{text}}를 넣으면 해당 위치에 입력문이 삽입됩니다. 없으면 프롬프트 뒤에 붙습니다." multiline minRows={5} fullWidth />
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<PhysicalButton tone="glass" disabled={saving} onClick={() => setDialogOpen(false)}>취소</PhysicalButton>
|
||||
<PhysicalButton tone="accent" disabled={saving || !draft.name.trim() || !draft.prompt.trim()} onClick={() => void save()}>{saving ? '저장 중...' : '저장'}</PhysicalButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function PipelineStep({ icon, step, label, accent = false, last = false }: { icon: React.ReactNode; step: string; label: string; accent?: boolean; last?: boolean }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: '50%', bgcolor: accent ? 'rgba(59,130,246,0.2)' : 'var(--d3-bg-elevated)', display: 'grid', placeItems: 'center', color: accent ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)' }}>{icon}</Box>
|
||||
<Box sx={{ flex: 1 }}><Typography sx={{ fontFamily: d3roFontMono, fontSize: 9, color: 'var(--d3-text-label)' }}>STEP {step}</Typography><Typography sx={{ fontSize: 12, fontWeight: 600, color: accent ? 'var(--d3-accent-main)' : '#fff' }}>{label}</Typography></Box>
|
||||
{!last && <ArrowRight size={14} color="var(--d3-text-label)" />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue