17개 태스크: 백엔드 scaffold → FSM → 에이전트 → 텔레그램 → 인프라 → 프론트엔드 Canvas → UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
87 KiB
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
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
# agent-office/app/__init__.py
Empty file for package init.
- Step 4: Create config.py
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
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
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
# 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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):
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:
# 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
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:
// ── 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:
{ path: 'agent-office', lazy: () => import('./pages/agent-office/AgentOffice') },
And add to navLinks array:
{
id: 'agent-office',
label: 'Agent Office',
path: '/agent-office',
subtitle: 'AI LAB',
description: 'AI 에이전트 사무실',
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
accent: '#8b5cf6',
},
- Step 3: Add to LAB_ITEMS in EffectLab.jsx
Add to the LAB_ITEMS array:
{
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
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
{
"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
// 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
// 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
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
// 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
// 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
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
// 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
// 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
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
// 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 (
<div className="ao-chat-panel">
<div className="ao-chat-header">
<span className="ao-chat-title">
{agentId === 'stock' ? '주식 트레이더' :
agentId === 'music' ? '음악 프로듀서' : 'Claude AI'}
</span>
<span className={`ao-chat-state ao-chat-state--${state.state || 'idle'}`}>
{state.state || 'idle'}
</span>
<button className="ao-chat-close" onClick={onClose}>×</button>
</div>
{state.detail && (
<div className="ao-chat-detail">{state.detail}</div>
)}
{state.state === 'waiting' && state.taskId && (
<div className="ao-chat-approval">
<p>승인 대기 중인 작업이 있습니다</p>
<div className="ao-chat-approval-btns">
<button className="ao-btn ao-btn--approve"
onClick={() => onApproval(agentId, state.taskId, true)}>
✅ 승인
</button>
<button className="ao-btn ao-btn--reject"
onClick={() => onApproval(agentId, state.taskId, false)}>
❌ 거절
</button>
</div>
</div>
)}
<div className="ao-chat-commands">
{commands.map(cmd => (
<button key={cmd.action} className="ao-cmd-btn"
onClick={() => handleQuickAction(cmd)}>
<span>{cmd.icon}</span> {cmd.label}
</button>
))}
</div>
{activeCommand && (
<div className="ao-chat-input-area">
<input
type="text"
className="ao-chat-input"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder={activeCommand === 'compose' ? '프롬프트 입력...' : '메시지 입력...'}
autoFocus
/>
<button className="ao-btn ao-btn--send" onClick={handleSend}>전송</button>
</div>
)}
{state.lastResult && (
<div className="ao-chat-result">
<h4>최근 결과</h4>
<pre>{JSON.stringify(state.lastResult, null, 2)}</pre>
</div>
)}
</div>
);
};
export default ChatPanel;
- Step 2: Create TaskHistory.jsx
// web-ui/src/pages/agent-office/components/TaskHistory.jsx
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' },
};
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 (
<div className="ao-history-panel">
<div className="ao-history-header">
<span>작업 이력 — {agentId}</span>
<button className="ao-chat-close" onClick={onClose}>×</button>
</div>
<div className="ao-history-list">
{loading && <p className="ao-history-empty">로딩 중...</p>}
{!loading && tasks.length === 0 && <p className="ao-history-empty">이력 없음</p>}
{tasks.map(task => {
const badge = STATUS_BADGE[task.status] || STATUS_BADGE.pending;
return (
<div key={task.id} className="ao-history-item">
<div className="ao-history-item-header">
<span className="ao-history-type">{task.task_type}</span>
<span className="ao-history-badge" style={{ background: badge.color }}>
{badge.label}
</span>
</div>
<div className="ao-history-time">
{task.created_at?.replace('T', ' ').slice(0, 19)}
</div>
{task.result_data && (
<details className="ao-history-detail">
<summary>결과 보기</summary>
<pre>{JSON.stringify(task.result_data, null, 2)}</pre>
</details>
)}
</div>
);
})}
</div>
</div>
);
};
export default TaskHistory;
- Step 3: Commit
git add src/pages/agent-office/components/
git commit -m "feat(agent-office): ChatPanel with commands/approval + TaskHistory panel"
Task 16: Frontend — AgentOffice Main Page + CSS
Files:
-
Create:
web-ui/src/pages/agent-office/AgentOffice.jsx -
Create:
web-ui/src/pages/agent-office/AgentOffice.css -
Step 1: Create AgentOffice.jsx
// web-ui/src/pages/agent-office/AgentOffice.jsx
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);
// Sync WebSocket state to canvas
useEffect(() => {
for (const [id, info] of Object.entries(agents)) {
updateAgentState(id, info.state, info.detail);
}
}, [agents, updateAgentState]);
return (
<div className="ao-page">
<div className="ao-header">
<h1 className="ao-title">Agent Office</h1>
<div className="ao-status">
<span className={`ao-dot ${connected ? 'ao-dot--on' : 'ao-dot--off'}`} />
{connected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="ao-workspace">
<div className="ao-canvas-container" ref={canvasContainerRef} />
{/* Agent indicator bar */}
<div className="ao-agent-bar">
{Object.entries(agents).map(([id, info]) => (
<button
key={id}
className={`ao-agent-chip ${info.state === 'waiting' ? 'ao-agent-chip--alert' : ''} ${selectedAgent === id ? 'ao-agent-chip--selected' : ''}`}
onClick={() => handleAgentClick(id)}
>
<span className={`ao-chip-dot ao-chip-dot--${info.state}`} />
{id}
{info.state === 'waiting' && <span className="ao-chip-badge">!</span>}
</button>
))}
{pendingTasks.length > 0 && (
<span className="ao-pending-count">{pendingTasks.length} pending</span>
)}
</div>
{/* Panels */}
{selectedAgent && (
<ChatPanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
onCommand={sendCommand}
onApproval={sendApproval}
onClose={() => setSelectedAgent(null)}
/>
)}
{showHistory && (
<TaskHistory
agentId={showHistory}
onClose={() => setShowHistory(null)}
/>
)}
</div>
{/* Bottom toolbar */}
<div className="ao-toolbar">
{['stock', 'music', 'claude'].map(id => (
<button key={id} className="ao-tool-btn"
onClick={() => setShowHistory(prev => prev === id ? null : id)}>
📋 {id} 이력
</button>
))}
</div>
</div>
);
}
- Step 2: Create AgentOffice.css
/* web-ui/src/pages/agent-office/AgentOffice.css */
.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%;
}
/* Agent bar */
.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;
}
/* Chat Panel */
.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;
}
/* Task History */
.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;
}
/* Toolbar */
.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; }
- Step 3: Commit
git add src/pages/agent-office/
git commit -m "feat(agent-office): AgentOffice main page with canvas + overlay panels + CSS"
Task 17: CLAUDE.md Updates
Files:
-
Modify:
web-backend/CLAUDE.md -
Step 1: Add agent-office to CLAUDE.md
Add to the Docker services table (section 4):
| `agent-office` | 18900 | AI 에이전트 사무실 — FSM, 텔레그램, 스케줄러 |
Add to the Nginx routing rules (section 5):
| `/api/agent-office/` | `agent-office:8000` | Agent Office API + WebSocket |
Add a new subsection to section 9 (서비스별 핵심 정보):
### agent-office (agent-office/)
- AI 에이전트 가상 사무실 서비스 (에이전트 FSM, 텔레그램 양방향, 스케줄러)
- 기존 서비스 프록시 호출 (stock-lab, music-lab)
- DB: `/app/data/agent_office.db` (agent_config, agent_tasks, agent_logs, telegram_state)
- 파일 구조: `main.py`, `config.py`, `db.py`, `models.py`, `websocket_manager.py`, `service_proxy.py`, `telegram_bot.py`, `scheduler.py`, `agents/base.py`, `agents/stock.py`, `agents/music.py`
**환경변수**
- `TELEGRAM_BOT_TOKEN`: 텔레그램 Bot API 토큰
- `TELEGRAM_CHAT_ID`: 알림 수신 채팅 ID
- `TELEGRAM_WEBHOOK_URL`: 텔레그램 Webhook 수신 URL
- `STOCK_LAB_URL`: stock-lab 내부 URL (기본 `http://stock-lab:8000`)
- `MUSIC_LAB_URL`: music-lab 내부 URL (기본 `http://music-lab:8000`)
**에이전트 목록 (MVP)**
- `stock`: 매일 08:00 뉴스 요약 + 주가 알람 (자동)
- `music`: 프롬프트 기반 작곡 (승인 필요)
**스케줄러 job**
- 08:00 매일 — StockAgent 뉴스 수집/요약/텔레그램 전송
- 60초 간격 — 에이전트 idle break 체크
**agent-office API 목록**
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/agent-office/agents` | 에이전트 목록 |
| GET | `/api/agent-office/agents/{id}` | 에이전트 상세 (설정+상태) |
| PUT | `/api/agent-office/agents/{id}` | 에이전트 설정 수정 |
| GET | `/api/agent-office/agents/{id}/tasks` | 에이전트 작업 이력 |
| GET | `/api/agent-office/agents/{id}/logs` | 에이전트 로그 |
| GET | `/api/agent-office/tasks/pending` | 승인 대기 작업 목록 |
| GET | `/api/agent-office/tasks/{id}` | 작업 상세 |
| POST | `/api/agent-office/command` | 에이전트 직접 지시 |
| POST | `/api/agent-office/approve` | 작업 승인/거절 |
| GET | `/api/agent-office/states` | 전체 에이전트 상태 |
| WS | `/api/agent-office/ws` | WebSocket 실시간 연결 |
| POST | `/api/agent-office/telegram/webhook` | 텔레그램 Webhook 수신 |
- Step 2: Commit
git add CLAUDE.md
git commit -m "docs: add agent-office service to CLAUDE.md"
Summary
| Task | Component | Estimated Complexity |
|---|---|---|
| 1 | Backend scaffold (config, db, models) | Standard |
| 2 | WebSocket manager | Simple |
| 3 | Service proxy | Simple |
| 4 | BaseAgent FSM | Standard |
| 5 | StockAgent | Standard |
| 6 | Telegram bot | Standard |
| 7 | MusicAgent | Standard |
| 8 | Scheduler | Simple |
| 9 | FastAPI main (REST + WS) | Complex |
| 10 | Infrastructure (Docker + Nginx) | Standard |
| 11 | Frontend API + routing + Lab entry | Simple |
| 12 | Canvas SpriteSheet + TileMap | Complex |
| 13 | Canvas AgentSprite + OfficeRenderer | Complex |
| 14 | Frontend hooks (WebSocket + Canvas) | Standard |
| 15 | Frontend ChatPanel + TaskHistory | Standard |
| 16 | Frontend AgentOffice page + CSS | Standard |
| 17 | CLAUDE.md updates | Simple |