diff --git a/src/api.js b/src/api.js index 6bedae5..c6e3018 100644 --- a/src/api.js +++ b/src/api.js @@ -588,3 +588,14 @@ export function deleteBrandLink(id) { return apiDelete(`/api/blog-marketing/links/${id}`); } +// ── Agent Office ────────────────────────────────── +export const getAgents = () => apiGet('/api/agent-office/agents'); +export const getAgentDetail = (id) => apiGet(`/api/agent-office/agents/${id}`); +export const updateAgentConfig = (id, body) => apiPut(`/api/agent-office/agents/${id}`, body); +export const getAgentTasks = (id, limit=20) => apiGet(`/api/agent-office/agents/${id}/tasks?limit=${limit}`); +export const getAgentLogs = (id, limit=50) => apiGet(`/api/agent-office/agents/${id}/logs?limit=${limit}`); +export const getPendingTasks = () => apiGet('/api/agent-office/tasks/pending'); +export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/agent-office/command', { agent, action, params }); +export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback }); +export const getAgentStates = () => apiGet('/api/agent-office/states'); + diff --git a/src/pages/agent-office/AgentOffice.css b/src/pages/agent-office/AgentOffice.css new file mode 100644 index 0000000..fc1b7c9 --- /dev/null +++ b/src/pages/agent-office/AgentOffice.css @@ -0,0 +1,331 @@ +.ao-page { + display: flex; + flex-direction: column; + height: 100vh; + background: #0d0d1a; + color: #e0e0e0; + font-family: 'Courier New', monospace; +} + +.ao-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: #1a1a2e; + border-bottom: 1px solid #2a2a4a; +} + +.ao-title { + font-size: 1.4rem; + color: #8b5cf6; + margin: 0; + letter-spacing: 2px; +} + +.ao-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: #888; +} + +.ao-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} +.ao-dot--on { background: #34d399; } +.ao-dot--off { background: #f87171; } + +.ao-workspace { + flex: 1; + position: relative; + overflow: hidden; +} + +.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; +} + +.ao-btn { + padding: 6px 16px; + border: none; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + font-family: inherit; +} +.ao-btn--approve { background: #065f46; color: #34d399; } +.ao-btn--approve:hover { background: #047857; } +.ao-btn--reject { background: #7f1d1d; color: #f87171; } +.ao-btn--reject:hover { background: #991b1b; } +.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; + border: 1px solid #333; + border-radius: 8px; + background: transparent; + color: #ccc; + font-size: 0.8rem; + 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; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e0e0e0; + font-size: 0.85rem; + font-family: inherit; +} +.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; +} + +.ao-toolbar { + display: flex; + gap: 8px; + padding: 8px 20px; + background: #1a1a2e; + border-top: 1px solid #2a2a4a; +} + +.ao-tool-btn { + padding: 6px 14px; + border: 1px solid #333; + border-radius: 6px; + background: transparent; + color: #aaa; + font-size: 0.8rem; + cursor: pointer; + font-family: inherit; +} +.ao-tool-btn:hover { border-color: #8b5cf6; color: #e0e0e0; } diff --git a/src/pages/agent-office/AgentOffice.jsx b/src/pages/agent-office/AgentOffice.jsx new file mode 100644 index 0000000..42e4a90 --- /dev/null +++ b/src/pages/agent-office/AgentOffice.jsx @@ -0,0 +1,85 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { useAgentManager } from './hooks/useAgentManager'; +import { useOfficeCanvas } from './hooks/useOfficeCanvas'; +import ChatPanel from './components/ChatPanel'; +import TaskHistory from './components/TaskHistory'; +import './AgentOffice.css'; + +export function Component() { + const canvasContainerRef = useRef(null); + const [selectedAgent, setSelectedAgent] = useState(null); + const [showHistory, setShowHistory] = useState(null); + + const { agents, pendingTasks, connected, sendCommand, sendApproval } = useAgentManager(); + + const handleAgentClick = useCallback((agentId) => { + setSelectedAgent(prev => prev === agentId ? null : agentId); + }, []); + + const { updateAgentState, moveAgent } = useOfficeCanvas(canvasContainerRef, handleAgentClick); + + useEffect(() => { + for (const [id, info] of Object.entries(agents)) { + updateAgentState(id, info.state, info.detail); + } + }, [agents, updateAgentState]); + + return ( +
+
+

