'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(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [mutatingIds, setMutatingIds] = useState>(() => new Set()) const [dialogOpen, setDialogOpen] = useState(false) const [editing, setEditing] = useState(null) const [draft, setDraft] = useState(EMPTY_DRAFT) const [saving, setSaving] = useState(false) const [testInput, setTestInput] = useState('') const [testOutput, setTestOutput] = useState(null) const [testError, setTestError] = useState(null) const [retryable, setRetryable] = useState(false) const [testing, setTesting] = useState(false) const controllerRef = useRef(null) const requestGeneration = useRef(0) const initializationRef = useRef | null>(null) const activeInstruction = state?.instructions.find((instruction) => instruction.id === state.activeInstructionId) ?? null const load = useCallback(async (): Promise => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 } if (!state) { return ( } onClick={() => void load()}>다시 시도}> {error ?? '명령을 불러오지 못했습니다.'} ) } const customInstructions = state.instructions.filter((instruction) => instruction.builtinKey === null) return ( ACTIVE SYNCED INSTRUCTION {activeInstruction?.name ?? '활성 명령 없음'} SUPABASE SSOT · REV {state.settingsRevision} } step="01" label="텍스트 입력" /> } step="02" label="활성 명령 적용" /> } step="03" label="llm-proxy" accent /> } step="04" label="검증된 응답" last /> {error && } onClick={() => void load()}>새로고침} sx={{ mb: 3 }}>{error}} SYNCED COMMANDS ({state.instructions.length}) }>사용자 명령 추가 {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 ( { 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' }} > {instruction.name} {instruction.builtinKey ? 'BUILT-IN · READ ONLY' : 'CUSTOM'} {active && ACTIVE} {instruction.description || '설명 없음'} {instruction.builtinKey === null && ( event.stopPropagation()}> void move(instruction, 'up')}> void move(instruction, 'down')}> openEdit(instruction)}> void remove(instruction)}> )} ) })} PIPELINE TEST BENCH 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 ? ( controllerRef.current?.abort()}> 취소 ) : ( void run()} disabled={!testInput.trim() || !activeInstruction}> 실행 )} {testing && 인증된 AI 응답을 기다리는 중...} {testError && } onClick={() => void run()}>다시 시도 : undefined}>{testError}} {testOutput && ( {testOutput} )} { 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 } }}> {editing ? '사용자 명령 편집' : '사용자 명령 추가'} setDraft((current) => ({ ...current, name: event.target.value }))} inputProps={{ maxLength: 80 }} autoFocus fullWidth size="small" sx={{ mt: 1 }} /> setDraft((current) => ({ ...current, description: event.target.value }))} inputProps={{ maxLength: 240 }} fullWidth size="small" /> setDraft((current) => ({ ...current, prompt: event.target.value }))} inputProps={{ maxLength: 4_000 }} helperText="{{text}}를 넣으면 해당 위치에 입력문이 삽입됩니다. 없으면 프롬프트 뒤에 붙습니다." multiline minRows={5} fullWidth /> setDialogOpen(false)}>취소 void save()}>{saving ? '저장 중...' : '저장'} ) } function PipelineStep({ icon, step, label, accent = false, last = false }: { icon: React.ReactNode; step: string; label: string; accent?: boolean; last?: boolean }): React.ReactElement { return ( {icon} STEP {step}{label} {!last && } ) }