# Agent Office Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 2D 픽셀아트 가상 사무실에서 AI 에이전트(주식, 음악)가 실제 작업을 수행하고, 텔레그램 양방향 연동으로 알림/승인을 처리하는 MVP 구현. **Architecture:** agent-office 백엔드 서비스(포트 18900)가 기존 stock-lab/music-lab API를 프록시 호출하고 에이전트 FSM을 관리. 프론트엔드는 Canvas 2D로 사무실을 렌더링하고 WebSocket으로 실시간 상태를 수신. 텔레그램 봇이 양방향 알림/승인을 처리. **Tech Stack:** FastAPI, SQLite, APScheduler, python-telegram-bot, WebSocket, React 18, HTML5 Canvas 2D, Vite **Design Spec:** `docs/superpowers/specs/2026-04-11-agent-office-design.md` --- ## File Structure ### Backend (web-backend/agent-office/) | File | Responsibility | |------|---------------| | `agent-office/app/__init__.py` | Package init | | `agent-office/app/main.py` | FastAPI app, WebSocket endpoint, REST routes, lifespan (scheduler start) | | `agent-office/app/config.py` | Environment variables, service URLs | | `agent-office/app/db.py` | SQLite init, CRUD for agent_config, agent_tasks, agent_logs, telegram_state | | `agent-office/app/models.py` | Pydantic request/response models | | `agent-office/app/websocket_manager.py` | WebSocket connection pool, broadcast | | `agent-office/app/service_proxy.py` | HTTP client for stock-lab, music-lab APIs | | `agent-office/app/telegram_bot.py` | Telegram Bot API: send messages, handle webhook callbacks | | `agent-office/app/scheduler.py` | APScheduler setup, job registration | | `agent-office/app/agents/__init__.py` | Package init, agent registry | | `agent-office/app/agents/base.py` | BaseAgent FSM (state transitions, idle timer, break logic) | | `agent-office/app/agents/stock.py` | StockAgent (news summary, price alerts) | | `agent-office/app/agents/music.py` | MusicAgent (compose pipeline with approval) | | `agent-office/Dockerfile` | Python 3.12-alpine, uvicorn | | `agent-office/requirements.txt` | Dependencies | ### Frontend (web-ui/src/pages/agent-office/) | File | Responsibility | |------|---------------| | `agent-office/AgentOffice.jsx` | Main page: Canvas container + React overlay panels | | `agent-office/AgentOffice.css` | All styles for office page | | `agent-office/canvas/OfficeRenderer.js` | Game loop, layer rendering, click detection | | `agent-office/canvas/SpriteSheet.js` | Sprite sheet loader, frame animation | | `agent-office/canvas/TileMap.js` | Tile map data + rendering (floor, furniture) | | `agent-office/canvas/AgentSprite.js` | Agent character: position, state, movement, animation | | `agent-office/components/ChatPanel.jsx` | Agent chat/command panel (click to open) | | `agent-office/components/TaskHistory.jsx` | Task history side panel | | `agent-office/hooks/useAgentManager.js` | WebSocket connection + agent state management | | `agent-office/hooks/useOfficeCanvas.js` | Canvas init, resize, click event binding | | `agent-office/assets/office-map.json` | Tile map layout data | ### Infrastructure | File | Change | |------|--------| | `docker-compose.yml` | Add agent-office service | | `nginx/default.conf` | Add /api/agent-office/ location with WebSocket upgrade | | `web-ui/src/routes.jsx` | Add agent-office route | | `web-ui/src/pages/effect-lab/EffectLab.jsx` | Add Agent Office to LAB_ITEMS | | `web-ui/src/api.js` | Add agent-office API helpers | --- ## Task 1: Backend Scaffold — config, db, models **Files:** - Create: `agent-office/app/__init__.py` - Create: `agent-office/app/config.py` - Create: `agent-office/app/db.py` - Create: `agent-office/app/models.py` - Create: `agent-office/requirements.txt` - Create: `agent-office/Dockerfile` - Test: `agent-office/app/test_db.py` - [ ] **Step 1: Create requirements.txt** ``` fastapi==0.115.6 uvicorn[standard]==0.30.6 requests==2.32.3 apscheduler==3.10.4 python-telegram-bot==21.5 websockets>=12.0 httpx>=0.27 ``` - [ ] **Step 2: Create Dockerfile** ```dockerfile FROM python:3.12-alpine ENV PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` - [ ] **Step 3: Create `__init__.py`** ```python # agent-office/app/__init__.py ``` Empty file for package init. - [ ] **Step 4: Create config.py** ```python import os # Service URLs (Docker internal network) STOCK_LAB_URL = os.getenv("STOCK_LAB_URL", "http://localhost:18500") MUSIC_LAB_URL = os.getenv("MUSIC_LAB_URL", "http://localhost:18600") # Telegram TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") TELEGRAM_WEBHOOK_URL = os.getenv("TELEGRAM_WEBHOOK_URL", "") # Database DB_PATH = os.getenv("AGENT_OFFICE_DB_PATH", "/app/data/agent_office.db") # CORS CORS_ALLOW_ORIGINS = os.getenv( "CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080" ) # Idle break threshold (seconds) IDLE_BREAK_THRESHOLD = int(os.getenv("IDLE_BREAK_THRESHOLD", "300")) # 5 min BREAK_DURATION_MIN = int(os.getenv("BREAK_DURATION_MIN", "60")) # 1 min BREAK_DURATION_MAX = int(os.getenv("BREAK_DURATION_MAX", "180")) # 3 min ``` - [ ] **Step 5: Create models.py** ```python from pydantic import BaseModel from typing import Optional class CommandRequest(BaseModel): agent: str action: str params: Optional[dict] = None class ApprovalRequest(BaseModel): agent: str task_id: str approved: bool feedback: Optional[str] = None class AgentConfigUpdate(BaseModel): enabled: Optional[bool] = None schedule_config: Optional[dict] = None custom_config: Optional[dict] = None class PriceAlertConfig(BaseModel): symbol: str name: str target_price: float direction: str # "above" or "below" class ComposeCommand(BaseModel): prompt: str style: Optional[str] = None model: Optional[str] = "V4" instrumental: Optional[bool] = False ``` - [ ] **Step 6: Create db.py** ```python import os import json import sqlite3 import uuid from typing import Any, Dict, List, Optional from .config import DB_PATH def _conn() -> sqlite3.Connection: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") return conn def init_db() -> None: with _conn() as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS agent_config ( agent_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, schedule_config TEXT NOT NULL DEFAULT '{}', custom_config TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_tasks ( id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, task_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', input_data TEXT NOT NULL DEFAULT '{}', result_data TEXT, requires_approval INTEGER NOT NULL DEFAULT 0, approved_at TEXT, approved_via TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), completed_at TEXT ) """) conn.execute(""" CREATE INDEX IF NOT EXISTS idx_tasks_agent ON agent_tasks(agent_id, created_at DESC) """) conn.execute(""" CREATE TABLE IF NOT EXISTS agent_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, task_id TEXT, level TEXT NOT NULL DEFAULT 'info', message TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS telegram_state ( callback_id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL, action TEXT, responded INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ) """) # Seed default agent configs for agent_id, name in [("stock", "주식 트레이더"), ("music", "음악 프로듀서")]: conn.execute( "INSERT OR IGNORE INTO agent_config(agent_id, display_name) VALUES(?,?)", (agent_id, name), ) # --- agent_config CRUD --- def get_all_agents() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute("SELECT * FROM agent_config ORDER BY agent_id").fetchall() return [_config_to_dict(r) for r in rows] def get_agent_config(agent_id: str) -> Optional[Dict[str, Any]]: with _conn() as conn: r = conn.execute("SELECT * FROM agent_config WHERE agent_id=?", (agent_id,)).fetchone() return _config_to_dict(r) if r else None def update_agent_config(agent_id: str, **kwargs) -> None: sets, vals = [], [] for k in ("enabled", "schedule_config", "custom_config"): if k in kwargs and kwargs[k] is not None: if k in ("schedule_config", "custom_config"): sets.append(f"{k}=?") vals.append(json.dumps(kwargs[k])) else: sets.append(f"{k}=?") vals.append(kwargs[k]) if not sets: return sets.append("updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')") vals.append(agent_id) with _conn() as conn: conn.execute(f"UPDATE agent_config SET {','.join(sets)} WHERE agent_id=?", vals) def _config_to_dict(r) -> Dict[str, Any]: return { "agent_id": r["agent_id"], "display_name": r["display_name"], "enabled": bool(r["enabled"]), "schedule_config": json.loads(r["schedule_config"]), "custom_config": json.loads(r["custom_config"]), "created_at": r["created_at"], "updated_at": r["updated_at"], } # --- agent_tasks CRUD --- def create_task(agent_id: str, task_type: str, input_data: dict, requires_approval: bool = False) -> str: task_id = str(uuid.uuid4()) status = "pending" if requires_approval else "working" with _conn() as conn: conn.execute( "INSERT INTO agent_tasks(id,agent_id,task_type,status,input_data,requires_approval) VALUES(?,?,?,?,?,?)", (task_id, agent_id, task_type, status, json.dumps(input_data), int(requires_approval)), ) return task_id def update_task_status(task_id: str, status: str, result_data: dict = None) -> None: with _conn() as conn: if result_data is not None: conn.execute( "UPDATE agent_tasks SET status=?, result_data=?, completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (status, json.dumps(result_data), task_id), ) else: conn.execute("UPDATE agent_tasks SET status=? WHERE id=?", (status, task_id)) def approve_task(task_id: str, via: str = "web") -> None: with _conn() as conn: conn.execute( "UPDATE agent_tasks SET status='approved', approved_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), approved_via=? WHERE id=?", (via, task_id), ) def reject_task(task_id: str) -> None: with _conn() as conn: conn.execute("UPDATE agent_tasks SET status='failed', completed_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?", (task_id,)) def get_task(task_id: str) -> Optional[Dict[str, Any]]: with _conn() as conn: r = conn.execute("SELECT * FROM agent_tasks WHERE id=?", (task_id,)).fetchone() return _task_to_dict(r) if r else None def get_agent_tasks(agent_id: str, limit: int = 20) -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( "SELECT * FROM agent_tasks WHERE agent_id=? ORDER BY created_at DESC LIMIT ?", (agent_id, limit), ).fetchall() return [_task_to_dict(r) for r in rows] def get_pending_approvals() -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( "SELECT * FROM agent_tasks WHERE status='pending' AND requires_approval=1 ORDER BY created_at DESC" ).fetchall() return [_task_to_dict(r) for r in rows] def _task_to_dict(r) -> Dict[str, Any]: return { "id": r["id"], "agent_id": r["agent_id"], "task_type": r["task_type"], "status": r["status"], "input_data": json.loads(r["input_data"]) if r["input_data"] else {}, "result_data": json.loads(r["result_data"]) if r["result_data"] else None, "requires_approval": bool(r["requires_approval"]), "approved_at": r["approved_at"], "approved_via": r["approved_via"], "created_at": r["created_at"], "completed_at": r["completed_at"], } # --- agent_logs --- def add_log(agent_id: str, message: str, level: str = "info", task_id: str = None) -> None: with _conn() as conn: conn.execute( "INSERT INTO agent_logs(agent_id,task_id,level,message) VALUES(?,?,?,?)", (agent_id, task_id, level, message), ) def get_logs(agent_id: str, limit: int = 50) -> List[Dict[str, Any]]: with _conn() as conn: rows = conn.execute( "SELECT * FROM agent_logs WHERE agent_id=? ORDER BY created_at DESC LIMIT ?", (agent_id, limit), ).fetchall() return [{"id": r["id"], "agent_id": r["agent_id"], "task_id": r["task_id"], "level": r["level"], "message": r["message"], "created_at": r["created_at"]} for r in rows] # --- telegram_state --- def save_telegram_callback(callback_id: str, task_id: str, agent_id: str) -> None: with _conn() as conn: conn.execute( "INSERT OR REPLACE INTO telegram_state(callback_id,task_id,agent_id) VALUES(?,?,?)", (callback_id, task_id, agent_id), ) def get_telegram_callback(callback_id: str) -> Optional[Dict[str, Any]]: with _conn() as conn: r = conn.execute("SELECT * FROM telegram_state WHERE callback_id=? AND responded=0", (callback_id,)).fetchone() if not r: return None return {"callback_id": r["callback_id"], "task_id": r["task_id"], "agent_id": r["agent_id"], "responded": bool(r["responded"])} def mark_telegram_responded(callback_id: str, action: str) -> None: with _conn() as conn: conn.execute("UPDATE telegram_state SET responded=1, action=? WHERE callback_id=?", (action, callback_id)) ``` - [ ] **Step 7: Write DB test** ```python # agent-office/app/test_db.py import os import sys import tempfile # Override DB_PATH before importing db _tmp = tempfile.mktemp(suffix=".db") os.environ["AGENT_OFFICE_DB_PATH"] = _tmp sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) from app.db import ( init_db, get_all_agents, get_agent_config, update_agent_config, create_task, update_task_status, approve_task, get_task, get_agent_tasks, get_pending_approvals, add_log, get_logs, save_telegram_callback, get_telegram_callback, mark_telegram_responded, ) def test_init_and_seed(): init_db() agents = get_all_agents() assert len(agents) == 2 ids = {a["agent_id"] for a in agents} assert ids == {"stock", "music"} def test_agent_config_update(): init_db() update_agent_config("stock", custom_config={"watch": ["AAPL"]}) cfg = get_agent_config("stock") assert cfg["custom_config"] == {"watch": ["AAPL"]} def test_task_lifecycle(): init_db() # Create task with approval tid = create_task("music", "compose", {"prompt": "test"}, requires_approval=True) task = get_task(tid) assert task["status"] == "pending" assert task["requires_approval"] is True # Approve approve_task(tid, via="telegram") task = get_task(tid) assert task["status"] == "approved" assert task["approved_via"] == "telegram" # Complete update_task_status(tid, "succeeded", {"url": "/media/music/test.mp3"}) task = get_task(tid) assert task["status"] == "succeeded" assert task["result_data"]["url"] == "/media/music/test.mp3" def test_task_no_approval(): init_db() tid = create_task("stock", "news_summary", {"limit": 10}) task = get_task(tid) assert task["status"] == "working" def test_pending_approvals(): init_db() create_task("music", "compose", {"prompt": "a"}, requires_approval=True) create_task("music", "compose", {"prompt": "b"}, requires_approval=True) create_task("stock", "news_summary", {}) pending = get_pending_approvals() assert len(pending) == 2 def test_logs(): init_db() add_log("stock", "News fetched", "info", "task-1") add_log("stock", "API error", "error") logs = get_logs("stock") assert len(logs) == 2 assert logs[0]["level"] == "error" # DESC order def test_telegram_state(): init_db() save_telegram_callback("cb-1", "task-1", "music") cb = get_telegram_callback("cb-1") assert cb["task_id"] == "task-1" mark_telegram_responded("cb-1", "approve") cb = get_telegram_callback("cb-1") assert cb is None # responded=1, filtered out if __name__ == "__main__": test_init_and_seed() test_agent_config_update() test_task_lifecycle() test_task_no_approval() test_pending_approvals() test_logs() test_telegram_state() print("All DB tests passed!") os.unlink(_tmp) ``` - [ ] **Step 8: Run DB test** Run: `cd agent-office && python -m app.test_db` Expected: "All DB tests passed!" - [ ] **Step 9: Commit** ```bash git add agent-office/ git commit -m "feat(agent-office): scaffold backend — config, db, models, Dockerfile" ``` --- ## Task 2: WebSocket Manager **Files:** - Create: `agent-office/app/websocket_manager.py` - [ ] **Step 1: Create websocket_manager.py** ```python import asyncio import json from typing import Any, Dict, Set from fastapi import WebSocket class WebSocketManager: def __init__(self): self._connections: Set[WebSocket] = set() self._lock = asyncio.Lock() async def connect(self, ws: WebSocket) -> None: await ws.accept() async with self._lock: self._connections.add(ws) async def disconnect(self, ws: WebSocket) -> None: async with self._lock: self._connections.discard(ws) async def broadcast(self, message: Dict[str, Any]) -> None: payload = json.dumps(message, ensure_ascii=False) async with self._lock: dead = set() for ws in self._connections: try: await ws.send_text(payload) except Exception: dead.add(ws) self._connections -= dead async def send_agent_state(self, agent_id: str, state: str, detail: str = "", task_id: str = None) -> None: msg = {"type": "agent_state", "agent": agent_id, "state": state, "detail": detail} if task_id: msg["task_id"] = task_id await self.broadcast(msg) async def send_task_complete(self, agent_id: str, task_id: str, result: dict) -> None: await self.broadcast({ "type": "task_complete", "agent": agent_id, "task_id": task_id, "result": result, }) async def send_agent_move(self, agent_id: str, target: str) -> None: await self.broadcast({"type": "agent_move", "agent": agent_id, "target": target}) ws_manager = WebSocketManager() ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/websocket_manager.py git commit -m "feat(agent-office): WebSocket connection manager with broadcast" ``` --- ## Task 3: Service Proxy **Files:** - Create: `agent-office/app/service_proxy.py` - [ ] **Step 1: Create service_proxy.py** ```python import httpx from typing import Any, Dict, List, Optional from .config import STOCK_LAB_URL, MUSIC_LAB_URL _client = httpx.AsyncClient(timeout=30.0) # --- Stock Lab --- async def fetch_stock_news(limit: int = 10, category: str = None) -> List[Dict[str, Any]]: params = {"limit": limit} if category: params["category"] = category resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/news", params=params) resp.raise_for_status() return resp.json() async def fetch_stock_indices() -> Dict[str, Any]: resp = await _client.get(f"{STOCK_LAB_URL}/api/stock/indices") resp.raise_for_status() return resp.json() # --- Music Lab --- async def generate_music(payload: dict) -> Dict[str, Any]: resp = await _client.post(f"{MUSIC_LAB_URL}/api/music/generate", json=payload) resp.raise_for_status() return resp.json() async def get_music_status(task_id: str) -> Dict[str, Any]: resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/status/{task_id}") resp.raise_for_status() return resp.json() async def get_music_credits() -> Dict[str, Any]: resp = await _client.get(f"{MUSIC_LAB_URL}/api/music/credits") resp.raise_for_status() return resp.json() ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/service_proxy.py git commit -m "feat(agent-office): service proxy for stock-lab and music-lab APIs" ``` --- ## Task 4: BaseAgent FSM **Files:** - Create: `agent-office/app/agents/__init__.py` - Create: `agent-office/app/agents/base.py` - [ ] **Step 1: Create agents/__init__.py** ```python from .stock import StockAgent from .music import MusicAgent AGENT_REGISTRY = {} def init_agents(): AGENT_REGISTRY["stock"] = StockAgent() AGENT_REGISTRY["music"] = MusicAgent() def get_agent(agent_id: str): return AGENT_REGISTRY.get(agent_id) def get_all_agent_states() -> list: return [ {"agent_id": aid, "state": agent.state, "detail": agent.state_detail} for aid, agent in AGENT_REGISTRY.items() ] ``` - [ ] **Step 2: Create agents/base.py** ```python import asyncio import random import time from typing import Optional from ..config import IDLE_BREAK_THRESHOLD, BREAK_DURATION_MIN, BREAK_DURATION_MAX from ..db import add_log VALID_STATES = ("idle", "working", "waiting", "reporting", "break") class BaseAgent: agent_id: str = "" display_name: str = "" state: str = "idle" state_detail: str = "" _idle_since: float = 0.0 _break_until: float = 0.0 _ws_manager = None def __init__(self): self._idle_since = time.time() def set_ws_manager(self, manager): self._ws_manager = manager async def transition(self, new_state: str, detail: str = "", task_id: str = None) -> None: if new_state not in VALID_STATES: return old = self.state self.state = new_state self.state_detail = detail if new_state == "idle": self._idle_since = time.time() elif new_state == "break": duration = random.randint(BREAK_DURATION_MIN, BREAK_DURATION_MAX) self._break_until = time.time() + duration add_log(self.agent_id, f"State: {old} → {new_state} ({detail})") if self._ws_manager: await self._ws_manager.send_agent_state(self.agent_id, new_state, detail, task_id) if new_state == "break": await self._ws_manager.send_agent_move(self.agent_id, "break_room") elif old == "break" and new_state == "idle": await self._ws_manager.send_agent_move(self.agent_id, "desk") async def check_idle_break(self) -> None: now = time.time() if self.state == "idle" and (now - self._idle_since) > IDLE_BREAK_THRESHOLD: if random.random() < 0.5: break_type = random.choice(["커피 타임", "잠깐 산책", "졸고 있음"]) await self.transition("break", break_type) elif self.state == "break" and now > self._break_until: await self.transition("idle", "휴식 완료") async def on_schedule(self) -> None: raise NotImplementedError async def on_command(self, command: str, params: dict) -> dict: raise NotImplementedError async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: raise NotImplementedError async def get_status(self) -> dict: return { "agent_id": self.agent_id, "display_name": self.display_name, "state": self.state, "detail": self.state_detail, } ``` - [ ] **Step 3: Commit** ```bash git add agent-office/app/agents/ git commit -m "feat(agent-office): BaseAgent FSM with idle/break behavior" ``` --- ## Task 5: StockAgent **Files:** - Create: `agent-office/app/agents/stock.py` - [ ] **Step 1: Create stock.py** ```python import asyncio from typing import Optional from .base import BaseAgent from ..db import create_task, update_task_status, get_agent_config, add_log from .. import service_proxy class StockAgent(BaseAgent): agent_id = "stock" display_name = "주식 트레이더" async def on_schedule(self) -> None: """매일 08:00 실행 — 뉴스 수집 + 요약 + 텔레그램 전송.""" if self.state not in ("idle", "break"): return task_id = create_task(self.agent_id, "news_summary", {"limit": 15}) await self.transition("working", "뉴스 수집 중...", task_id) try: news = await service_proxy.fetch_stock_news(limit=15) indices = await service_proxy.fetch_stock_indices() summary = self._format_news_summary(news, indices) update_task_status(task_id, "succeeded", { "summary": summary, "news_count": len(news) if isinstance(news, list) else 0, }) await self.transition("reporting", "뉴스 요약 전송 중...") # Telegram send will be wired in Task 6 from ..telegram_bot import send_stock_summary await send_stock_summary(summary) await self.transition("idle", "뉴스 요약 완료") except Exception as e: add_log(self.agent_id, f"News summary failed: {e}", "error", task_id) update_task_status(task_id, "failed", {"error": str(e)}) await self.transition("idle", f"오류: {e}") async def on_command(self, command: str, params: dict) -> dict: if command == "fetch_news": await self.on_schedule() return {"ok": True, "message": "뉴스 수집 시작"} if command == "add_alert": config = get_agent_config(self.agent_id) alerts = config["custom_config"].get("alerts", []) alerts.append({ "symbol": params["symbol"], "name": params.get("name", params["symbol"]), "target_price": params["target_price"], "direction": params.get("direction", "above"), }) from ..db import update_agent_config update_agent_config(self.agent_id, custom_config={**config["custom_config"], "alerts": alerts}) return {"ok": True, "message": f"알람 추가: {params['symbol']}"} if command == "list_alerts": config = get_agent_config(self.agent_id) alerts = config["custom_config"].get("alerts", []) return {"ok": True, "alerts": alerts} return {"ok": False, "message": f"Unknown command: {command}"} async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: pass # Stock agent has no approval-required tasks def _format_news_summary(self, news, indices) -> str: lines = ["📈 [주식 에이전트] 아침 뉴스 요약", "━" * 20] if isinstance(news, list): for item in news[:10]: title = item.get("title", "") if title: lines.append(f"• {title}") elif isinstance(news, dict) and "articles" in news: for item in news["articles"][:10]: title = item.get("title", "") if title: lines.append(f"• {title}") if indices: lines.append("") lines.append("📊 주요 지수") if isinstance(indices, dict): for key, val in indices.items(): if isinstance(val, dict): name = val.get("name", key) price = val.get("price", "") change = val.get("change", "") lines.append(f"{name}: {price} ({change})") return "\n".join(lines) ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/agents/stock.py git commit -m "feat(agent-office): StockAgent — news summary, price alerts" ``` --- ## Task 6: Telegram Bot **Files:** - Create: `agent-office/app/telegram_bot.py` - [ ] **Step 1: Create telegram_bot.py** ```python import json import uuid import httpx from typing import Optional from .config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_WEBHOOK_URL from .db import save_telegram_callback, get_telegram_callback, mark_telegram_responded _BASE = "https://api.telegram.org/bot" def _enabled() -> bool: return bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID) async def _api(method: str, payload: dict) -> dict: if not _enabled(): return {"ok": False, "description": "Telegram not configured"} async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post(f"{_BASE}{TELEGRAM_BOT_TOKEN}/{method}", json=payload) return resp.json() async def send_message(text: str, reply_markup: dict = None) -> dict: payload = { "chat_id": TELEGRAM_CHAT_ID, "text": text, "parse_mode": "HTML", } if reply_markup: payload["reply_markup"] = reply_markup return await _api("sendMessage", payload) async def send_stock_summary(summary: str) -> dict: return await send_message(summary) async def send_approval_request(agent_id: str, task_id: str, title: str, detail: str) -> dict: approve_id = f"approve_{uuid.uuid4().hex[:8]}" reject_id = f"reject_{uuid.uuid4().hex[:8]}" save_telegram_callback(approve_id, task_id, agent_id) save_telegram_callback(reject_id, task_id, agent_id) text = f"{title}\n{'━' * 20}\n{detail}" reply_markup = { "inline_keyboard": [[ {"text": "✅ 승인", "callback_data": approve_id}, {"text": "❌ 거절", "callback_data": reject_id}, ]] } return await send_message(text, reply_markup) async def send_task_result(agent_id: str, title: str, result: str) -> dict: text = f"{title}\n{'━' * 20}\n{result}" return await send_message(text) async def handle_webhook(data: dict) -> Optional[dict]: """Process incoming Telegram webhook update. Returns action info or None.""" callback_query = data.get("callback_query") if not callback_query: return None callback_id = callback_query.get("data", "") cb = get_telegram_callback(callback_id) if not cb: return None action = "approve" if callback_id.startswith("approve_") else "reject" mark_telegram_responded(callback_id, action) # Answer callback query to remove loading state await _api("answerCallbackQuery", { "callback_query_id": callback_query["id"], "text": "승인됨 ✅" if action == "approve" else "거절됨 ❌", }) return { "task_id": cb["task_id"], "agent_id": cb["agent_id"], "action": action, "approved": action == "approve", } async def setup_webhook() -> dict: if not _enabled() or not TELEGRAM_WEBHOOK_URL: return {"ok": False, "description": "Webhook URL not configured"} return await _api("setWebhook", {"url": TELEGRAM_WEBHOOK_URL}) ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/telegram_bot.py git commit -m "feat(agent-office): Telegram bot — send messages, approval requests, webhook handler" ``` --- ## Task 7: MusicAgent **Files:** - Create: `agent-office/app/agents/music.py` - [ ] **Step 1: Create music.py** ```python import asyncio from .base import BaseAgent from ..db import create_task, update_task_status, approve_task, reject_task, add_log from .. import service_proxy from .. import telegram_bot class MusicAgent(BaseAgent): agent_id = "music" display_name = "음악 프로듀서" async def on_schedule(self) -> None: pass # Music agent is command-driven, not scheduled async def on_command(self, command: str, params: dict) -> dict: if command == "compose": prompt = params.get("prompt", "") style = params.get("style", "") model = params.get("model", "V4") instrumental = params.get("instrumental", False) if not prompt: return {"ok": False, "message": "프롬프트를 입력해주세요"} task_id = create_task(self.agent_id, "compose", { "prompt": prompt, "style": style, "model": model, "instrumental": instrumental, }, requires_approval=True) await self.transition("waiting", "프롬프트 승인 대기", task_id) detail = f"프롬프트: {prompt}" if style: detail += f"\n스타일: {style}" detail += f"\n모델: {model}" await telegram_bot.send_approval_request( self.agent_id, task_id, "🎵 [음악 에이전트] 작곡 요청", detail, ) return {"ok": True, "task_id": task_id, "message": "승인 대기 중"} if command == "credits": credits = await service_proxy.get_music_credits() return {"ok": True, "credits": credits} return {"ok": False, "message": f"Unknown command: {command}"} async def on_approval(self, task_id: str, approved: bool, feedback: str = "") -> None: if not approved: reject_task(task_id) await self.transition("idle", "작곡 거절됨") await telegram_bot.send_task_result( self.agent_id, "🎵 [음악 에이전트] 작곡 취소", "사용자가 거절했습니다.", ) return from ..db import get_task task = get_task(task_id) if not task: return approve_task(task_id, via="telegram") await self.transition("working", "작곡 중...", task_id) try: input_data = task["input_data"] payload = { "provider": "suno", "model": input_data.get("model", "V4"), "prompt": input_data.get("prompt", ""), "style": input_data.get("style", ""), "instrumental": input_data.get("instrumental", False), "custom_mode": True, } result = await service_proxy.generate_music(payload) music_task_id = result.get("task_id") if not music_task_id: raise Exception("music-lab did not return task_id") # Poll for completion for _ in range(60): # max 5 min (60 * 5s) await asyncio.sleep(5) status = await service_proxy.get_music_status(music_task_id) progress = status.get("progress", 0) state = status.get("status", "") if state == "succeeded": tracks = status.get("tracks", []) update_task_status(task_id, "succeeded", { "music_task_id": music_task_id, "tracks": tracks, }) await self.transition("reporting", "작곡 완료!") track_info = "" for t in tracks: title = t.get("title", "Untitled") url = t.get("audio_url", "") track_info += f"🎶 {title}\n{url}\n" await telegram_bot.send_task_result( self.agent_id, "🎵 [음악 에이전트] 작곡 완료", track_info or "트랙 생성 완료", ) await self.transition("idle", "작곡 완료") return if state == "failed": raise Exception(status.get("message", "Generation failed")) raise Exception("Timeout: 5분 초과") except Exception as e: add_log(self.agent_id, f"Compose failed: {e}", "error", task_id) update_task_status(task_id, "failed", {"error": str(e)}) await self.transition("idle", f"오류: {e}") await telegram_bot.send_task_result( self.agent_id, "🎵 [음악 에이전트] 작곡 실패", f"오류: {e}", ) ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/agents/music.py git commit -m "feat(agent-office): MusicAgent — compose with approval, polling, telegram notifications" ``` --- ## Task 8: Scheduler **Files:** - Create: `agent-office/app/scheduler.py` - [ ] **Step 1: Create scheduler.py** ```python import asyncio from apscheduler.schedulers.asyncio import AsyncIOScheduler from .agents import AGENT_REGISTRY scheduler = AsyncIOScheduler(timezone="Asia/Seoul") def _run_async(coro_func): """Wrap async agent method for APScheduler.""" def wrapper(): loop = asyncio.get_event_loop() for agent in AGENT_REGISTRY.values(): if hasattr(agent, coro_func): loop.create_task(getattr(agent, coro_func)()) return wrapper async def _check_idle_breaks(): for agent in AGENT_REGISTRY.values(): await agent.check_idle_break() async def _run_stock_schedule(): agent = AGENT_REGISTRY.get("stock") if agent: await agent.on_schedule() def init_scheduler(): # Stock agent: daily news at 08:00 scheduler.add_job(_run_stock_schedule, "cron", hour=8, minute=0, id="stock_news") # Idle break check: every 60 seconds scheduler.add_job(_check_idle_breaks, "interval", seconds=60, id="idle_check") scheduler.start() ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/scheduler.py git commit -m "feat(agent-office): APScheduler — stock news cron, idle break checker" ``` --- ## Task 9: FastAPI Main — REST + WebSocket + Lifespan **Files:** - Create: `agent-office/app/main.py` - [ ] **Step 1: Create main.py** ```python import os import json from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from .config import CORS_ALLOW_ORIGINS from .db import init_db, get_all_agents, get_agent_config, update_agent_config, get_agent_tasks, get_pending_approvals, get_task, get_logs from .models import CommandRequest, ApprovalRequest, AgentConfigUpdate from .websocket_manager import ws_manager from .agents import init_agents, get_agent, get_all_agent_states, AGENT_REGISTRY from .scheduler import init_scheduler from . import telegram_bot app = FastAPI() _cors_origins = CORS_ALLOW_ORIGINS.split(",") app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in _cors_origins], allow_credentials=False, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type"], ) @app.on_event("startup") async def on_startup(): init_db() os.makedirs("/app/data", exist_ok=True) init_agents() for agent in AGENT_REGISTRY.values(): agent.set_ws_manager(ws_manager) init_scheduler() @app.get("/health") def health(): return {"ok": True} # --- WebSocket --- @app.websocket("/api/agent-office/ws") async def websocket_endpoint(ws: WebSocket): await ws_manager.connect(ws) # Send initial state try: await ws.send_text(json.dumps({ "type": "init", "agents": get_all_agent_states(), "pending": [t["id"] for t in get_pending_approvals()], }, ensure_ascii=False)) while True: data = await ws.receive_text() msg = json.loads(data) await _handle_ws_message(msg) except WebSocketDisconnect: pass finally: await ws_manager.disconnect(ws) async def _handle_ws_message(msg: dict): msg_type = msg.get("type") agent_id = msg.get("agent") agent = get_agent(agent_id) if agent_id else None if msg_type == "command" and agent: action = msg.get("action", "") params = msg.get("params", {}) result = await agent.on_command(action, params) await ws_manager.broadcast({"type": "command_result", "agent": agent_id, "result": result}) elif msg_type == "approval" and agent: task_id = msg.get("task_id") approved = msg.get("approved", False) if task_id: await agent.on_approval(task_id, approved) elif msg_type == "query" and agent: status = await agent.get_status() await ws_manager.broadcast({"type": "agent_status", "agent": agent_id, "status": status}) # --- REST Endpoints --- @app.get("/api/agent-office/agents") def list_agents(): return {"agents": get_all_agents()} @app.get("/api/agent-office/agents/{agent_id}") def agent_detail(agent_id: str): config = get_agent_config(agent_id) if not config: return {"error": "Agent not found"}, 404 agent = get_agent(agent_id) state_info = {"state": agent.state, "detail": agent.state_detail} if agent else {} return {**config, **state_info} @app.put("/api/agent-office/agents/{agent_id}") def update_agent(agent_id: str, body: AgentConfigUpdate): update_agent_config(agent_id, enabled=body.enabled, schedule_config=body.schedule_config, custom_config=body.custom_config) return {"ok": True} @app.get("/api/agent-office/agents/{agent_id}/tasks") def agent_tasks(agent_id: str, limit: int = 20): return {"tasks": get_agent_tasks(agent_id, limit)} @app.get("/api/agent-office/agents/{agent_id}/logs") def agent_logs(agent_id: str, limit: int = 50): return {"logs": get_logs(agent_id, limit)} @app.get("/api/agent-office/tasks/pending") def pending_tasks(): return {"tasks": get_pending_approvals()} @app.get("/api/agent-office/tasks/{task_id}") def task_detail(task_id: str): task = get_task(task_id) if not task: return {"error": "Task not found"}, 404 return task @app.post("/api/agent-office/command") async def send_command(body: CommandRequest): agent = get_agent(body.agent) if not agent: return {"error": f"Agent '{body.agent}' not found"} result = await agent.on_command(body.action, body.params or {}) return result @app.post("/api/agent-office/approve") async def approve(body: ApprovalRequest): agent = get_agent(body.agent) if not agent: return {"error": f"Agent '{body.agent}' not found"} await agent.on_approval(body.task_id, body.approved, body.feedback or "") return {"ok": True} # --- Telegram Webhook --- @app.post("/api/agent-office/telegram/webhook") async def telegram_webhook(data: dict): result = await telegram_bot.handle_webhook(data) if result: agent = get_agent(result["agent_id"]) if agent: await agent.on_approval(result["task_id"], result["approved"]) return {"ok": True} @app.get("/api/agent-office/states") def all_states(): return {"agents": get_all_agent_states()} ``` - [ ] **Step 2: Commit** ```bash git add agent-office/app/main.py git commit -m "feat(agent-office): FastAPI main — REST routes, WebSocket, telegram webhook, lifespan" ``` --- ## Task 10: Infrastructure — Docker Compose + Nginx **Files:** - Modify: `docker-compose.yml` - Modify: `nginx/default.conf` - [ ] **Step 1: Add agent-office to docker-compose.yml** Add the following service block after the existing services (e.g., after `realestate-lab`): ```yaml agent-office: build: context: ./agent-office container_name: agent-office restart: unless-stopped ports: - "18900:8000" environment: - TZ=${TZ:-Asia/Seoul} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - STOCK_LAB_URL=http://stock-lab:8000 - MUSIC_LAB_URL=http://music-lab:8000 - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} - TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-} volumes: - ${RUNTIME_PATH:-.}/data/agent-office:/app/data depends_on: - stock-lab - music-lab healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] interval: 30s timeout: 5s retries: 3 ``` - [ ] **Step 2: Add Nginx location block for agent-office** Add before the catch-all `/api/` block in `nginx/default.conf`: ```nginx # agent-office API + WebSocket location /api/agent-office/ { resolver 127.0.0.11 valid=10s; set $agent_office_backend agent-office:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 86400s; proxy_pass http://$agent_office_backend$request_uri; } ``` - [ ] **Step 3: Commit** ```bash git add docker-compose.yml nginx/default.conf git commit -m "infra(agent-office): Docker Compose service + Nginx WebSocket proxy" ``` --- ## Task 11: Frontend — API Helpers + Route + Lab Entry **Files:** - Modify: `web-ui/src/api.js` — add agent-office helpers - Modify: `web-ui/src/routes.jsx` — add route - Modify: `web-ui/src/pages/effect-lab/EffectLab.jsx` — add LAB_ITEMS entry - [ ] **Step 1: Add API helpers to api.js** Append to the end of `web-ui/src/api.js`: ```javascript // ── 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'); ``` - [ ] **Step 2: Add route to routes.jsx** Add to the `appRoutes` array: ```javascript { path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') }, ``` And add to `navLinks` array: ```javascript { id: 'agent-office', label: 'Agent Office', path: '/agent-office', subtitle: 'AI LAB', description: 'AI 에이전트 사무실', icon: 🏢, accent: '#8b5cf6', }, ``` - [ ] **Step 3: Add to LAB_ITEMS in EffectLab.jsx** Add to the `LAB_ITEMS` array: ```javascript { 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', }, ``` - [ ] **Step 4: Commit** ```bash cd ../web-ui git add src/api.js src/routes.jsx src/pages/effect-lab/EffectLab.jsx git commit -m "feat(agent-office): API helpers, route, Lab entry" ``` --- ## Task 12: Frontend Canvas — SpriteSheet + TileMap **Files:** - Create: `web-ui/src/pages/agent-office/canvas/SpriteSheet.js` - Create: `web-ui/src/pages/agent-office/canvas/TileMap.js` - Create: `web-ui/src/pages/agent-office/assets/office-map.json` - [ ] **Step 1: Create office-map.json** ```json { "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" } } ``` - [ ] **Step 2: Create SpriteSheet.js** ```javascript // web-ui/src/pages/agent-office/canvas/SpriteSheet.js const PIXEL_CHARS = { stock: { body: '#4488cc', accent: '#cc4444', // necktie label: '주식', hair: '#332222', }, music: { body: '#44aa88', accent: '#ffaa00', // headphones label: '음악', hair: '#443322', }, claude: { body: '#8855cc', accent: '#cc88ff', label: 'Claude', hair: '#554466', }, }; const ANIM_FRAMES = { idle: { frames: 2, speed: 800 }, // ms per frame 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 (agent-specific) ctx.fillStyle = char.accent; if (agentId === 'stock') { // Tie ctx.fillRect(0, 2 * s, 1 * s, 5 * s); } else if (agentId === 'music') { // Headphones 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') { // AI glow 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 animation: 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; } ``` - [ ] **Step 3: Create TileMap.js** ```javascript // web-ui/src/pages/agent-office/canvas/TileMap.js 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'; const PLANT_POT = '#664422'; const PLANT_LEAF = '#44aa44'; 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); // Floor tiles 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); // Grid line ctx.strokeStyle = 'rgba(255,255,255,0.03)'; ctx.strokeRect(c * tileSize, r * tileSize, tileSize, tileSize); } } // Walls (top edge) ctx.fillStyle = WALL_COLOR; ctx.fillRect(0, 0, cols * tileSize, 4); // Furniture 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') { _drawDesk(ctx, fx, fy, fw, fh, f.label); } else if (f.type === 'table') { _drawTable(ctx, fx, fy, fw, fh); } else if (f.type === 'sofa') { _drawSofa(ctx, fx, fy); } else if (f.type === 'coffee') { _drawCoffee(ctx, fx, fy); } } ctx.restore(); return { scale, offsetX, offsetY, tileSize }; } function _drawDesk(ctx, x, y, w, h, label) { // Desk surface ctx.fillStyle = DESK_COLOR; ctx.fillRect(x, y, w, h); ctx.fillStyle = DESK_TOP; ctx.fillRect(x + 2, y + 2, w - 4, 6); // Monitor const mx = x + w / 2 - 8; ctx.fillStyle = MONITOR_COLOR; ctx.fillRect(mx, y + 4, 16, 12); ctx.fillStyle = MONITOR_SCREEN; ctx.fillRect(mx + 2, y + 6, 12, 8); // Label if (label) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, x + w / 2, y + h + 12); } } function _drawTable(ctx, x, y, w, h) { ctx.fillStyle = TABLE_COLOR; ctx.fillRect(x, y, w, h); ctx.fillStyle = '#7a6a4a'; ctx.fillRect(x + 4, y + 4, w - 8, h - 8); } function _drawSofa(ctx, x, y) { ctx.fillStyle = SOFA_COLOR; ctx.fillRect(x, y, 48, 32); ctx.fillStyle = '#aa5555'; ctx.fillRect(x + 4, y + 4, 40, 24); } function _drawCoffee(ctx, x, y) { ctx.fillStyle = PLANT_POT; ctx.fillRect(x + 8, y + 8, 16, 20); ctx.fillStyle = '#886644'; ctx.fillRect(x + 6, y + 6, 20, 4); } 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, }; } ``` - [ ] **Step 4: Commit** ```bash git add src/pages/agent-office/canvas/ src/pages/agent-office/assets/ git commit -m "feat(agent-office): Canvas engine — SpriteSheet, TileMap, office-map data" ``` --- ## Task 13: Frontend Canvas — AgentSprite + OfficeRenderer **Files:** - Create: `web-ui/src/pages/agent-office/canvas/AgentSprite.js` - Create: `web-ui/src/pages/agent-office/canvas/OfficeRenderer.js` - [ ] **Step 1: Create AgentSprite.js** ```javascript // web-ui/src/pages/agent-office/canvas/AgentSprite.js 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; // tiles per frame } 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) { // Frame animation const speed = getAnimSpeed(this.state); if (now - this._lastFrameTime > speed) { this.frameIndex++; this._lastFrameTime = now; } // Movement 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; } } ``` - [ ] **Step 2: Create OfficeRenderer.js** ```javascript // web-ui/src/pages/agent-office/canvas/OfficeRenderer.js 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; // Initialize agents from map waypoints const agentIds = ['stock', 'music', 'claude']; 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; // Clear ctx.clearRect(0, 0, canvas.width, canvas.height); // Background ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, canvas.width, canvas.height); // Draw tilemap this.renderInfo = drawTileMap(ctx, mapData, canvas.width, canvas.height); // Update and draw agents const now = Date.now(); for (const sprite of Object.values(this.agents)) { sprite.update(now); sprite.draw(ctx, this.renderInfo); } // Draw overlays (bubbles, icons, labels) 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; // Status icon const icon = STATUS_ICONS[sprite.state]; if (icon) { ctx.font = `${14 * scale}px serif`; ctx.textAlign = 'center'; ctx.fillText(icon, cx, cy - 15 * scale); } // Name label 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); // Detail bubble (working/waiting) 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); } } } ``` - [ ] **Step 3: Commit** ```bash git add src/pages/agent-office/canvas/ git commit -m "feat(agent-office): AgentSprite movement + OfficeRenderer game loop" ``` --- ## Task 14: Frontend Hooks — useAgentManager + useOfficeCanvas **Files:** - Create: `web-ui/src/pages/agent-office/hooks/useAgentManager.js` - Create: `web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js` - [ ] **Step 1: Create useAgentManager.js** ```javascript // web-ui/src/pages/agent-office/hooks/useAgentManager.js 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 }; } ``` - [ ] **Step 2: Create useOfficeCanvas.js** ```javascript // web-ui/src/pages/agent-office/hooks/useOfficeCanvas.js 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); const canvasRef = 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); canvasRef.current = 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 }; } ``` - [ ] **Step 3: Commit** ```bash git add src/pages/agent-office/hooks/ git commit -m "feat(agent-office): useAgentManager WebSocket hook + useOfficeCanvas rendering hook" ``` --- ## Task 15: Frontend Components — ChatPanel + TaskHistory **Files:** - Create: `web-ui/src/pages/agent-office/components/ChatPanel.jsx` - Create: `web-ui/src/pages/agent-office/components/TaskHistory.jsx` - [ ] **Step 1: Create ChatPanel.jsx** ```jsx // web-ui/src/pages/agent-office/components/ChatPanel.jsx 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: '💳' }, ], claude: [ { action: 'instruct', label: '지시하기', icon: '💬', needsInput: true }, ], }; 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 (
승인 대기 중인 작업이 있습니다
{JSON.stringify(state.lastResult, null, 2)}
로딩 중...
} {!loading && tasks.length === 0 &&이력 없음
} {tasks.map(task => { const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending; return ({JSON.stringify(task.result_data, null, 2)}