Agent Office

+
+ + {connected ? 'Connected' : 'Disconnected'} +
+
+ +
+
+ +
+ {Object.entries(agents).map(([id, info]) => ( + + ))} + {pendingTasks.length > 0 && ( + {pendingTasks.length} pending + )} +
+ + {selectedAgent && ( + setSelectedAgent(null)} + /> + )} + + {showHistory && ( + setShowHistory(null)} + /> + )} +
+ +
+ {Object.keys(agents).map(id => ( + + ))} +
+
+ ); +} diff --git a/src/pages/agent-office/assets/office-map.json b/src/pages/agent-office/assets/office-map.json new file mode 100644 index 0000000..509563e --- /dev/null +++ b/src/pages/agent-office/assets/office-map.json @@ -0,0 +1,45 @@ +{ + "tileSize": 32, + "cols": 20, + "rows": 14, + "layers": { + "floor": [ + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] + ] + }, + "furniture": [ + {"type": "desk", "x": 2, "y": 1, "label": "Stock"}, + {"type": "desk", "x": 7, "y": 1, "label": "Music"}, + {"type": "desk", "x": 12, "y": 1, "label": "Claude"}, + {"type": "desk", "x": 17, "y": 1, "label": "(빈)"}, + {"type": "table", "x": 8, "y": 6, "w": 4, "h": 2, "label": "회의 테이블"}, + {"type": "sofa", "x": 1, "y": 10, "label": "휴게실"}, + {"type": "coffee", "x": 3, "y": 10, "label": "☕"}, + {"type": "desk", "x": 14, "y": 10, "w": 5, "h": 2, "label": "CEO"} + ], + "waypoints": { + "stock_desk": {"x": 2, "y": 2}, + "music_desk": {"x": 7, "y": 2}, + "claude_desk": {"x": 12, "y": 2}, + "meeting_table": {"x": 9, "y": 7}, + "break_room": {"x": 2, "y": 11}, + "ceo_desk": {"x": 16, "y": 11} + }, + "colors": { + "1": "#3a3a50", + "2": "#4a3a2a" + } +} diff --git a/src/pages/agent-office/canvas/AgentSprite.js b/src/pages/agent-office/canvas/AgentSprite.js new file mode 100644 index 0000000..caad3c9 --- /dev/null +++ b/src/pages/agent-office/canvas/AgentSprite.js @@ -0,0 +1,84 @@ +import { drawAgent, getAnimSpeed } from './SpriteSheet'; + +export class AgentSprite { + constructor(agentId, waypoints) { + this.agentId = agentId; + this.waypoints = waypoints; + this.state = 'idle'; + this.detail = ''; + + const deskKey = `${agentId}_desk`; + const desk = waypoints[deskKey] || { x: 5, y: 3 }; + this.x = desk.x; + this.y = desk.y; + this.targetX = desk.x; + this.targetY = desk.y; + this.deskPos = { x: desk.x, y: desk.y }; + + this.frameIndex = 0; + this._lastFrameTime = 0; + this._moveSpeed = 0.05; + } + + setState(newState, detail = '') { + this.state = newState; + this.detail = detail; + this.frameIndex = 0; + } + + moveTo(target) { + const wp = this.waypoints[target]; + if (wp) { + this.targetX = wp.x; + this.targetY = wp.y; + } + } + + moveToDesk() { + this.targetX = this.deskPos.x; + this.targetY = this.deskPos.y; + } + + update(now) { + const speed = getAnimSpeed(this.state); + if (now - this._lastFrameTime > speed) { + this.frameIndex++; + this._lastFrameTime = now; + } + + const dx = this.targetX - this.x; + const dy = this.targetY - this.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist > 0.1) { + const step = Math.min(this._moveSpeed, dist); + this.x += (dx / dist) * step; + this.y += (dy / dist) * step; + } else { + this.x = this.targetX; + this.y = this.targetY; + } + } + + draw(ctx, renderInfo) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + const canvasX = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; + const canvasY = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; + + const isMoving = Math.abs(this.targetX - this.x) > 0.1 || Math.abs(this.targetY - this.y) > 0.1; + const drawState = isMoving ? 'walk' : this.state; + + drawAgent(ctx, this.agentId, canvasX, canvasY, drawState, this.frameIndex, scale * 1.5); + } + + hitTest(canvasX, canvasY, renderInfo) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + const cx = offsetX + this.x * tileSize * scale + (tileSize * scale) / 2; + const cy = offsetY + this.y * tileSize * scale + (tileSize * scale) / 2; + const hitW = 20 * scale; + const hitH = 30 * scale; + + return canvasX >= cx - hitW && canvasX <= cx + hitW && + canvasY >= cy - hitH && canvasY <= cy + hitH; + } +} diff --git a/src/pages/agent-office/canvas/OfficeRenderer.js b/src/pages/agent-office/canvas/OfficeRenderer.js new file mode 100644 index 0000000..9f37f9b --- /dev/null +++ b/src/pages/agent-office/canvas/OfficeRenderer.js @@ -0,0 +1,129 @@ +import { drawTileMap } from './TileMap'; +import { AgentSprite } from './AgentSprite'; +import { getCharLabel } from './SpriteSheet'; + +const STATUS_ICONS = { + idle: null, + working: null, + waiting: '❗', + reporting: '📋', + break: '☕', +}; + +export class OfficeRenderer { + constructor(canvas, mapData) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.mapData = mapData; + this.renderInfo = null; + this.agents = {}; + this._animId = null; + this._onClick = null; + + const agentIds = ['stock', 'music']; + for (const id of agentIds) { + this.agents[id] = new AgentSprite(id, mapData.waypoints); + } + } + + start() { + this._loop = this._loop.bind(this); + this._animId = requestAnimationFrame(this._loop); + } + + stop() { + if (this._animId) { + cancelAnimationFrame(this._animId); + this._animId = null; + } + } + + resize(width, height) { + this.canvas.width = width; + this.canvas.height = height; + } + + setOnClick(handler) { + this._onClick = handler; + } + + handleClick(canvasX, canvasY) { + if (!this.renderInfo) return null; + + for (const [id, sprite] of Object.entries(this.agents)) { + if (sprite.hitTest(canvasX, canvasY, this.renderInfo)) { + if (this._onClick) this._onClick(id); + return id; + } + } + return null; + } + + updateAgentState(agentId, state, detail) { + const sprite = this.agents[agentId]; + if (sprite) { + sprite.setState(state, detail); + if (state === 'idle' || state === 'working' || state === 'waiting') { + sprite.moveToDesk(); + } + } + } + + moveAgent(agentId, target) { + const sprite = this.agents[agentId]; + if (sprite) { + sprite.moveTo(target); + } + } + + _loop(timestamp) { + const { ctx, canvas, mapData } = this; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height); + + const now = Date.now(); + for (const sprite of Object.values(this.agents)) { + sprite.update(now); + sprite.draw(ctx, this.renderInfo); + } + + for (const [id, sprite] of Object.entries(this.agents)) { + this._drawOverlay(ctx, sprite, id); + } + + this._animId = requestAnimationFrame(this._loop); + } + + _drawOverlay(ctx, sprite, agentId) { + if (!this.renderInfo) return; + const { scale, offsetX, offsetY, tileSize } = this.renderInfo; + const cx = offsetX + sprite.x * tileSize * scale + (tileSize * scale) / 2; + const cy = offsetY + sprite.y * tileSize * scale - 10 * scale; + + const icon = STATUS_ICONS[sprite.state]; + if (icon) { + ctx.font = `${14 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.fillText(icon, cx, cy - 15 * scale); + } + + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.font = `${8 * scale}px monospace`; + ctx.textAlign = 'center'; + ctx.fillText(getCharLabel(agentId), cx, cy + 30 * scale + 30); + + if (sprite.detail && (sprite.state === 'working' || sprite.state === 'waiting')) { + const bubbleY = cy - 25 * scale; + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + const textW = ctx.measureText(sprite.detail).width; + ctx.fillRect(cx - textW / 2 - 6, bubbleY - 10, textW + 12, 16); + ctx.fillStyle = '#fff'; + ctx.font = `${7 * scale}px monospace`; + ctx.fillText(sprite.detail, cx, bubbleY); + } + } +} diff --git a/src/pages/agent-office/canvas/SpriteSheet.js b/src/pages/agent-office/canvas/SpriteSheet.js new file mode 100644 index 0000000..1693617 --- /dev/null +++ b/src/pages/agent-office/canvas/SpriteSheet.js @@ -0,0 +1,89 @@ +const PIXEL_CHARS = { + stock: { body: '#4488cc', accent: '#cc4444', label: '주식', hair: '#332222' }, + music: { body: '#44aa88', accent: '#ffaa00', label: '음악', hair: '#443322' }, + claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466' }, +}; + +const ANIM_FRAMES = { + idle: { frames: 2, speed: 800 }, + working: { frames: 4, speed: 200 }, + waiting: { frames: 2, speed: 400 }, + break: { frames: 2, speed: 1000 }, + walk: { frames: 4, speed: 150 }, +}; + +export function drawAgent(ctx, agentId, x, y, state, frameIndex, scale = 2) { + const char = PIXEL_CHARS[agentId] || PIXEL_CHARS.claude; + const s = scale; + const anim = ANIM_FRAMES[state] || ANIM_FRAMES.idle; + const frame = frameIndex % anim.frames; + + ctx.save(); + ctx.translate(x, y); + + // Shadow + ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.fillRect(-4 * s, 14 * s, 8 * s, 2 * s); + + // Body + ctx.fillStyle = char.body; + ctx.fillRect(-3 * s, 2 * s, 6 * s, 8 * s); + + // Head + ctx.fillStyle = '#ffcc99'; + ctx.fillRect(-3 * s, -4 * s, 6 * s, 6 * s); + + // Hair + ctx.fillStyle = char.hair; + ctx.fillRect(-3 * s, -5 * s, 6 * s, 2 * s); + + // Eyes + ctx.fillStyle = '#222'; + const eyeOffset = state === 'break' && frame === 1 ? 0 : 1; + ctx.fillRect(-2 * s, -1 * s, 1 * s, eyeOffset * s); + ctx.fillRect(1 * s, -1 * s, 1 * s, eyeOffset * s); + + // Legs + ctx.fillStyle = '#335'; + const legSpread = state === 'walk' ? (frame % 2 === 0 ? 1 : -1) : 0; + ctx.fillRect(-2 * s, 10 * s, 2 * s, 4 * s); + ctx.fillRect(0 + legSpread * s, 10 * s, 2 * s, 4 * s); + + // Accent + ctx.fillStyle = char.accent; + if (agentId === 'stock') { + ctx.fillRect(0, 2 * s, 1 * s, 5 * s); + } else if (agentId === 'music') { + ctx.fillRect(-4 * s, -4 * s, 1 * s, 4 * s); + ctx.fillRect(3 * s, -4 * s, 1 * s, 4 * s); + ctx.fillRect(-4 * s, -5 * s, 8 * s, 1 * s); + } else if (agentId === 'claude') { + ctx.globalAlpha = 0.3 + 0.2 * Math.sin(Date.now() / 500); + ctx.fillRect(-4 * s, -6 * s, 8 * s, 1 * s); + ctx.globalAlpha = 1; + } + + // Working: typing hands + if (state === 'working') { + ctx.fillStyle = '#ffcc99'; + const handY = 6 * s + (frame % 2) * s; + ctx.fillRect(-4 * s, handY, 1 * s, 2 * s); + ctx.fillRect(3 * s, handY, 1 * s, 2 * s); + } + + // Waiting wobble + if (state === 'waiting') { + const wobble = Math.sin(Date.now() / 200) * s; + ctx.translate(wobble, 0); + } + + ctx.restore(); +} + +export function getAnimSpeed(state) { + return (ANIM_FRAMES[state] || ANIM_FRAMES.idle).speed; +} + +export function getCharLabel(agentId) { + return (PIXEL_CHARS[agentId] || {}).label || agentId; +} diff --git a/src/pages/agent-office/canvas/TileMap.js b/src/pages/agent-office/canvas/TileMap.js new file mode 100644 index 0000000..be1ae3f --- /dev/null +++ b/src/pages/agent-office/canvas/TileMap.js @@ -0,0 +1,90 @@ +const WALL_COLOR = '#2a2a3a'; +const DESK_COLOR = '#6b5b3a'; +const DESK_TOP = '#8b7b5a'; +const TABLE_COLOR = '#5a4a2a'; +const SOFA_COLOR = '#884444'; +const MONITOR_COLOR = '#224466'; +const MONITOR_SCREEN = '#44aacc'; + +export function drawTileMap(ctx, mapData, width, height) { + const { tileSize, cols, rows, layers, furniture, colors } = mapData; + const scaleX = width / (cols * tileSize); + const scaleY = height / (rows * tileSize); + const scale = Math.min(scaleX, scaleY); + + const offsetX = (width - cols * tileSize * scale) / 2; + const offsetY = (height - rows * tileSize * scale) / 2; + + ctx.save(); + ctx.translate(offsetX, offsetY); + ctx.scale(scale, scale); + + const floor = layers.floor; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const tile = floor[r][c]; + ctx.fillStyle = colors[String(tile)] || '#3a3a50'; + ctx.fillRect(c * tileSize, r * tileSize, tileSize, tileSize); + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize); + } + } + + ctx.fillStyle = WALL_COLOR; + ctx.fillRect(0, 0, cols * tileSize, 4); + + for (const f of furniture) { + const fx = f.x * tileSize; + const fy = f.y * tileSize; + const fw = (f.w || 2) * tileSize; + const fh = (f.h || 2) * tileSize; + + if (f.type === 'desk') { + ctx.fillStyle = DESK_COLOR; + ctx.fillRect(fx, fy, fw, fh); + ctx.fillStyle = DESK_TOP; + ctx.fillRect(fx + 2, fy + 2, fw - 4, 6); + const mx = fx + fw / 2 - 8; + ctx.fillStyle = MONITOR_COLOR; + ctx.fillRect(mx, fy + 4, 16, 12); + ctx.fillStyle = MONITOR_SCREEN; + ctx.fillRect(mx + 2, fy + 6, 12, 8); + if (f.label) { + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '8px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(f.label, fx + fw / 2, fy + fh + 12); + } + } else if (f.type === 'table') { + ctx.fillStyle = TABLE_COLOR; + ctx.fillRect(fx, fy, fw, fh); + ctx.fillStyle = '#7a6a4a'; + ctx.fillRect(fx + 4, fy + 4, fw - 8, fh - 8); + } else if (f.type === 'sofa') { + ctx.fillStyle = SOFA_COLOR; + ctx.fillRect(fx, fy, 48, 32); + ctx.fillStyle = '#aa5555'; + ctx.fillRect(fx + 4, fy + 4, 40, 24); + } else if (f.type === 'coffee') { + ctx.fillStyle = '#664422'; + ctx.fillRect(fx + 8, fy + 8, 16, 20); + ctx.fillStyle = '#886644'; + ctx.fillRect(fx + 6, fy + 6, 20, 4); + } + } + + ctx.restore(); + return { scale, offsetX, offsetY, tileSize }; +} + +export function worldToTile(mapData, renderInfo, canvasX, canvasY) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + const wx = (canvasX - offsetX) / scale; + const wy = (canvasY - offsetY) / scale; + return { col: Math.floor(wx / tileSize), row: Math.floor(wy / tileSize), worldX: wx, worldY: wy }; +} + +export function tileToCanvas(mapData, renderInfo, col, row) { + const { scale, offsetX, offsetY, tileSize } = renderInfo; + return { x: offsetX + col * tileSize * scale + (tileSize * scale) / 2, y: offsetY + row * tileSize * scale + (tileSize * scale) / 2 }; +} diff --git a/src/pages/agent-office/components/ChatPanel.jsx b/src/pages/agent-office/components/ChatPanel.jsx new file mode 100644 index 0000000..f9f5363 --- /dev/null +++ b/src/pages/agent-office/components/ChatPanel.jsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; + +const AGENT_COMMANDS = { + stock: [ + { action: 'fetch_news', label: '뉴스 수집', icon: '📰' }, + { action: 'list_alerts', label: '알람 목록', icon: '🔔' }, + ], + music: [ + { action: 'compose', label: '작곡 시작', icon: '🎵', needsInput: true }, + { action: 'credits', label: '크레딧 확인', icon: '💳' }, + ], +}; + +const ChatPanel = ({ agentId, agentState, onCommand, onApproval, onClose }) => { + const [input, setInput] = useState(''); + const [activeCommand, setActiveCommand] = useState(null); + + const commands = AGENT_COMMANDS[agentId] || []; + const state = agentState || {}; + + const handleSend = () => { + if (!input.trim() || !activeCommand) return; + const params = activeCommand === 'compose' + ? { prompt: input } + : { message: input }; + onCommand(agentId, activeCommand, params); + setInput(''); + setActiveCommand(null); + }; + + const handleQuickAction = (cmd) => { + if (cmd.needsInput) { + setActiveCommand(cmd.action); + } else { + onCommand(agentId, cmd.action, {}); + } + }; + + return ( +
+
+ + {agentId === 'stock' ? '주식 트레이더' : + agentId === 'music' ? '음악 프로듀서' : agentId} + + + {state.state || 'idle'} + + +
+ + {state.detail && ( +
{state.detail}
+ )} + + {state.state === 'waiting' && state.taskId && ( +
+

승인 대기 중인 작업이 있습니다

+
+ + +
+
+ )} + +
+ {commands.map(cmd => ( + + ))} +
+ + {activeCommand && ( +
+ setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSend()} + placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'} + autoFocus + /> + +
+ )} + + {state.lastResult && ( +
+

최근 결과

+
{JSON.stringify(state.lastResult, null, 2)}
+
+ )} +
+ ); +}; + +export default ChatPanel; diff --git a/src/pages/agent-office/components/TaskHistory.jsx b/src/pages/agent-office/components/TaskHistory.jsx new file mode 100644 index 0000000..74baf14 --- /dev/null +++ b/src/pages/agent-office/components/TaskHistory.jsx @@ -0,0 +1,62 @@ +import React, { useState, useEffect } from 'react'; +import { getAgentTasks } from '../../../api'; + +const STATUS_BADGE = { + pending: { label: '대기', color: '#fbbf24' }, + approved: { label: '승인됨', color: '#60a5fa' }, + working: { label: '진행중', color: '#818cf8' }, + succeeded: { label: '완료', color: '#34d399' }, + failed: { label: '실패', color: '#f87171' }, + rejected: { label: '거절됨', color: '#fb923c' }, +}; + +const TaskHistory = ({ agentId, onClose }) => { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!agentId) return; + setLoading(true); + getAgentTasks(agentId, 30) + .then(data => setTasks(data.tasks || [])) + .catch(() => setTasks([])) + .finally(() => setLoading(false)); + }, [agentId]); + + return ( +
+
+ 작업 이력 — {agentId} + +
+
+ {loading &&

로딩 중...

} + {!loading && tasks.length === 0 &&

이력 없음

} + {tasks.map(task => { + const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending; + return ( +
+
+ {task.task_type} + + {badge.label} + +
+
+ {task.created_at?.replace('T', ' ').slice(0, 19)} +
+ {task.result_data && ( +
+ 결과 보기 +
{JSON.stringify(task.result_data, null, 2)}
+
+ )} +
+ ); + })} +
+
+ ); +}; + +export default TaskHistory; diff --git a/src/pages/agent-office/hooks/useAgentManager.js b/src/pages/agent-office/hooks/useAgentManager.js new file mode 100644 index 0000000..b7c8d5e --- /dev/null +++ b/src/pages/agent-office/hooks/useAgentManager.js @@ -0,0 +1,88 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; + +export function useAgentManager() { + const [agents, setAgents] = useState({}); + const [pendingTasks, setPendingTasks] = useState([]); + const [connected, setConnected] = useState(false); + const wsRef = useRef(null); + const reconnectTimer = useRef(null); + + const connect = useCallback(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/agent-office/ws`; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setConnected(true); + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + }; + + ws.onclose = () => { + setConnected(false); + reconnectTimer.current = setTimeout(connect, 3000); + }; + + ws.onerror = () => { ws.close(); }; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + + switch (msg.type) { + case 'init': { + const agentMap = {}; + for (const a of msg.agents) { + agentMap[a.agent_id] = { state: a.state, detail: a.detail }; + } + setAgents(agentMap); + setPendingTasks(msg.pending || []); + break; + } + case 'agent_state': + setAgents(prev => ({ + ...prev, + [msg.agent]: { state: msg.state, detail: msg.detail, taskId: msg.task_id }, + })); + break; + case 'task_complete': + setAgents(prev => ({ + ...prev, + [msg.agent]: { ...prev[msg.agent], lastResult: msg.result }, + })); + setPendingTasks(prev => prev.filter(id => id !== msg.task_id)); + break; + case 'command_result': + setAgents(prev => ({ + ...prev, + [msg.agent]: { ...prev[msg.agent], lastCommand: msg.result }, + })); + break; + default: + break; + } + }; + }, []); + + useEffect(() => { + connect(); + return () => { + if (wsRef.current) wsRef.current.close(); + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + }; + }, [connect]); + + const sendCommand = useCallback((agent, action, params = {}) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params })); + } + }, []); + + const sendApproval = useCallback((agent, taskId, approved) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved })); + } + }, []); + + return { agents, pendingTasks, connected, sendCommand, sendApproval }; +} diff --git a/src/pages/agent-office/hooks/useOfficeCanvas.js b/src/pages/agent-office/hooks/useOfficeCanvas.js new file mode 100644 index 0000000..f276be9 --- /dev/null +++ b/src/pages/agent-office/hooks/useOfficeCanvas.js @@ -0,0 +1,62 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { OfficeRenderer } from '../canvas/OfficeRenderer'; +import officeMap from '../assets/office-map.json'; + +export function useOfficeCanvas(containerRef, onAgentClick) { + const rendererRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + const canvas = document.createElement('canvas'); + canvas.style.display = 'block'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.imageRendering = 'pixelated'; + containerRef.current.appendChild(canvas); + + const renderer = new OfficeRenderer(canvas, officeMap); + rendererRef.current = renderer; + + const resize = () => { + const rect = containerRef.current.getBoundingClientRect(); + renderer.resize(rect.width, rect.height); + }; + + resize(); + renderer.start(); + + renderer.setOnClick((agentId) => { + if (onAgentClick) onAgentClick(agentId); + }); + + const handleClick = (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + renderer.handleClick(x, y); + }; + + canvas.addEventListener('click', handleClick); + window.addEventListener('resize', resize); + + return () => { + renderer.stop(); + canvas.removeEventListener('click', handleClick); + window.removeEventListener('resize', resize); + if (containerRef.current && canvas.parentNode === containerRef.current) { + containerRef.current.removeChild(canvas); + } + }; + }, [containerRef, onAgentClick]); + + const updateAgentState = useCallback((agentId, state, detail) => { + rendererRef.current?.updateAgentState(agentId, state, detail); + }, []); + + const moveAgent = useCallback((agentId, target) => { + rendererRef.current?.moveAgent(agentId, target); + }, []); + + return { updateAgentState, moveAgent }; +} diff --git a/src/pages/effect-lab/EffectLab.jsx b/src/pages/effect-lab/EffectLab.jsx index 2ee715c..acca1de 100644 --- a/src/pages/effect-lab/EffectLab.jsx +++ b/src/pages/effect-lab/EffectLab.jsx @@ -25,6 +25,17 @@ const LAB_ITEMS = [ icon: '📅', status: 'live', }, + { + id: 'agent-office', + path: '/agent-office', + title: 'Agent Office', + category: 'AI · 자동화', + desc: 'AI 에이전트들이 사무실에서 자동으로 작업하는 가상 오피스', + tags: ['Canvas 2D', 'WebSocket', 'AI Agent', 'Telegram'], + accent: '#8b5cf6', + icon: '🏢', + status: 'wip', + }, ]; const STATUS_LABEL = { diff --git a/src/routes.jsx b/src/routes.jsx index 8a530e5..189c34d 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -117,6 +117,15 @@ export const navLinks = [ icon: , accent: '#f472b6', }, + { + id: 'agent-office', + label: 'Agent Office', + path: '/agent-office', + subtitle: 'AI LAB', + description: 'AI 에이전트 사무실', + icon: 🏢, + accent: '#8b5cf6', + }, ]; export const appRoutes = [ @@ -172,4 +181,8 @@ export const appRoutes = [ path: 'todo', element: , }, + { + path: 'agent-office', + lazy: () => import('./pages/agent-office/AgentOffice'), + }, ];