feat: Agent Office — AI 에이전트 가상 오피스 (#2)

## Summary
- Canvas 2D 픽셀아트 오피스 렌더링 (SpriteSheet + TileMap + AgentSprite)
- WebSocket 실시간 에이전트 상태 동기화 (useAgentManager)
- ChatPanel (명령/승인) + TaskHistory (작업 이력) UI
- 다크 테마 + glassmorphism 패널

## Changes (7 commits)
- API helpers + route + Lab entry
- Canvas engine: SpriteSheet, TileMap, AgentSprite, OfficeRenderer
- React hooks: useAgentManager, useOfficeCanvas
- Components: ChatPanel, TaskHistory
- Main page + CSS
- Code review fixes: claude agent 참조 제거, rejected 배지 추가

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-04-11 13:35:35 +09:00
parent 7fc2d3aaf7
commit 25715a2198
14 changed files with 1206 additions and 0 deletions

View File

@@ -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 };
}