From a165d6271f5ddc8a2ed91840b6ddfd5b8727b2e8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 11 Apr 2026 15:32:07 +0900 Subject: [PATCH] refactor(agent-office): dashboard layout with agent columns + CEO command panel - Restructure layout: dashboard (top, 3 columns) + office canvas (bottom, 280px) - AgentColumn: per-agent status, quick commands, approval UI, task history - CommandColumn: CEO command input with agent selector, quick shortcuts, history - Remove overlay panels (ChatPanel/DocumentPanel) - integrated into dashboard - Office canvas shrunk to compact strip at bottom Co-Authored-By: Claude Opus 4.6 --- src/pages/agent-office/AgentOffice.css | 546 +++++++----------- src/pages/agent-office/AgentOffice.jsx | 66 +-- .../agent-office/components/AgentColumn.jsx | 139 +++++ .../agent-office/components/CommandColumn.jsx | 103 ++++ 4 files changed, 478 insertions(+), 376 deletions(-) create mode 100644 src/pages/agent-office/components/AgentColumn.jsx create mode 100644 src/pages/agent-office/components/CommandColumn.jsx diff --git a/src/pages/agent-office/AgentOffice.css b/src/pages/agent-office/AgentOffice.css index 7d61ca2..429ea3a 100644 --- a/src/pages/agent-office/AgentOffice.css +++ b/src/pages/agent-office/AgentOffice.css @@ -11,13 +11,14 @@ display: flex; align-items: center; justify-content: space-between; - padding: 12px 20px; + padding: 8px 20px; background: #1a1a2e; border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; } .ao-title { - font-size: 1.4rem; + font-size: 1.2rem; color: #8b5cf6; margin: 0; letter-spacing: 2px; @@ -27,7 +28,7 @@ display: flex; align-items: center; gap: 8px; - font-size: 0.85rem; + font-size: 0.8rem; color: #888; } @@ -39,152 +40,218 @@ .ao-dot--on { background: #34d399; } .ao-dot--off { background: #f87171; } -.ao-workspace { +/* Dashboard */ +.ao-dashboard { + display: flex; + gap: 1px; + background: #2a2a4a; flex: 1; - position: relative; + min-height: 0; overflow: hidden; } +/* Agent Column */ +.ao-col { + flex: 1; + display: flex; + flex-direction: column; + background: #0d0d1a; + min-width: 0; + overflow-y: auto; +} + +.ao-col-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 3px solid; + background: #1a1a2e; + flex-shrink: 0; +} + +.ao-col-name { + font-weight: bold; + font-size: 0.9rem; +} + +.ao-col-state { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 8px; + text-transform: uppercase; + margin-left: auto; +} +.ao-col-state--idle { background: #333; color: #888; } +.ao-col-state--working { background: #3730a3; color: #a5b4fc; } +.ao-col-state--waiting { background: #92400e; color: #fbbf24; } +.ao-col-state--reporting { background: #065f46; color: #34d399; } +.ao-col-state--break { background: #4c1d95; color: #c4b5fd; } +.ao-col-state--offline { background: #1f1f1f; color: #555; } + +.ao-col-badge { + background: #f43f5e; + color: #fff; + font-size: 0.65rem; + padding: 1px 5px; + border-radius: 6px; + font-weight: bold; +} + +.ao-col-detail { + padding: 6px 12px; + font-size: 0.8rem; + color: #a78bfa; + background: rgba(139, 92, 246, 0.05); + border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; +} + +.ao-col-approval { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(251, 191, 36, 0.08); + border-bottom: 1px solid #2a2a4a; + font-size: 0.8rem; + color: #fbbf24; + flex-shrink: 0; +} + +.ao-col-commands { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid #1a1a2e; + flex-shrink: 0; +} + +.ao-col-input { + display: flex; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid #1a1a2e; + flex-shrink: 0; +} + +.ao-col-tasks { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.ao-col-tasks-title { + padding: 4px 12px; + font-size: 0.7rem; + color: #555; + text-transform: uppercase; + letter-spacing: 1px; +} + +.ao-col-empty { + padding: 12px; + text-align: center; + color: #444; + font-size: 0.8rem; +} + +.ao-col-task { + padding: 6px 12px; + border-bottom: 1px solid rgba(255,255,255,0.03); +} + +.ao-col-task-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ao-col-task-type { + font-size: 0.8rem; + color: #ccc; +} + +.ao-col-task-badge { + font-size: 0.65rem; + padding: 1px 6px; + border-radius: 4px; + color: #fff; +} + +.ao-col-task-time { + font-size: 0.7rem; + color: #555; + margin-top: 2px; +} + +.ao-col-task-detail { + margin-top: 4px; + font-size: 0.7rem; +} +.ao-col-task-detail summary { + cursor: pointer; + color: #8b5cf6; +} +.ao-col-task-detail pre { + color: #888; + white-space: pre-wrap; + margin: 4px 0 0; + max-height: 120px; + overflow-y: auto; +} + +/* Command Column */ +.ao-cmd-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid #1a1a2e; + flex-shrink: 0; +} + +.ao-cmd-row { + display: flex; + gap: 6px; +} + +.ao-cmd-select { + flex: 1; + padding: 6px 8px; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e0e0e0; + font-size: 0.8rem; + font-family: inherit; +} +.ao-cmd-select:focus { border-color: #8b5cf6; outline: none; } + +.ao-cmd-send { + width: 100%; +} + +/* Office Section */ +.ao-office-section { + height: 280px; + flex-shrink: 0; + border-top: 2px solid #2a2a4a; + position: relative; +} + .ao-canvas-container { width: 100%; height: 100%; } -.ao-agent-bar { - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - display: flex; - gap: 8px; - padding: 6px 12px; - background: rgba(0, 0, 0, 0.6); - border-radius: 20px; - backdrop-filter: blur(8px); -} - -.ao-agent-chip { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border: 1px solid #333; - border-radius: 12px; - background: transparent; - color: #ccc; - font-size: 0.8rem; - cursor: pointer; - font-family: inherit; -} -.ao-agent-chip:hover { border-color: #8b5cf6; } -.ao-agent-chip--selected { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.15); } -.ao-agent-chip--alert { animation: ao-pulse 1s infinite; } - -@keyframes ao-pulse { - 0%, 100% { border-color: #fbbf24; } - 50% { border-color: #f59e0b; box-shadow: 0 0 8px rgba(251, 191, 36, 0.4); } -} - -.ao-chip-dot { - width: 6px; - height: 6px; - border-radius: 50%; -} -.ao-chip-dot--idle { background: #666; } -.ao-chip-dot--working { background: #818cf8; } -.ao-chip-dot--waiting { background: #fbbf24; } -.ao-chip-dot--reporting { background: #34d399; } -.ao-chip-dot--break { background: #a78bfa; } - -.ao-chip-badge { - background: #f87171; - color: #fff; - font-size: 0.65rem; - padding: 0 4px; - border-radius: 4px; - font-weight: bold; -} - -.ao-pending-count { - color: #fbbf24; - font-size: 0.75rem; - align-self: center; -} - -.ao-chat-panel { - position: absolute; - right: 16px; - top: 60px; - width: 340px; - max-height: calc(100% - 80px); - background: rgba(26, 26, 46, 0.95); - border: 1px solid #333; - border-radius: 12px; - overflow-y: auto; - backdrop-filter: blur(12px); -} - -.ao-chat-header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - border-bottom: 1px solid #2a2a4a; -} - -.ao-chat-title { - flex: 1; - font-weight: bold; - color: #e0e0e0; -} - -.ao-chat-state { - font-size: 0.75rem; - padding: 2px 8px; - border-radius: 8px; - text-transform: uppercase; -} -.ao-chat-state--idle { background: #333; } -.ao-chat-state--working { background: #3730a3; } -.ao-chat-state--waiting { background: #92400e; } -.ao-chat-state--break { background: #4c1d95; } - -.ao-chat-close { - background: none; - border: none; - color: #888; - font-size: 1.2rem; - cursor: pointer; -} -.ao-chat-close:hover { color: #fff; } - -.ao-chat-detail { - padding: 8px 16px; - color: #aaa; - font-size: 0.85rem; -} - -.ao-chat-approval { - padding: 12px 16px; - background: rgba(251, 191, 36, 0.1); - border-top: 1px solid #2a2a4a; - border-bottom: 1px solid #2a2a4a; -} -.ao-chat-approval p { - margin: 0 0 8px; - color: #fbbf24; - font-size: 0.85rem; -} -.ao-chat-approval-btns { - display: flex; - gap: 8px; -} - +/* Shared */ .ao-btn { - padding: 6px 16px; + padding: 4px 12px; border: none; border-radius: 6px; - font-size: 0.85rem; + font-size: 0.8rem; cursor: pointer; font-family: inherit; } @@ -195,225 +262,32 @@ .ao-btn--send { background: #4c1d95; color: #c4b5fd; } .ao-btn--send:hover { background: #5b21b6; } -.ao-chat-commands { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 12px 16px; -} - .ao-cmd-btn { - padding: 6px 12px; + padding: 4px 10px; border: 1px solid #333; - border-radius: 8px; + border-radius: 6px; background: transparent; color: #ccc; - font-size: 0.8rem; + font-size: 0.75rem; cursor: pointer; font-family: inherit; } .ao-cmd-btn:hover { border-color: #8b5cf6; background: rgba(139, 92, 246, 0.1); } -.ao-chat-input-area { - display: flex; - gap: 8px; - padding: 8px 16px 12px; -} .ao-chat-input { flex: 1; - padding: 8px 12px; + padding: 6px 10px; background: #111; border: 1px solid #333; border-radius: 6px; color: #e0e0e0; - font-size: 0.85rem; + font-size: 0.8rem; font-family: inherit; + min-width: 0; } .ao-chat-input:focus { border-color: #8b5cf6; outline: none; } -.ao-chat-result { - padding: 8px 16px; - border-top: 1px solid #2a2a4a; -} -.ao-chat-result h4 { - margin: 0 0 8px; - font-size: 0.8rem; - color: #888; -} -.ao-chat-result pre { - font-size: 0.75rem; - color: #aaa; - overflow-x: auto; - white-space: pre-wrap; - margin: 0; -} - -.ao-history-panel { - position: absolute; - left: 16px; - top: 60px; - width: 340px; - max-height: calc(100% - 80px); - background: rgba(26, 26, 46, 0.95); - border: 1px solid #333; - border-radius: 12px; - overflow-y: auto; - backdrop-filter: blur(12px); -} - -.ao-history-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #2a2a4a; - font-weight: bold; -} - -.ao-history-list { padding: 8px; } -.ao-history-empty { text-align: center; color: #666; padding: 20px; } - -.ao-history-item { - padding: 10px 12px; - border-bottom: 1px solid #1a1a2e; -} -.ao-history-item:last-child { border-bottom: none; } - -.ao-history-item-header { - display: flex; - justify-content: space-between; - align-items: center; -} -.ao-history-type { font-size: 0.85rem; color: #ccc; } -.ao-history-badge { - font-size: 0.7rem; - padding: 2px 8px; - border-radius: 4px; - color: #fff; -} -.ao-history-time { - font-size: 0.75rem; - color: #666; - margin-top: 4px; -} -.ao-history-detail { - margin-top: 6px; - font-size: 0.75rem; -} -.ao-history-detail summary { - cursor: pointer; - color: #8b5cf6; -} -.ao-history-detail pre { - color: #aaa; - white-space: pre-wrap; - margin: 4px 0 0; -} - -/* Document Panel (CEO desk) */ -.ao-doc-panel { - position: absolute; - left: 16px; - top: 60px; - width: 400px; - max-height: calc(100% - 80px); - background: rgba(26, 26, 46, 0.95); - border: 1px solid #333; - border-radius: 12px; - overflow-y: auto; - backdrop-filter: blur(12px); - display: flex; - flex-direction: column; -} - -.ao-doc-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid #2a2a4a; -} - -.ao-doc-title { - font-weight: bold; - font-size: 1rem; - color: #e0e0e0; -} - -.ao-doc-tabs { - display: flex; - gap: 4px; - padding: 8px 16px; - border-bottom: 1px solid #2a2a4a; -} - -.ao-doc-tab { - padding: 4px 12px; - border: 1px solid #333; - border-radius: 8px; - background: transparent; - color: #888; - font-size: 0.8rem; - cursor: pointer; - font-family: inherit; -} -.ao-doc-tab:hover { color: #ccc; border-color: #555; } -.ao-doc-tab--active { background: rgba(139, 92, 246, 0.2); border-color: #8b5cf6; color: #c4b5fd; } - -.ao-doc-feed { padding: 4px 8px; } -.ao-doc-feed-toolbar { padding: 4px 8px; display: flex; justify-content: flex-end; } - -.ao-doc-feed-item { - padding: 8px 10px; - border-bottom: 1px solid #1a1a2e; -} -.ao-doc-feed-item:last-child { border-bottom: none; } - -.ao-doc-feed-row { - display: flex; - align-items: center; - gap: 6px; -} - -.ao-doc-agent-tag { - font-size: 0.7rem; - padding: 1px 6px; - border-radius: 4px; - color: #fff; -} -.ao-doc-agent-tag--stock { background: #2563eb; } -.ao-doc-agent-tag--music { background: #059669; } - -.ao-doc-feed-msg { - font-size: 0.8rem; - color: #ccc; - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.ao-doc-feed-time { - font-size: 0.7rem; - color: #555; - margin-top: 2px; -} - .ao-doc-tg-status { font-size: 0.7rem; margin-left: 4px; } - -.ao-doc-detail { padding: 0; } -.ao-doc-agent-select { display: flex; gap: 4px; padding: 8px 16px; } -.ao-doc-detail-tabs { display: flex; gap: 4px; padding: 4px 16px; border-bottom: 1px solid #2a2a4a; } - -.ao-doc-log-item { - display: flex; - gap: 6px; - padding: 4px 12px; - font-size: 0.75rem; - border-bottom: 1px solid rgba(255,255,255,0.03); -} -.ao-doc-log-level { font-weight: bold; white-space: nowrap; } -.ao-doc-log-msg { color: #aaa; flex: 1; word-break: break-all; } diff --git a/src/pages/agent-office/AgentOffice.jsx b/src/pages/agent-office/AgentOffice.jsx index 5fadb86..f7c01fe 100644 --- a/src/pages/agent-office/AgentOffice.jsx +++ b/src/pages/agent-office/AgentOffice.jsx @@ -1,27 +1,27 @@ import React, { useRef, useState, useCallback, useEffect } from 'react'; import { useAgentManager } from './hooks/useAgentManager'; import { useOfficeCanvas } from './hooks/useOfficeCanvas'; -import ChatPanel from './components/ChatPanel'; -import DocumentPanel from './components/DocumentPanel'; +import AgentColumn from './components/AgentColumn'; +import CommandColumn from './components/CommandColumn'; import './AgentOffice.css'; +const AGENT_META = { + stock: { name: '주식 트레이더', color: '#4488cc' }, + music: { name: '음악 프로듀서', color: '#44aa88' }, +}; + export function Component() { const canvasContainerRef = useRef(null); - const [selectedAgent, setSelectedAgent] = useState(null); - const [showDocument, setShowDocument] = useState(false); const { agents, pendingTasks, connected, notifications, sendCommand, sendApproval, clearNotifications } = useAgentManager(); const handleAgentClick = useCallback((agentId) => { - setSelectedAgent(prev => prev === agentId ? null : agentId); clearNotifications(agentId); }, [clearNotifications]); - const handleCeoClick = useCallback(() => { - setShowDocument(prev => !prev); - }, []); + const handleCeoClick = useCallback(() => {}, []); - const { updateAgentState, moveAgent, setAgentNotification, setCeoDocBadge } = useOfficeCanvas(canvasContainerRef, handleAgentClick, handleCeoClick); + const { updateAgentState, setAgentNotification, setCeoDocBadge } = useOfficeCanvas(canvasContainerRef, handleAgentClick, handleCeoClick); useEffect(() => { for (const [id, info] of Object.entries(agents)) { @@ -53,41 +53,27 @@ export function Component() { -
-
- -
- {Object.entries(agents).map(([id, info]) => ( - - ))} - {pendingTasks.length > 0 && ( - {pendingTasks.length} pending - )} -
- - {selectedAgent && ( - + {['stock', 'music'].map(id => ( + setSelectedAgent(null)} + onClearNotification={() => clearNotifications(id)} /> - )} + ))} + +
- {showDocument && ( - setShowDocument(false)} /> - )} +
+
); diff --git a/src/pages/agent-office/components/AgentColumn.jsx b/src/pages/agent-office/components/AgentColumn.jsx new file mode 100644 index 0000000..e988ed7 --- /dev/null +++ b/src/pages/agent-office/components/AgentColumn.jsx @@ -0,0 +1,139 @@ +import React, { useState, useEffect } from 'react'; +import { getAgentTasks } from '../../../api'; + +const STATUS_BADGE = { + pending: { label: '대기', bg: '#92400e' }, + approved: { label: '승인됨', bg: '#1e40af' }, + working: { label: '진행중', bg: '#3730a3' }, + succeeded: { label: '완료', bg: '#065f46' }, + failed: { label: '실패', bg: '#7f1d1d' }, + rejected: { label: '거절됨', bg: '#9a3412' }, +}; + +const AGENT_COMMANDS = { + stock: [ + { action: 'fetch_news', label: '뉴스 수집', icon: '📰' }, + { action: 'list_alerts', label: '알람 목록', icon: '🔔' }, + { action: 'test_telegram', label: 'TG 테스트', icon: '📨' }, + ], + music: [ + { action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true }, + { action: 'credits', label: '크레딧', icon: '💳' }, + ], +}; + +const AgentColumn = ({ agentId, meta, agentState, notification, onCommand, onApproval, onClearNotification }) => { + const [tasks, setTasks] = useState([]); + const [input, setInput] = useState(''); + const [activeCommand, setActiveCommand] = useState(null); + + const state = agentState || { state: 'offline' }; + const commands = AGENT_COMMANDS[agentId] || []; + + useEffect(() => { + getAgentTasks(agentId, 10) + .then(d => setTasks(d.tasks || [])) + .catch(() => setTasks([])); + }, [agentId]); + + // Refresh tasks when state changes to idle (task likely completed) + useEffect(() => { + if (state.state === 'idle' && state.detail) { + getAgentTasks(agentId, 10) + .then(d => setTasks(d.tasks || [])) + .catch(() => {}); + } + }, [agentId, state.state, state.detail]); + + const handleQuickAction = (cmd) => { + if (cmd.needsInput) { + setActiveCommand(cmd.action); + } else { + onCommand(agentId, cmd.action, {}); + } + onClearNotification(); + }; + + const handleSend = () => { + if (!input.trim() || !activeCommand) return; + onCommand(agentId, activeCommand, activeCommand === 'compose' ? { prompt: input } : { message: input }); + setInput(''); + setActiveCommand(null); + }; + + const formatTime = (t) => t ? t.replace('T', ' ').slice(11, 19) : ''; + + return ( +
+
+ {meta.name} + {state.state} + {notification > 0 && {notification}} +
+ + {state.detail && ( +
{state.detail}
+ )} + + {state.state === 'waiting' && state.taskId && ( +
+ 승인 대기 + + +
+ )} + +
+ {commands.map(cmd => ( + + ))} +
+ + {activeCommand && ( +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSend()} + placeholder="입력..." + autoFocus + /> + +
+ )} + +
+
최근 작업
+ {tasks.length === 0 &&
이력 없음
} + {tasks.map(task => { + const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending; + return ( +
+
+ {task.task_type} + {badge.label} +
+
+ {formatTime(task.created_at)} + {task.result_data?.telegram_sent !== undefined && ( + {task.result_data.telegram_sent ? ' TG OK' : ' TG Fail'} + )} +
+ {task.result_data && ( +
+ 결과 +
{JSON.stringify(task.result_data, null, 2)}
+
+ )} +
+ ); + })} +
+
+ ); +}; + +export default AgentColumn; diff --git a/src/pages/agent-office/components/CommandColumn.jsx b/src/pages/agent-office/components/CommandColumn.jsx new file mode 100644 index 0000000..a183d19 --- /dev/null +++ b/src/pages/agent-office/components/CommandColumn.jsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; + +const TARGETS = [ + { id: 'stock', name: '주식 트레이더' }, + { id: 'music', name: '음악 프로듀서' }, +]; + +const QUICK_COMMANDS = [ + { target: 'stock', action: 'fetch_news', label: '뉴스 수집' }, + { target: 'stock', action: 'test_telegram', label: 'TG 테스트' }, + { target: 'music', action: 'credits', label: '크레딧 확인' }, +]; + +const CommandColumn = ({ agents, onCommand }) => { + const [target, setTarget] = useState('stock'); + const [action, setAction] = useState(''); + const [params, setParams] = useState(''); + const [history, setHistory] = useState([]); + + const handleSend = () => { + if (!action.trim()) return; + let parsedParams = {}; + if (params.trim()) { + try { parsedParams = JSON.parse(params); } + catch { parsedParams = { message: params }; } + } + onCommand(target, action, parsedParams); + setHistory(prev => [{ + time: new Date().toLocaleTimeString(), + target, + action, + params: parsedParams, + }, ...prev].slice(0, 20)); + setAction(''); + setParams(''); + }; + + const handleQuick = (cmd) => { + onCommand(cmd.target, cmd.action, {}); + setHistory(prev => [{ + time: new Date().toLocaleTimeString(), + target: cmd.target, + action: cmd.action, + params: {}, + }, ...prev].slice(0, 20)); + }; + + return ( +
+
+ CEO 명령 +
+ +
+
+ +
+ setAction(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSend()} + placeholder="명령어 (fetch_news, compose...)" + /> + setParams(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSend()} + placeholder="파라미터 (JSON 또는 텍스트)" + /> + +
+ +
+ {QUICK_COMMANDS.map((cmd, i) => ( + + ))} +
+ +
+
명령 이력
+ {history.length === 0 &&
이력 없음
} + {history.map((h, i) => ( +
+
+ {h.target}.{h.action} + {h.time} +
+
+ ))} +
+
+ ); +}; + +export default CommandColumn;