17개 태스크: 백엔드 scaffold → FSM → 에이전트 → 텔레그램 → 인프라 → 프론트엔드 Canvas → UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2962 lines
87 KiB
Markdown
2962 lines
87 KiB
Markdown
# 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: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
|
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 (
|
|
<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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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 (서비스별 핵심 정보):
|
|
|
|
```markdown
|
|
### 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**
|
|
|
|
```bash
|
|
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 |
|