pyaesonegtckglay-dotcom commited on
Commit Β·
666aab6
0
Parent(s):
π Devin Agent Platform v2.0
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- backend/Dockerfile +30 -0
- backend/Dockerfile.hf +48 -0
- backend/README.md +58 -0
- backend/__pycache__/main.cpython-312.pyc +0 -0
- backend/api/__init__.py +0 -0
- backend/api/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/api/__pycache__/websocket_manager.cpython-312.pyc +0 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/api/routes/__pycache__/chat.cpython-312.pyc +0 -0
- backend/api/routes/__pycache__/github.cpython-312.pyc +0 -0
- backend/api/routes/__pycache__/health.cpython-312.pyc +0 -0
- backend/api/routes/__pycache__/memory.cpython-312.pyc +0 -0
- backend/api/routes/__pycache__/tasks.cpython-312.pyc +0 -0
- backend/api/routes/chat.py +214 -0
- backend/api/routes/github.py +336 -0
- backend/api/routes/health.py +53 -0
- backend/api/routes/memory.py +50 -0
- backend/api/routes/tasks.py +167 -0
- backend/api/websocket_manager.py +134 -0
- backend/core/__init__.py +0 -0
- backend/core/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/core/__pycache__/agent.cpython-312.pyc +0 -0
- backend/core/__pycache__/models.cpython-312.pyc +0 -0
- backend/core/__pycache__/task_engine.cpython-312.pyc +0 -0
- backend/core/agent.py +392 -0
- backend/core/models.py +213 -0
- backend/core/task_engine.py +241 -0
- backend/ecosystem.config.cjs +20 -0
- backend/github/__init__.py +0 -0
- backend/main.py +180 -0
- backend/memory/__init__.py +0 -0
- backend/memory/__pycache__/__init__.cpython-312.pyc +0 -0
- backend/memory/__pycache__/db.cpython-312.pyc +0 -0
- backend/memory/db.py +271 -0
- backend/requirements.txt +28 -0
- backend/tools/__init__.py +0 -0
- backend/tools/executor.py +176 -0
- frontend/app/globals.css +178 -0
- frontend/app/layout.tsx +21 -0
- frontend/app/page.tsx +65 -0
- frontend/components/chat/ChatPanel.tsx +233 -0
- frontend/components/chat/MessageBubble.tsx +162 -0
- frontend/components/layout/MemoryPanel.tsx +103 -0
- frontend/components/layout/Sidebar.tsx +193 -0
- frontend/components/layout/TasksPanel.tsx +170 -0
- frontend/components/layout/TopBar.tsx +98 -0
- frontend/components/timeline/ExecutionTimeline.tsx +224 -0
- frontend/ecosystem.config.cjs +19 -0
- frontend/hooks/nanoid.ts +8 -0
backend/Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Install system deps
|
| 4 |
+
RUN apt-get update && apt-get install -y \
|
| 5 |
+
git curl build-essential libssl-dev \
|
| 6 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
# Install Python deps
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy source
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# Create workspace
|
| 18 |
+
RUN mkdir -p /tmp/workspace /tmp/repos
|
| 19 |
+
|
| 20 |
+
# HuggingFace Spaces runs as user 1000
|
| 21 |
+
RUN useradd -m -u 1000 user && chown -R user:user /app /tmp/workspace /tmp/repos
|
| 22 |
+
USER 1000
|
| 23 |
+
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
ENV PORT=7860
|
| 27 |
+
ENV HOST=0.0.0.0
|
| 28 |
+
ENV DB_PATH=/tmp/devin_agent.db
|
| 29 |
+
|
| 30 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1", "--loop", "asyncio"]
|
backend/Dockerfile.hf
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# HuggingFace Spaces Dockerfile
|
| 4 |
+
# Compatible with free CPU tier
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
# System deps
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
git curl build-essential \
|
| 11 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
+
|
| 13 |
+
# Python deps
|
| 14 |
+
COPY requirements.txt .
|
| 15 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 16 |
+
pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# App code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Setup dirs
|
| 22 |
+
RUN mkdir -p /tmp/workspace /tmp/repos /tmp/devin_data
|
| 23 |
+
|
| 24 |
+
# HF runs as uid 1000
|
| 25 |
+
RUN useradd -m -u 1000 user 2>/dev/null || true
|
| 26 |
+
RUN chown -R 1000:1000 /app /tmp/workspace /tmp/repos /tmp/devin_data
|
| 27 |
+
|
| 28 |
+
USER 1000
|
| 29 |
+
|
| 30 |
+
EXPOSE 7860
|
| 31 |
+
|
| 32 |
+
ENV PORT=7860
|
| 33 |
+
ENV HOST=0.0.0.0
|
| 34 |
+
ENV DB_PATH=/tmp/devin_agent.db
|
| 35 |
+
ENV PYTHONUNBUFFERED=1
|
| 36 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 37 |
+
|
| 38 |
+
# Health check
|
| 39 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
| 40 |
+
CMD curl -f http://localhost:7860/api/v1/health || exit 1
|
| 41 |
+
|
| 42 |
+
CMD ["uvicorn", "main:app", \
|
| 43 |
+
"--host", "0.0.0.0", \
|
| 44 |
+
"--port", "7860", \
|
| 45 |
+
"--workers", "1", \
|
| 46 |
+
"--loop", "asyncio", \
|
| 47 |
+
"--timeout-keep-alive", "75", \
|
| 48 |
+
"--log-level", "info"]
|
backend/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Devin Agent Platform
|
| 3 |
+
emoji: π€
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Production-grade autonomous AI engineering platform
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# π€ Devin Agent Platform v2.0
|
| 14 |
+
|
| 15 |
+
> **Manus/Devin-style Autonomous AI Engineering Platform**
|
| 16 |
+
> Real-time WebSocket streaming Β· Autonomous GitHub operations Β· Persistent memory
|
| 17 |
+
|
| 18 |
+
## β¨ Features
|
| 19 |
+
|
| 20 |
+
- β‘ **Real-time WebSocket streaming** β live token-by-token LLM output
|
| 21 |
+
- πΊοΈ **Autonomous task planning** β goal β plan β execute automatically
|
| 22 |
+
- π§ **Persistent memory** β SQLite-backed conversation + project memory
|
| 23 |
+
- π **GitHub automation** β clone, commit, push, PR, issues autonomously
|
| 24 |
+
- π **Self-healing** β auto-retry with exponential backoff
|
| 25 |
+
- π‘ **SSE fallback** β Server-Sent Events for streaming compatibility
|
| 26 |
+
- π **REST + WebSocket API** β full-featured backend
|
| 27 |
+
|
| 28 |
+
## π API Endpoints
|
| 29 |
+
|
| 30 |
+
| Method | Endpoint | Description |
|
| 31 |
+
|--------|----------|-------------|
|
| 32 |
+
| POST | `/api/v1/tasks/create` | Create autonomous task |
|
| 33 |
+
| GET | `/api/v1/tasks/{id}` | Get task details |
|
| 34 |
+
| POST | `/api/v1/tasks/{id}/cancel` | Cancel task |
|
| 35 |
+
| POST | `/api/v1/tasks/{id}/retry` | Retry failed task |
|
| 36 |
+
| GET | `/api/v1/tasks/{id}/stream` | SSE task stream |
|
| 37 |
+
| POST | `/api/v1/chat` | Chat with agent |
|
| 38 |
+
| POST | `/api/v1/goal` | Submit high-level goal |
|
| 39 |
+
| POST | `/api/v1/plan` | Generate execution plan |
|
| 40 |
+
| WS | `/ws/tasks/{task_id}` | Live task WebSocket |
|
| 41 |
+
| WS | `/ws/logs` | Global log stream |
|
| 42 |
+
| WS | `/ws/chat/{session_id}` | Chat WebSocket |
|
| 43 |
+
| WS | `/ws/agent/status` | Agent status stream |
|
| 44 |
+
|
| 45 |
+
## π Environment Variables (HF Secrets)
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
OPENAI_API_KEY = sk-... (for real AI)
|
| 49 |
+
ANTHROPIC_API_KEY = sk-ant-... (alternative)
|
| 50 |
+
GITHUB_TOKEN = ghp_... (GitHub ops)
|
| 51 |
+
GITHUB_OWNER = your-username (GitHub ops)
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## π Quick Start
|
| 55 |
+
|
| 56 |
+
Visit `/api/docs` for interactive Swagger UI.
|
| 57 |
+
|
| 58 |
+
**Demo mode** works without any API keys β set `OPENAI_API_KEY` for real AI.
|
backend/__pycache__/main.cpython-312.pyc
ADDED
|
Binary file (9.42 kB). View file
|
|
|
backend/api/__init__.py
ADDED
|
File without changes
|
backend/api/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (143 Bytes). View file
|
|
|
backend/api/__pycache__/websocket_manager.cpython-312.pyc
ADDED
|
Binary file (7.67 kB). View file
|
|
|
backend/api/routes/__init__.py
ADDED
|
File without changes
|
backend/api/routes/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (150 Bytes). View file
|
|
|
backend/api/routes/__pycache__/chat.cpython-312.pyc
ADDED
|
Binary file (10.6 kB). View file
|
|
|
backend/api/routes/__pycache__/github.cpython-312.pyc
ADDED
|
Binary file (17.8 kB). View file
|
|
|
backend/api/routes/__pycache__/health.cpython-312.pyc
ADDED
|
Binary file (2.93 kB). View file
|
|
|
backend/api/routes/__pycache__/memory.cpython-312.pyc
ADDED
|
Binary file (2.96 kB). View file
|
|
|
backend/api/routes/__pycache__/tasks.cpython-312.pyc
ADDED
|
Binary file (8.07 kB). View file
|
|
|
backend/api/routes/chat.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat + Goal API Routes β Real-time streaming responses
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
|
| 13 |
+
from core.models import ChatRequest, GoalRequest, TaskCreateRequest
|
| 14 |
+
from memory.db import save_memory, get_history
|
| 15 |
+
|
| 16 |
+
router = APIRouter()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_engine(request: Request):
|
| 20 |
+
return request.app.state.task_engine
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_ws(request: Request):
|
| 24 |
+
return request.app.state.ws_manager
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# βββ Chat (REST + SSE streaming) βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
|
| 29 |
+
@router.post("/chat", summary="Chat with the agent")
|
| 30 |
+
async def chat(req: ChatRequest, request: Request):
|
| 31 |
+
from core.agent import AgentCore
|
| 32 |
+
ws = get_ws(request)
|
| 33 |
+
agent = AgentCore(ws)
|
| 34 |
+
|
| 35 |
+
messages = [{"role": m.role, "content": m.content} for m in req.messages]
|
| 36 |
+
|
| 37 |
+
if req.stream:
|
| 38 |
+
async def stream_gen():
|
| 39 |
+
async def _run():
|
| 40 |
+
result = await agent.llm_stream(
|
| 41 |
+
messages=messages,
|
| 42 |
+
session_id=req.session_id,
|
| 43 |
+
model=req.model,
|
| 44 |
+
temperature=req.temperature,
|
| 45 |
+
max_tokens=req.max_tokens,
|
| 46 |
+
)
|
| 47 |
+
await save_memory(
|
| 48 |
+
content=result,
|
| 49 |
+
memory_type="conversation",
|
| 50 |
+
session_id=req.session_id,
|
| 51 |
+
project_id=req.project_id,
|
| 52 |
+
key="assistant",
|
| 53 |
+
)
|
| 54 |
+
# Save user message too
|
| 55 |
+
user_msg = next((m["content"] for m in reversed(messages) if m["role"] == "user"), "")
|
| 56 |
+
await save_memory(
|
| 57 |
+
content=user_msg,
|
| 58 |
+
memory_type="conversation",
|
| 59 |
+
session_id=req.session_id,
|
| 60 |
+
project_id=req.project_id,
|
| 61 |
+
key="user",
|
| 62 |
+
)
|
| 63 |
+
return result
|
| 64 |
+
|
| 65 |
+
room_buffer = []
|
| 66 |
+
original_emit_chat = ws.emit_chat
|
| 67 |
+
async def capture_emit(sid, etype, data):
|
| 68 |
+
if etype == "llm_chunk":
|
| 69 |
+
chunk = data.get("chunk", "")
|
| 70 |
+
room_buffer.append(chunk)
|
| 71 |
+
yield_data = json.dumps({"type": etype, "data": data, "session_id": sid})
|
| 72 |
+
return yield_data
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
# Stream tokens directly
|
| 76 |
+
full = ""
|
| 77 |
+
from core.agent import AgentCore as _A
|
| 78 |
+
import httpx
|
| 79 |
+
import os
|
| 80 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 81 |
+
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
| 82 |
+
|
| 83 |
+
if OPENAI_API_KEY:
|
| 84 |
+
headers = {
|
| 85 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 86 |
+
"Content-Type": "application/json",
|
| 87 |
+
}
|
| 88 |
+
payload = {
|
| 89 |
+
"model": req.model,
|
| 90 |
+
"messages": messages,
|
| 91 |
+
"stream": True,
|
| 92 |
+
"temperature": req.temperature,
|
| 93 |
+
"max_tokens": req.max_tokens,
|
| 94 |
+
}
|
| 95 |
+
from core.agent import OPENAI_BASE_URL
|
| 96 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 97 |
+
async with client.stream("POST", f"{OPENAI_BASE_URL}/chat/completions",
|
| 98 |
+
headers=headers, json=payload) as resp:
|
| 99 |
+
async for line in resp.aiter_lines():
|
| 100 |
+
if not line.startswith("data:"):
|
| 101 |
+
continue
|
| 102 |
+
chunk_str = line[6:].strip()
|
| 103 |
+
if chunk_str == "[DONE]":
|
| 104 |
+
break
|
| 105 |
+
try:
|
| 106 |
+
data = json.loads(chunk_str)
|
| 107 |
+
delta = data["choices"][0]["delta"].get("content", "")
|
| 108 |
+
if delta:
|
| 109 |
+
full += delta
|
| 110 |
+
yield f"data: {json.dumps({'type': 'llm_chunk', 'data': {'chunk': delta}, 'session_id': req.session_id})}\n\n"
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
else:
|
| 114 |
+
# Demo streaming
|
| 115 |
+
demo = (
|
| 116 |
+
f"Hello! I'm your Devin-style AI Agent. I received: '{req.messages[-1].content[:80]}'. "
|
| 117 |
+
f"Set OPENAI_API_KEY or ANTHROPIC_API_KEY for real AI responses. "
|
| 118 |
+
f"I support real-time streaming, task planning, GitHub automation, and more!"
|
| 119 |
+
)
|
| 120 |
+
for word in demo.split():
|
| 121 |
+
chunk = word + " "
|
| 122 |
+
full += chunk
|
| 123 |
+
await asyncio.sleep(0.04)
|
| 124 |
+
yield f"data: {json.dumps({'type': 'llm_chunk', 'data': {'chunk': chunk}, 'session_id': req.session_id})}\n\n"
|
| 125 |
+
|
| 126 |
+
yield f"data: {json.dumps({'type': 'stream_end', 'data': {'full_response': full}, 'session_id': req.session_id})}\n\n"
|
| 127 |
+
|
| 128 |
+
return StreamingResponse(
|
| 129 |
+
stream_gen(),
|
| 130 |
+
media_type="text/event-stream",
|
| 131 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
| 132 |
+
)
|
| 133 |
+
else:
|
| 134 |
+
# Non-streaming
|
| 135 |
+
agent = AgentCore(get_ws(request))
|
| 136 |
+
result = await agent.llm_stream(messages, session_id=req.session_id)
|
| 137 |
+
return {
|
| 138 |
+
"response": result,
|
| 139 |
+
"session_id": req.session_id,
|
| 140 |
+
"model": req.model,
|
| 141 |
+
"timestamp": time.time(),
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.post("/chat/stream", summary="Explicit streaming chat endpoint")
|
| 146 |
+
async def chat_stream(req: ChatRequest, request: Request):
|
| 147 |
+
req.stream = True
|
| 148 |
+
return await chat(req, request)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# βββ Goal API (create task from goal) βββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
+
|
| 153 |
+
@router.post("/goal", summary="Submit a high-level goal to the agent")
|
| 154 |
+
async def submit_goal(req: GoalRequest, request: Request):
|
| 155 |
+
engine = get_engine(request)
|
| 156 |
+
task_req = TaskCreateRequest(
|
| 157 |
+
goal=req.goal,
|
| 158 |
+
session_id=req.session_id,
|
| 159 |
+
project_id=req.project_id,
|
| 160 |
+
stream=req.stream,
|
| 161 |
+
metadata={"source": "goal_api", "github_repo": req.github_repo},
|
| 162 |
+
)
|
| 163 |
+
task_id = await engine.submit(task_req)
|
| 164 |
+
return {
|
| 165 |
+
"task_id": task_id,
|
| 166 |
+
"goal": req.goal,
|
| 167 |
+
"status": "queued",
|
| 168 |
+
"session_id": req.session_id,
|
| 169 |
+
"ws_url": f"/ws/tasks/{task_id}",
|
| 170 |
+
"stream_url": f"/api/v1/tasks/{task_id}/stream",
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.post("/goal/stream", summary="Submit goal with SSE streaming response")
|
| 175 |
+
async def submit_goal_stream(req: GoalRequest, request: Request):
|
| 176 |
+
req.stream = True
|
| 177 |
+
return await submit_goal(req, request)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# βββ Execute (direct tool execution) ββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
|
| 182 |
+
@router.post("/execute", summary="Execute a tool directly")
|
| 183 |
+
async def execute(
|
| 184 |
+
tool: str,
|
| 185 |
+
task: str,
|
| 186 |
+
request: Request,
|
| 187 |
+
session_id: str = "",
|
| 188 |
+
):
|
| 189 |
+
from tools.executor import ToolExecutor
|
| 190 |
+
ws = get_ws(request)
|
| 191 |
+
executor = ToolExecutor(ws)
|
| 192 |
+
result = await executor.run(
|
| 193 |
+
tool=tool,
|
| 194 |
+
task=task,
|
| 195 |
+
session_id=session_id,
|
| 196 |
+
)
|
| 197 |
+
return {"tool": tool, "task": task, "result": result, "session_id": session_id}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# βββ Plan (generate plan without executing) βββββββββββββββββββββββββββββββββββ
|
| 201 |
+
|
| 202 |
+
@router.post("/plan", summary="Generate execution plan for a goal")
|
| 203 |
+
async def generate_plan(req: GoalRequest, request: Request):
|
| 204 |
+
from core.agent import AgentCore
|
| 205 |
+
ws = get_ws(request)
|
| 206 |
+
agent = AgentCore(ws)
|
| 207 |
+
task_id = f"plan_{uuid.uuid4().hex[:8]}"
|
| 208 |
+
plan = await agent.plan(goal=req.goal, task_id=task_id, session_id=req.session_id)
|
| 209 |
+
return {
|
| 210 |
+
"goal": req.goal,
|
| 211 |
+
"plan": plan.model_dump(),
|
| 212 |
+
"session_id": req.session_id,
|
| 213 |
+
"task_id": task_id,
|
| 214 |
+
}
|
backend/api/routes/github.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GitHub Autonomous Engineering API Routes
|
| 3 |
+
Clone, commit, push, PR, issues β all autonomous
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
import asyncio
|
| 9 |
+
import tempfile
|
| 10 |
+
import shutil
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 15 |
+
|
| 16 |
+
from core.models import (
|
| 17 |
+
GitHubCloneRequest, GitHubCreateRepoRequest,
|
| 18 |
+
GitHubCommitRequest, GitHubPRRequest, GitHubIssueRequest,
|
| 19 |
+
)
|
| 20 |
+
from memory.db import save_memory
|
| 21 |
+
|
| 22 |
+
router = APIRouter()
|
| 23 |
+
|
| 24 |
+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
| 25 |
+
GITHUB_OWNER = os.environ.get("GITHUB_OWNER", "")
|
| 26 |
+
GITHUB_API = "https://api.github.com"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def gh_headers():
|
| 30 |
+
if not GITHUB_TOKEN:
|
| 31 |
+
raise HTTPException(status_code=400, detail="GITHUB_TOKEN not configured")
|
| 32 |
+
return {
|
| 33 |
+
"Authorization": f"Bearer {GITHUB_TOKEN}",
|
| 34 |
+
"Accept": "application/vnd.github+json",
|
| 35 |
+
"X-GitHub-Api-Version": "2022-11-28",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def gh_get(path: str) -> dict:
|
| 40 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 41 |
+
r = await client.get(f"{GITHUB_API}{path}", headers=gh_headers())
|
| 42 |
+
r.raise_for_status()
|
| 43 |
+
return r.json()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def gh_post(path: str, data: dict) -> dict:
|
| 47 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 48 |
+
r = await client.post(f"{GITHUB_API}{path}", headers=gh_headers(), json=data)
|
| 49 |
+
r.raise_for_status()
|
| 50 |
+
return r.json()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
async def gh_put(path: str, data: dict) -> dict:
|
| 54 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 55 |
+
r = await client.put(f"{GITHUB_API}{path}", headers=gh_headers(), json=data)
|
| 56 |
+
r.raise_for_status()
|
| 57 |
+
return r.json()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def gh_patch(path: str, data: dict) -> dict:
|
| 61 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 62 |
+
r = await client.patch(f"{GITHUB_API}{path}", headers=gh_headers(), json=data)
|
| 63 |
+
r.raise_for_status()
|
| 64 |
+
return r.json()
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# βββ Clone ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
+
|
| 69 |
+
@router.post("/clone", summary="Clone a GitHub repository")
|
| 70 |
+
async def clone_repo(req: GitHubCloneRequest):
|
| 71 |
+
try:
|
| 72 |
+
import git
|
| 73 |
+
except ImportError:
|
| 74 |
+
raise HTTPException(status_code=500, detail="gitpython not installed")
|
| 75 |
+
|
| 76 |
+
local_path = req.local_path or f"/tmp/repos/{req.repo_url.split('/')[-1].replace('.git', '')}"
|
| 77 |
+
os.makedirs(local_path, exist_ok=True)
|
| 78 |
+
|
| 79 |
+
if GITHUB_TOKEN:
|
| 80 |
+
url = req.repo_url.replace("https://", f"https://{GITHUB_TOKEN}@")
|
| 81 |
+
else:
|
| 82 |
+
url = req.repo_url
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
if os.path.exists(os.path.join(local_path, ".git")):
|
| 86 |
+
repo = git.Repo(local_path)
|
| 87 |
+
repo.remotes.origin.pull()
|
| 88 |
+
action = "pulled"
|
| 89 |
+
else:
|
| 90 |
+
repo = git.Repo.clone_from(url, local_path, branch=req.branch, depth=1)
|
| 91 |
+
action = "cloned"
|
| 92 |
+
|
| 93 |
+
files = []
|
| 94 |
+
for root, dirs, fnames in os.walk(local_path):
|
| 95 |
+
dirs[:] = [d for d in dirs if d not in [".git", "node_modules", "__pycache__"]]
|
| 96 |
+
for f in fnames[:50]:
|
| 97 |
+
files.append(os.path.relpath(os.path.join(root, f), local_path))
|
| 98 |
+
|
| 99 |
+
# Save to memory
|
| 100 |
+
await save_memory(
|
| 101 |
+
content=f"Repo {req.repo_url} cloned to {local_path}. Files: {', '.join(files[:20])}",
|
| 102 |
+
memory_type="repo",
|
| 103 |
+
key=req.repo_url,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
return {
|
| 107 |
+
"action": action,
|
| 108 |
+
"repo_url": req.repo_url,
|
| 109 |
+
"local_path": local_path,
|
| 110 |
+
"branch": req.branch,
|
| 111 |
+
"files_count": len(files),
|
| 112 |
+
"files": files[:30],
|
| 113 |
+
}
|
| 114 |
+
except Exception as e:
|
| 115 |
+
raise HTTPException(status_code=500, detail=f"Clone failed: {str(e)}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# βββ Create Repo ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
@router.post("/create_repo", summary="Create a new GitHub repository")
|
| 121 |
+
async def create_repo(req: GitHubCreateRepoRequest):
|
| 122 |
+
data = {
|
| 123 |
+
"name": req.name,
|
| 124 |
+
"description": req.description,
|
| 125 |
+
"private": req.private,
|
| 126 |
+
"auto_init": req.auto_init,
|
| 127 |
+
}
|
| 128 |
+
try:
|
| 129 |
+
result = await gh_post("/user/repos", data)
|
| 130 |
+
return {
|
| 131 |
+
"repo": result["full_name"],
|
| 132 |
+
"url": result["html_url"],
|
| 133 |
+
"clone_url": result["clone_url"],
|
| 134 |
+
"default_branch": result.get("default_branch", "main"),
|
| 135 |
+
"private": result["private"],
|
| 136 |
+
}
|
| 137 |
+
except httpx.HTTPStatusError as e:
|
| 138 |
+
raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# βββ Commit Files βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 142 |
+
|
| 143 |
+
@router.post("/commit", summary="Commit files to a repository")
|
| 144 |
+
async def commit_files(req: GitHubCommitRequest):
|
| 145 |
+
import base64
|
| 146 |
+
|
| 147 |
+
owner_repo = req.repo if "/" in req.repo else f"{GITHUB_OWNER}/{req.repo}"
|
| 148 |
+
results = []
|
| 149 |
+
|
| 150 |
+
for file_path, content in req.files.items():
|
| 151 |
+
encoded = base64.b64encode(content.encode()).decode()
|
| 152 |
+
|
| 153 |
+
# Get current SHA if file exists
|
| 154 |
+
sha = None
|
| 155 |
+
try:
|
| 156 |
+
existing = await gh_get(f"/repos/{owner_repo}/contents/{file_path}?ref={req.branch}")
|
| 157 |
+
sha = existing.get("sha")
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
payload = {
|
| 162 |
+
"message": req.message,
|
| 163 |
+
"content": encoded,
|
| 164 |
+
"branch": req.branch,
|
| 165 |
+
}
|
| 166 |
+
if sha:
|
| 167 |
+
payload["sha"] = sha
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
result = await gh_put(f"/repos/{owner_repo}/contents/{file_path}", payload)
|
| 171 |
+
results.append({"file": file_path, "status": "committed", "sha": result["content"]["sha"]})
|
| 172 |
+
except Exception as e:
|
| 173 |
+
results.append({"file": file_path, "status": "error", "error": str(e)})
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"repo": owner_repo,
|
| 177 |
+
"branch": req.branch,
|
| 178 |
+
"message": req.message,
|
| 179 |
+
"files": results,
|
| 180 |
+
"committed": sum(1 for r in results if r["status"] == "committed"),
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# βββ Push βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 185 |
+
|
| 186 |
+
@router.post("/push", summary="Push local changes to remote")
|
| 187 |
+
async def push_changes(
|
| 188 |
+
repo_path: str,
|
| 189 |
+
branch: str = "main",
|
| 190 |
+
message: str = "Auto-commit by Devin Agent",
|
| 191 |
+
):
|
| 192 |
+
try:
|
| 193 |
+
import git
|
| 194 |
+
repo = git.Repo(repo_path)
|
| 195 |
+
repo.git.add(A=True)
|
| 196 |
+
if repo.index.diff("HEAD") or repo.untracked_files:
|
| 197 |
+
repo.index.commit(message)
|
| 198 |
+
origin = repo.remote("origin")
|
| 199 |
+
origin.push(refspec=f"HEAD:{branch}")
|
| 200 |
+
return {"status": "pushed", "branch": branch, "message": message}
|
| 201 |
+
except Exception as e:
|
| 202 |
+
raise HTTPException(status_code=500, detail=f"Push failed: {str(e)}")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# βββ Create PR ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 206 |
+
|
| 207 |
+
@router.post("/pr/create", summary="Create a Pull Request")
|
| 208 |
+
async def create_pr(req: GitHubPRRequest):
|
| 209 |
+
owner_repo = req.repo if "/" in req.repo else f"{GITHUB_OWNER}/{req.repo}"
|
| 210 |
+
data = {
|
| 211 |
+
"title": req.title,
|
| 212 |
+
"body": req.body,
|
| 213 |
+
"head": req.head,
|
| 214 |
+
"base": req.base,
|
| 215 |
+
"draft": req.draft,
|
| 216 |
+
}
|
| 217 |
+
try:
|
| 218 |
+
result = await gh_post(f"/repos/{owner_repo}/pulls", data)
|
| 219 |
+
return {
|
| 220 |
+
"pr_number": result["number"],
|
| 221 |
+
"title": result["title"],
|
| 222 |
+
"url": result["html_url"],
|
| 223 |
+
"state": result["state"],
|
| 224 |
+
"head": req.head,
|
| 225 |
+
"base": req.base,
|
| 226 |
+
}
|
| 227 |
+
except httpx.HTTPStatusError as e:
|
| 228 |
+
raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
# βββ Create Issue βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 232 |
+
|
| 233 |
+
@router.post("/issues/create", summary="Create a GitHub Issue")
|
| 234 |
+
async def create_issue(req: GitHubIssueRequest):
|
| 235 |
+
owner_repo = req.repo if "/" in req.repo else f"{GITHUB_OWNER}/{req.repo}"
|
| 236 |
+
data = {"title": req.title, "body": req.body, "labels": req.labels}
|
| 237 |
+
try:
|
| 238 |
+
result = await gh_post(f"/repos/{owner_repo}/issues", data)
|
| 239 |
+
return {
|
| 240 |
+
"issue_number": result["number"],
|
| 241 |
+
"title": result["title"],
|
| 242 |
+
"url": result["html_url"],
|
| 243 |
+
"state": result["state"],
|
| 244 |
+
}
|
| 245 |
+
except httpx.HTTPStatusError as e:
|
| 246 |
+
raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# βββ Code Review ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 250 |
+
|
| 251 |
+
@router.post("/review", summary="AI code review for a PR")
|
| 252 |
+
async def review_pr(repo: str, pr_number: int, request: Request):
|
| 253 |
+
owner_repo = repo if "/" in repo else f"{GITHUB_OWNER}/{repo}"
|
| 254 |
+
try:
|
| 255 |
+
pr = await gh_get(f"/repos/{owner_repo}/pulls/{pr_number}")
|
| 256 |
+
files = await gh_get(f"/repos/{owner_repo}/pulls/{pr_number}/files")
|
| 257 |
+
|
| 258 |
+
file_changes = []
|
| 259 |
+
for f in files[:10]:
|
| 260 |
+
file_changes.append(f"{f['filename']}: +{f.get('additions',0)}/-{f.get('deletions',0)}")
|
| 261 |
+
|
| 262 |
+
ws = request.app.state.ws_manager
|
| 263 |
+
from core.agent import AgentCore
|
| 264 |
+
agent = AgentCore(ws)
|
| 265 |
+
|
| 266 |
+
review_prompt = (
|
| 267 |
+
f"Review this Pull Request:\n"
|
| 268 |
+
f"Title: {pr['title']}\n"
|
| 269 |
+
f"Description: {pr.get('body', 'No description')}\n"
|
| 270 |
+
f"Files changed: {chr(10).join(file_changes)}\n\n"
|
| 271 |
+
f"Provide a constructive code review with: summary, potential issues, suggestions, and verdict."
|
| 272 |
+
)
|
| 273 |
+
messages = [
|
| 274 |
+
{"role": "system", "content": "You are a senior software engineer doing code review. Be constructive, specific, and helpful."},
|
| 275 |
+
{"role": "user", "content": review_prompt},
|
| 276 |
+
]
|
| 277 |
+
review = await agent.llm_stream(messages)
|
| 278 |
+
|
| 279 |
+
# Post review comment
|
| 280 |
+
if GITHUB_TOKEN:
|
| 281 |
+
await gh_post(f"/repos/{owner_repo}/issues/{pr_number}/comments", {"body": f"π€ **Devin Agent Code Review**\n\n{review}"})
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
"pr_number": pr_number,
|
| 285 |
+
"title": pr["title"],
|
| 286 |
+
"review": review,
|
| 287 |
+
"files_reviewed": len(files),
|
| 288 |
+
"posted_to_github": bool(GITHUB_TOKEN),
|
| 289 |
+
}
|
| 290 |
+
except Exception as e:
|
| 291 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# βββ Repo Info ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 295 |
+
|
| 296 |
+
@router.get("/repo/{owner}/{repo}", summary="Get repository info")
|
| 297 |
+
async def get_repo_info(owner: str, repo: str):
|
| 298 |
+
try:
|
| 299 |
+
info = await gh_get(f"/repos/{owner}/{repo}")
|
| 300 |
+
return {
|
| 301 |
+
"name": info["name"],
|
| 302 |
+
"full_name": info["full_name"],
|
| 303 |
+
"description": info.get("description"),
|
| 304 |
+
"url": info["html_url"],
|
| 305 |
+
"default_branch": info["default_branch"],
|
| 306 |
+
"language": info.get("language"),
|
| 307 |
+
"stars": info["stargazers_count"],
|
| 308 |
+
"forks": info["forks_count"],
|
| 309 |
+
"open_issues": info["open_issues_count"],
|
| 310 |
+
"private": info["private"],
|
| 311 |
+
}
|
| 312 |
+
except httpx.HTTPStatusError as e:
|
| 313 |
+
raise HTTPException(status_code=e.response.status_code, detail=e.response.text)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
# βββ Status check βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 317 |
+
|
| 318 |
+
@router.get("/status", summary="GitHub integration status")
|
| 319 |
+
async def github_status():
|
| 320 |
+
configured = bool(GITHUB_TOKEN)
|
| 321 |
+
user = None
|
| 322 |
+
if configured:
|
| 323 |
+
try:
|
| 324 |
+
user_info = await gh_get("/user")
|
| 325 |
+
user = user_info.get("login")
|
| 326 |
+
except Exception:
|
| 327 |
+
configured = False
|
| 328 |
+
return {
|
| 329 |
+
"configured": configured,
|
| 330 |
+
"user": user,
|
| 331 |
+
"owner": GITHUB_OWNER or user,
|
| 332 |
+
"capabilities": [
|
| 333 |
+
"clone", "create_repo", "commit", "push",
|
| 334 |
+
"pr/create", "issues/create", "review"
|
| 335 |
+
],
|
| 336 |
+
}
|
backend/api/routes/health.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Health + Status Routes
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import os
|
| 7 |
+
import psutil
|
| 8 |
+
from fastapi import APIRouter, Request
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/health", summary="Health check")
|
| 14 |
+
async def health(request: Request):
|
| 15 |
+
ws = request.app.state.ws_manager
|
| 16 |
+
engine = request.app.state.task_engine
|
| 17 |
+
stats = ws.get_stats()
|
| 18 |
+
return {
|
| 19 |
+
"status": "healthy",
|
| 20 |
+
"version": "2.0.0",
|
| 21 |
+
"timestamp": time.time(),
|
| 22 |
+
"websocket_connections": stats["total_connections"],
|
| 23 |
+
"websocket_rooms": list(stats["rooms"].keys()),
|
| 24 |
+
"task_queue_size": engine._queue.qsize(),
|
| 25 |
+
"active_tasks": len(engine._active),
|
| 26 |
+
"llm": {
|
| 27 |
+
"openai": bool(os.environ.get("OPENAI_API_KEY")),
|
| 28 |
+
"anthropic": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
| 29 |
+
"model": os.environ.get("DEFAULT_MODEL", "gpt-4o"),
|
| 30 |
+
},
|
| 31 |
+
"github": bool(os.environ.get("GITHUB_TOKEN")),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/metrics", summary="System metrics")
|
| 36 |
+
async def metrics():
|
| 37 |
+
cpu = psutil.cpu_percent(interval=0.1)
|
| 38 |
+
mem = psutil.virtual_memory()
|
| 39 |
+
disk = psutil.disk_usage("/")
|
| 40 |
+
return {
|
| 41 |
+
"cpu_percent": cpu,
|
| 42 |
+
"memory": {
|
| 43 |
+
"total_mb": round(mem.total / 1024 / 1024),
|
| 44 |
+
"used_mb": round(mem.used / 1024 / 1024),
|
| 45 |
+
"percent": mem.percent,
|
| 46 |
+
},
|
| 47 |
+
"disk": {
|
| 48 |
+
"total_gb": round(disk.total / 1024 / 1024 / 1024, 1),
|
| 49 |
+
"used_gb": round(disk.used / 1024 / 1024 / 1024, 1),
|
| 50 |
+
"percent": disk.percent,
|
| 51 |
+
},
|
| 52 |
+
"timestamp": time.time(),
|
| 53 |
+
}
|
backend/api/routes/memory.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Memory API Routes β Persistent agent memory
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 7 |
+
from core.models import MemorySaveRequest, MemorySearchRequest
|
| 8 |
+
from memory.db import save_memory, search_memory, get_project_memory, get_history
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/", summary="Save memory")
|
| 14 |
+
async def save(req: MemorySaveRequest):
|
| 15 |
+
await save_memory(
|
| 16 |
+
content=req.content,
|
| 17 |
+
memory_type=req.memory_type.value,
|
| 18 |
+
session_id=req.session_id,
|
| 19 |
+
project_id=req.project_id,
|
| 20 |
+
key=req.key,
|
| 21 |
+
metadata=req.metadata,
|
| 22 |
+
)
|
| 23 |
+
return {"status": "saved", "memory_type": req.memory_type, "timestamp": time.time()}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/search", summary="Search memory")
|
| 27 |
+
async def search(req: MemorySearchRequest):
|
| 28 |
+
results = await search_memory(
|
| 29 |
+
query=req.query,
|
| 30 |
+
session_id=req.session_id,
|
| 31 |
+
project_id=req.project_id,
|
| 32 |
+
limit=req.limit,
|
| 33 |
+
)
|
| 34 |
+
return {"results": results, "total": len(results), "query": req.query}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.get("/project/{project_id}", summary="Get project memory")
|
| 38 |
+
async def project_memory(
|
| 39 |
+
project_id: str,
|
| 40 |
+
memory_type: str = Query(default=""),
|
| 41 |
+
limit: int = Query(default=100, le=500),
|
| 42 |
+
):
|
| 43 |
+
results = await get_project_memory(project_id, memory_type=memory_type, limit=limit)
|
| 44 |
+
return {"project_id": project_id, "memories": results, "total": len(results)}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.get("/history/{session_id}", summary="Get conversation history")
|
| 48 |
+
async def history(session_id: str, limit: int = Query(default=50, le=200)):
|
| 49 |
+
results = await get_history(session_id, limit=limit)
|
| 50 |
+
return {"session_id": session_id, "history": results, "total": len(results)}
|
backend/api/routes/tasks.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task API Routes β CRUD + Streaming + WebSocket
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import time
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Request, Query
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
|
| 13 |
+
from core.models import (
|
| 14 |
+
TaskCreateRequest, TaskCancelRequest, TaskRetryRequest, TaskResponse, TaskStatus
|
| 15 |
+
)
|
| 16 |
+
from memory.db import get_task, list_tasks, get_task_events, update_task_status
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_engine(request: Request):
|
| 22 |
+
return request.app.state.task_engine
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_ws(request: Request):
|
| 26 |
+
return request.app.state.ws_manager
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# βββ Create Task βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
|
| 31 |
+
@router.post("/create", summary="Create & queue a new agent task")
|
| 32 |
+
async def create_task(req: TaskCreateRequest, request: Request):
|
| 33 |
+
engine = get_engine(request)
|
| 34 |
+
task_id = await engine.submit(req)
|
| 35 |
+
task = await get_task(task_id)
|
| 36 |
+
return {
|
| 37 |
+
"task_id": task_id,
|
| 38 |
+
"status": "queued",
|
| 39 |
+
"goal": req.goal,
|
| 40 |
+
"session_id": req.session_id,
|
| 41 |
+
"stream_url": f"/api/v1/tasks/{task_id}/stream",
|
| 42 |
+
"ws_url": f"/ws/tasks/{task_id}",
|
| 43 |
+
"created_at": task["created_at"] if task else time.time(),
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# βββ Get Task ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
|
| 49 |
+
@router.get("/{task_id}", summary="Get task details")
|
| 50 |
+
async def get_task_detail(task_id: str):
|
| 51 |
+
task = await get_task(task_id)
|
| 52 |
+
if not task:
|
| 53 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 54 |
+
return task
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# βββ Get Task Status βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 58 |
+
|
| 59 |
+
@router.get("/{task_id}/status", summary="Get task status only")
|
| 60 |
+
async def get_task_status(task_id: str):
|
| 61 |
+
task = await get_task(task_id)
|
| 62 |
+
if not task:
|
| 63 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 64 |
+
return {
|
| 65 |
+
"task_id": task_id,
|
| 66 |
+
"status": task["status"],
|
| 67 |
+
"retry_count": task.get("retry_count", 0),
|
| 68 |
+
"created_at": task.get("created_at"),
|
| 69 |
+
"started_at": task.get("started_at"),
|
| 70 |
+
"completed_at": task.get("completed_at"),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# βββ Cancel Task βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 75 |
+
|
| 76 |
+
@router.post("/{task_id}/cancel", summary="Cancel a running task")
|
| 77 |
+
async def cancel_task(task_id: str, req: TaskCancelRequest, request: Request):
|
| 78 |
+
task = await get_task(task_id)
|
| 79 |
+
if not task:
|
| 80 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 81 |
+
if task["status"] in ("completed", "failed", "cancelled"):
|
| 82 |
+
raise HTTPException(status_code=400, detail=f"Task already {task['status']}")
|
| 83 |
+
engine = get_engine(request)
|
| 84 |
+
await engine.cancel(task_id, req.reason)
|
| 85 |
+
return {"task_id": task_id, "status": "cancelled", "reason": req.reason}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# βββ Retry Task ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 89 |
+
|
| 90 |
+
@router.post("/{task_id}/retry", summary="Retry a failed task")
|
| 91 |
+
async def retry_task(task_id: str, request: Request):
|
| 92 |
+
task = await get_task(task_id)
|
| 93 |
+
if not task:
|
| 94 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 95 |
+
if task["status"] not in ("failed", "cancelled"):
|
| 96 |
+
raise HTTPException(status_code=400, detail="Only failed/cancelled tasks can be retried")
|
| 97 |
+
engine = get_engine(request)
|
| 98 |
+
await engine.retry(task_id)
|
| 99 |
+
return {"task_id": task_id, "status": "queued", "message": "Task requeued for retry"}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# βββ Stream Task Events (SSE) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 103 |
+
|
| 104 |
+
@router.get("/{task_id}/stream", summary="Stream task events via SSE")
|
| 105 |
+
async def stream_task(task_id: str, request: Request):
|
| 106 |
+
task = await get_task(task_id)
|
| 107 |
+
if not task:
|
| 108 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 109 |
+
|
| 110 |
+
async def event_generator():
|
| 111 |
+
# First, replay all stored events
|
| 112 |
+
events = await get_task_events(task_id)
|
| 113 |
+
for ev in events:
|
| 114 |
+
data = json.dumps({
|
| 115 |
+
"type": ev["event_type"],
|
| 116 |
+
"task_id": task_id,
|
| 117 |
+
"timestamp": ev["timestamp"],
|
| 118 |
+
"data": json.loads(ev["data"]) if ev.get("data") else {},
|
| 119 |
+
})
|
| 120 |
+
yield f"data: {data}\n\n"
|
| 121 |
+
|
| 122 |
+
# Then stream live events via WS manager buffer
|
| 123 |
+
ws = get_ws(request)
|
| 124 |
+
room = f"task:{task_id}"
|
| 125 |
+
last_count = len(events)
|
| 126 |
+
|
| 127 |
+
# Poll for new events (for SSE fallback)
|
| 128 |
+
for _ in range(600): # max 5 minutes
|
| 129 |
+
await asyncio.sleep(0.5)
|
| 130 |
+
current_task = await get_task(task_id)
|
| 131 |
+
if current_task and current_task["status"] in ("completed", "failed", "cancelled"):
|
| 132 |
+
yield f"data: {json.dumps({'type': 'stream_end', 'task_id': task_id, 'status': current_task['status']})}\n\n"
|
| 133 |
+
break
|
| 134 |
+
# heartbeat
|
| 135 |
+
yield f"data: {json.dumps({'type': 'heartbeat', 'timestamp': time.time()})}\n\n"
|
| 136 |
+
|
| 137 |
+
return StreamingResponse(
|
| 138 |
+
event_generator(),
|
| 139 |
+
media_type="text/event-stream",
|
| 140 |
+
headers={
|
| 141 |
+
"Cache-Control": "no-cache",
|
| 142 |
+
"X-Accel-Buffering": "no",
|
| 143 |
+
"Connection": "keep-alive",
|
| 144 |
+
},
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# βββ List Tasks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 149 |
+
|
| 150 |
+
@router.get("/", summary="List tasks")
|
| 151 |
+
async def list_all_tasks(
|
| 152 |
+
session_id: str = Query(default=""),
|
| 153 |
+
limit: int = Query(default=50, le=200),
|
| 154 |
+
):
|
| 155 |
+
tasks = await list_tasks(session_id=session_id, limit=limit)
|
| 156 |
+
return {"tasks": tasks, "total": len(tasks)}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# βββ Task Events History βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 160 |
+
|
| 161 |
+
@router.get("/{task_id}/events", summary="Get all events for a task")
|
| 162 |
+
async def task_events(task_id: str):
|
| 163 |
+
task = await get_task(task_id)
|
| 164 |
+
if not task:
|
| 165 |
+
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
| 166 |
+
events = await get_task_events(task_id)
|
| 167 |
+
return {"task_id": task_id, "events": events, "total": len(events)}
|
backend/api/websocket_manager.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket Connection Manager β Production Grade
|
| 3 |
+
Handles rooms, heartbeats, event buffering, reconnect support
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from typing import Dict, List, Optional, Set
|
| 12 |
+
import structlog
|
| 13 |
+
|
| 14 |
+
log = structlog.get_logger()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class WebSocketManager:
|
| 18 |
+
def __init__(self):
|
| 19 |
+
# room β set of websockets
|
| 20 |
+
self._rooms: Dict[str, Set] = defaultdict(set)
|
| 21 |
+
# ws β list of rooms
|
| 22 |
+
self._ws_rooms: Dict[object, Set[str]] = defaultdict(set)
|
| 23 |
+
# Event buffer per room (for replay on reconnect)
|
| 24 |
+
self._event_buffer: Dict[str, List] = defaultdict(list)
|
| 25 |
+
self._buffer_max = 100
|
| 26 |
+
# Active connection count
|
| 27 |
+
self._connection_count = 0
|
| 28 |
+
|
| 29 |
+
async def connect(self, websocket, room: str):
|
| 30 |
+
await websocket.accept()
|
| 31 |
+
self._rooms[room].add(websocket)
|
| 32 |
+
self._ws_rooms[websocket].add(room)
|
| 33 |
+
self._connection_count += 1
|
| 34 |
+
log.info("WS connected", room=room, total=self._connection_count)
|
| 35 |
+
|
| 36 |
+
# Replay buffered events for this room
|
| 37 |
+
buffered = self._event_buffer.get(room, [])[-20:]
|
| 38 |
+
for event in buffered:
|
| 39 |
+
try:
|
| 40 |
+
await websocket.send_json(event)
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
await websocket.send_json({
|
| 45 |
+
"type": "connected",
|
| 46 |
+
"room": room,
|
| 47 |
+
"timestamp": time.time(),
|
| 48 |
+
"buffered_events": len(buffered),
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
def disconnect(self, websocket, room: Optional[str] = None):
|
| 52 |
+
if room:
|
| 53 |
+
self._rooms[room].discard(websocket)
|
| 54 |
+
self._ws_rooms[websocket].discard(room)
|
| 55 |
+
else:
|
| 56 |
+
for r in list(self._ws_rooms.get(websocket, [])):
|
| 57 |
+
self._rooms[r].discard(websocket)
|
| 58 |
+
self._ws_rooms.pop(websocket, None)
|
| 59 |
+
self._connection_count = max(0, self._connection_count - 1)
|
| 60 |
+
log.info("WS disconnected", room=room, total=self._connection_count)
|
| 61 |
+
|
| 62 |
+
async def broadcast(self, room: str, event: dict):
|
| 63 |
+
"""Broadcast event to all sockets in a room."""
|
| 64 |
+
if "timestamp" not in event:
|
| 65 |
+
event["timestamp"] = time.time()
|
| 66 |
+
if "id" not in event:
|
| 67 |
+
event["id"] = str(uuid.uuid4())[:8]
|
| 68 |
+
|
| 69 |
+
# Buffer event
|
| 70 |
+
self._event_buffer[room].append(event)
|
| 71 |
+
if len(self._event_buffer[room]) > self._buffer_max:
|
| 72 |
+
self._event_buffer[room].pop(0)
|
| 73 |
+
|
| 74 |
+
dead = set()
|
| 75 |
+
for ws in list(self._rooms.get(room, [])):
|
| 76 |
+
try:
|
| 77 |
+
await ws.send_json(event)
|
| 78 |
+
except Exception:
|
| 79 |
+
dead.add(ws)
|
| 80 |
+
|
| 81 |
+
for ws in dead:
|
| 82 |
+
self.disconnect(ws, room)
|
| 83 |
+
|
| 84 |
+
async def broadcast_global(self, event: dict):
|
| 85 |
+
"""Broadcast to ALL connected websockets."""
|
| 86 |
+
for room in list(self._rooms.keys()):
|
| 87 |
+
await self.broadcast(room, event)
|
| 88 |
+
|
| 89 |
+
async def emit(self, task_id: str, event_type: str, data: dict, session_id: str = ""):
|
| 90 |
+
"""Emit a structured event to a task room + logs room."""
|
| 91 |
+
event = {
|
| 92 |
+
"type": event_type,
|
| 93 |
+
"task_id": task_id,
|
| 94 |
+
"session_id": session_id,
|
| 95 |
+
"timestamp": time.time(),
|
| 96 |
+
"data": data,
|
| 97 |
+
}
|
| 98 |
+
await self.broadcast(f"task:{task_id}", event)
|
| 99 |
+
await self.broadcast("logs", event)
|
| 100 |
+
await self.broadcast("agent_status", {
|
| 101 |
+
"type": "agent_event",
|
| 102 |
+
"task_id": task_id,
|
| 103 |
+
"event_type": event_type,
|
| 104 |
+
"timestamp": time.time(),
|
| 105 |
+
})
|
| 106 |
+
|
| 107 |
+
async def emit_chat(self, session_id: str, event_type: str, data: dict):
|
| 108 |
+
"""Emit event to a chat session room."""
|
| 109 |
+
event = {
|
| 110 |
+
"type": event_type,
|
| 111 |
+
"session_id": session_id,
|
| 112 |
+
"timestamp": time.time(),
|
| 113 |
+
"data": data,
|
| 114 |
+
}
|
| 115 |
+
await self.broadcast(f"chat:{session_id}", event)
|
| 116 |
+
|
| 117 |
+
async def heartbeat_loop(self):
|
| 118 |
+
"""Send heartbeat to all connections every 15s."""
|
| 119 |
+
while True:
|
| 120 |
+
await asyncio.sleep(15)
|
| 121 |
+
heartbeat = {
|
| 122 |
+
"type": "heartbeat",
|
| 123 |
+
"timestamp": time.time(),
|
| 124 |
+
"connections": self._connection_count,
|
| 125 |
+
}
|
| 126 |
+
for room in list(self._rooms.keys()):
|
| 127 |
+
await self.broadcast(room, heartbeat)
|
| 128 |
+
|
| 129 |
+
def get_stats(self) -> dict:
|
| 130 |
+
return {
|
| 131 |
+
"total_connections": self._connection_count,
|
| 132 |
+
"rooms": {r: len(ws) for r, ws in self._rooms.items()},
|
| 133 |
+
"buffered_events": {r: len(e) for r, e in self._event_buffer.items()},
|
| 134 |
+
}
|
backend/core/__init__.py
ADDED
|
File without changes
|
backend/core/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (144 Bytes). View file
|
|
|
backend/core/__pycache__/agent.cpython-312.pyc
ADDED
|
Binary file (17.2 kB). View file
|
|
|
backend/core/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (9.79 kB). View file
|
|
|
backend/core/__pycache__/task_engine.cpython-312.pyc
ADDED
|
Binary file (14.5 kB). View file
|
|
|
backend/core/agent.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Core β Planner + Executor + Self-Heal Loop
|
| 3 |
+
LLM-powered with OpenAI/Anthropic support, streaming tokens
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
from typing import Any, Dict, List, Optional
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
import structlog
|
| 14 |
+
|
| 15 |
+
from core.models import TaskPlan, TaskStep
|
| 16 |
+
from api.websocket_manager import WebSocketManager
|
| 17 |
+
from memory.db import save_memory, get_history, search_memory
|
| 18 |
+
|
| 19 |
+
log = structlog.get_logger()
|
| 20 |
+
|
| 21 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 22 |
+
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
| 23 |
+
DEFAULT_MODEL = os.environ.get("DEFAULT_MODEL", "gpt-4o")
|
| 24 |
+
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
SYSTEM_PROMPT = """You are an elite autonomous AI software engineer β like Devin or Manus.
|
| 28 |
+
You can plan, code, debug, refactor, test, and deploy software autonomously.
|
| 29 |
+
You think step-by-step, write production-quality code, and self-heal on errors.
|
| 30 |
+
Always respond in structured JSON when asked for plans or structured output.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
PLANNER_PROMPT = """You are a senior software architect. Given a goal, produce a detailed execution plan.
|
| 34 |
+
|
| 35 |
+
Respond ONLY with valid JSON:
|
| 36 |
+
{
|
| 37 |
+
"steps": [
|
| 38 |
+
{
|
| 39 |
+
"name": "Step name",
|
| 40 |
+
"description": "What this step does",
|
| 41 |
+
"tool": "code|shell|file|browser|github|memory|search|test|none",
|
| 42 |
+
"estimated_seconds": 10
|
| 43 |
+
}
|
| 44 |
+
],
|
| 45 |
+
"estimated_duration": 60,
|
| 46 |
+
"tools_needed": ["code", "shell"]
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
Goal: {goal}
|
| 50 |
+
Context: {context}
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class AgentCore:
|
| 55 |
+
def __init__(self, ws_manager: WebSocketManager):
|
| 56 |
+
self.ws = ws_manager
|
| 57 |
+
self.model = DEFAULT_MODEL
|
| 58 |
+
|
| 59 |
+
# βββ LLM Call (with streaming) βββββββββββββββββββββββββββββββββββββββββββββ
|
| 60 |
+
|
| 61 |
+
async def llm_stream(
|
| 62 |
+
self,
|
| 63 |
+
messages: List[Dict],
|
| 64 |
+
task_id: str = "",
|
| 65 |
+
session_id: str = "",
|
| 66 |
+
model: str = "",
|
| 67 |
+
temperature: float = 0.7,
|
| 68 |
+
max_tokens: int = 4096,
|
| 69 |
+
) -> str:
|
| 70 |
+
"""Stream LLM tokens, emitting llm_chunk events via WebSocket."""
|
| 71 |
+
model = model or self.model
|
| 72 |
+
full_text = ""
|
| 73 |
+
|
| 74 |
+
if OPENAI_API_KEY:
|
| 75 |
+
full_text = await self._openai_stream(
|
| 76 |
+
messages, task_id, session_id, model, temperature, max_tokens
|
| 77 |
+
)
|
| 78 |
+
elif ANTHROPIC_API_KEY:
|
| 79 |
+
full_text = await self._anthropic_stream(
|
| 80 |
+
messages, task_id, session_id, temperature, max_tokens
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
# Demo mode β simulate streaming
|
| 84 |
+
full_text = await self._demo_stream(messages, task_id, session_id)
|
| 85 |
+
|
| 86 |
+
return full_text
|
| 87 |
+
|
| 88 |
+
async def _openai_stream(
|
| 89 |
+
self, messages, task_id, session_id, model, temperature, max_tokens
|
| 90 |
+
) -> str:
|
| 91 |
+
full_text = ""
|
| 92 |
+
headers = {
|
| 93 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 94 |
+
"Content-Type": "application/json",
|
| 95 |
+
}
|
| 96 |
+
payload = {
|
| 97 |
+
"model": model,
|
| 98 |
+
"messages": messages,
|
| 99 |
+
"stream": True,
|
| 100 |
+
"temperature": temperature,
|
| 101 |
+
"max_tokens": max_tokens,
|
| 102 |
+
}
|
| 103 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 104 |
+
async with client.stream(
|
| 105 |
+
"POST", f"{OPENAI_BASE_URL}/chat/completions",
|
| 106 |
+
headers=headers, json=payload
|
| 107 |
+
) as resp:
|
| 108 |
+
resp.raise_for_status()
|
| 109 |
+
async for line in resp.aiter_lines():
|
| 110 |
+
if not line.startswith("data:"):
|
| 111 |
+
continue
|
| 112 |
+
chunk = line[6:].strip()
|
| 113 |
+
if chunk == "[DONE]":
|
| 114 |
+
break
|
| 115 |
+
try:
|
| 116 |
+
data = json.loads(chunk)
|
| 117 |
+
delta = data["choices"][0]["delta"].get("content", "")
|
| 118 |
+
if delta:
|
| 119 |
+
full_text += delta
|
| 120 |
+
if task_id:
|
| 121 |
+
await self.ws.emit(task_id, "llm_chunk", {
|
| 122 |
+
"chunk": delta,
|
| 123 |
+
"accumulated": len(full_text),
|
| 124 |
+
}, session_id=session_id)
|
| 125 |
+
if session_id and not task_id:
|
| 126 |
+
await self.ws.emit_chat(session_id, "llm_chunk", {
|
| 127 |
+
"chunk": delta,
|
| 128 |
+
})
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
return full_text
|
| 132 |
+
|
| 133 |
+
async def _anthropic_stream(
|
| 134 |
+
self, messages, task_id, session_id, temperature, max_tokens
|
| 135 |
+
) -> str:
|
| 136 |
+
full_text = ""
|
| 137 |
+
system = ""
|
| 138 |
+
filtered = []
|
| 139 |
+
for m in messages:
|
| 140 |
+
if m["role"] == "system":
|
| 141 |
+
system = m["content"]
|
| 142 |
+
else:
|
| 143 |
+
filtered.append(m)
|
| 144 |
+
headers = {
|
| 145 |
+
"x-api-key": ANTHROPIC_API_KEY,
|
| 146 |
+
"anthropic-version": "2023-06-01",
|
| 147 |
+
"Content-Type": "application/json",
|
| 148 |
+
}
|
| 149 |
+
payload = {
|
| 150 |
+
"model": "claude-3-5-sonnet-20241022",
|
| 151 |
+
"max_tokens": max_tokens,
|
| 152 |
+
"messages": filtered,
|
| 153 |
+
"stream": True,
|
| 154 |
+
}
|
| 155 |
+
if system:
|
| 156 |
+
payload["system"] = system
|
| 157 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 158 |
+
async with client.stream(
|
| 159 |
+
"POST", "https://api.anthropic.com/v1/messages",
|
| 160 |
+
headers=headers, json=payload
|
| 161 |
+
) as resp:
|
| 162 |
+
resp.raise_for_status()
|
| 163 |
+
async for line in resp.aiter_lines():
|
| 164 |
+
if not line.startswith("data:"):
|
| 165 |
+
continue
|
| 166 |
+
try:
|
| 167 |
+
data = json.loads(line[5:].strip())
|
| 168 |
+
if data.get("type") == "content_block_delta":
|
| 169 |
+
delta = data["delta"].get("text", "")
|
| 170 |
+
if delta:
|
| 171 |
+
full_text += delta
|
| 172 |
+
if task_id:
|
| 173 |
+
await self.ws.emit(task_id, "llm_chunk", {
|
| 174 |
+
"chunk": delta,
|
| 175 |
+
}, session_id=session_id)
|
| 176 |
+
if session_id and not task_id:
|
| 177 |
+
await self.ws.emit_chat(session_id, "llm_chunk", {
|
| 178 |
+
"chunk": delta,
|
| 179 |
+
})
|
| 180 |
+
except Exception:
|
| 181 |
+
pass
|
| 182 |
+
return full_text
|
| 183 |
+
|
| 184 |
+
async def _demo_stream(self, messages, task_id, session_id) -> str:
|
| 185 |
+
"""Demo mode β simulate LLM streaming without API key."""
|
| 186 |
+
last_user = next(
|
| 187 |
+
(m["content"] for m in reversed(messages) if m["role"] == "user"), "Hello"
|
| 188 |
+
)
|
| 189 |
+
response = (
|
| 190 |
+
f"π€ **Devin Agent** (Demo Mode)\n\n"
|
| 191 |
+
f"I received your request: *{last_user[:100]}*\n\n"
|
| 192 |
+
f"To enable real AI responses, set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in your environment.\n\n"
|
| 193 |
+
f"**What I can do with a real API key:**\n"
|
| 194 |
+
f"- π Generate detailed execution plans\n"
|
| 195 |
+
f"- π» Write and execute code autonomously\n"
|
| 196 |
+
f"- π§ Debug and self-heal on errors\n"
|
| 197 |
+
f"- π Manage GitHub repos autonomously\n"
|
| 198 |
+
f"- π§ Remember long-running project context\n"
|
| 199 |
+
f"- π Deploy applications automatically\n"
|
| 200 |
+
)
|
| 201 |
+
full_text = ""
|
| 202 |
+
for word in response.split():
|
| 203 |
+
chunk = word + " "
|
| 204 |
+
full_text += chunk
|
| 205 |
+
await asyncio.sleep(0.03)
|
| 206 |
+
if task_id:
|
| 207 |
+
await self.ws.emit(task_id, "llm_chunk", {
|
| 208 |
+
"chunk": chunk,
|
| 209 |
+
"demo": True,
|
| 210 |
+
}, session_id=session_id)
|
| 211 |
+
if session_id and not task_id:
|
| 212 |
+
await self.ws.emit_chat(session_id, "llm_chunk", {
|
| 213 |
+
"chunk": chunk,
|
| 214 |
+
"demo": True,
|
| 215 |
+
})
|
| 216 |
+
return full_text
|
| 217 |
+
|
| 218 |
+
# βββ Planning ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 219 |
+
|
| 220 |
+
async def plan(self, goal: str, task_id: str, session_id: str = "") -> TaskPlan:
|
| 221 |
+
"""Generate a structured execution plan."""
|
| 222 |
+
# Get context from memory
|
| 223 |
+
memories = await search_memory(goal[:50], session_id=session_id)
|
| 224 |
+
context = "\n".join([m["content"][:200] for m in memories[:3]])
|
| 225 |
+
|
| 226 |
+
prompt = PLANNER_PROMPT.format(goal=goal, context=context or "No prior context")
|
| 227 |
+
|
| 228 |
+
messages = [
|
| 229 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 230 |
+
{"role": "user", "content": prompt},
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
if not OPENAI_API_KEY and not ANTHROPIC_API_KEY:
|
| 234 |
+
# Demo plan
|
| 235 |
+
return self._demo_plan(goal)
|
| 236 |
+
|
| 237 |
+
raw = await self.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 238 |
+
|
| 239 |
+
# Extract JSON from response
|
| 240 |
+
try:
|
| 241 |
+
# Find JSON block
|
| 242 |
+
start = raw.find("{")
|
| 243 |
+
end = raw.rfind("}") + 1
|
| 244 |
+
if start >= 0 and end > start:
|
| 245 |
+
data = json.loads(raw[start:end])
|
| 246 |
+
else:
|
| 247 |
+
data = json.loads(raw)
|
| 248 |
+
|
| 249 |
+
steps = []
|
| 250 |
+
for i, s in enumerate(data.get("steps", [])):
|
| 251 |
+
steps.append(TaskStep(
|
| 252 |
+
name=s.get("name", f"Step {i+1}"),
|
| 253 |
+
description=s.get("description", ""),
|
| 254 |
+
tool=s.get("tool", "none"),
|
| 255 |
+
))
|
| 256 |
+
|
| 257 |
+
return TaskPlan(
|
| 258 |
+
goal=goal,
|
| 259 |
+
steps=steps if steps else [TaskStep(name="Execute goal", description=goal, tool="code")],
|
| 260 |
+
estimated_duration=data.get("estimated_duration", 60),
|
| 261 |
+
tools_needed=data.get("tools_needed", []),
|
| 262 |
+
)
|
| 263 |
+
except Exception as e:
|
| 264 |
+
log.warning("Plan parse failed, using fallback", error=str(e))
|
| 265 |
+
return self._demo_plan(goal)
|
| 266 |
+
|
| 267 |
+
def _demo_plan(self, goal: str) -> TaskPlan:
|
| 268 |
+
"""Fallback plan for demo mode."""
|
| 269 |
+
steps = [
|
| 270 |
+
TaskStep(name="Analyze Requirements", description=f"Analyze: {goal[:60]}", tool="none"),
|
| 271 |
+
TaskStep(name="Design Solution", description="Design the solution architecture", tool="none"),
|
| 272 |
+
TaskStep(name="Implement", description="Write the implementation code", tool="code"),
|
| 273 |
+
TaskStep(name="Test", description="Test the implementation", tool="test"),
|
| 274 |
+
TaskStep(name="Document", description="Write documentation", tool="none"),
|
| 275 |
+
]
|
| 276 |
+
return TaskPlan(
|
| 277 |
+
goal=goal,
|
| 278 |
+
steps=steps,
|
| 279 |
+
estimated_duration=120,
|
| 280 |
+
tools_needed=["code", "test"],
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# βββ Step Execution ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 284 |
+
|
| 285 |
+
async def execute_step(
|
| 286 |
+
self,
|
| 287 |
+
step: TaskStep,
|
| 288 |
+
task_id: str,
|
| 289 |
+
session_id: str = "",
|
| 290 |
+
context: Dict = {},
|
| 291 |
+
) -> str:
|
| 292 |
+
"""Execute a single step using the appropriate tool."""
|
| 293 |
+
from tools.executor import ToolExecutor
|
| 294 |
+
executor = ToolExecutor(self.ws)
|
| 295 |
+
|
| 296 |
+
await self.ws.emit(task_id, "tool_called", {
|
| 297 |
+
"tool": step.tool or "none",
|
| 298 |
+
"step": step.name,
|
| 299 |
+
"description": step.description,
|
| 300 |
+
}, session_id=session_id)
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
result = await executor.run(
|
| 304 |
+
tool=step.tool or "none",
|
| 305 |
+
task=step.description,
|
| 306 |
+
goal=context.get("goal", ""),
|
| 307 |
+
previous=context.get("previous_results", []),
|
| 308 |
+
task_id=task_id,
|
| 309 |
+
session_id=session_id,
|
| 310 |
+
)
|
| 311 |
+
await self.ws.emit(task_id, "tool_result", {
|
| 312 |
+
"tool": step.tool,
|
| 313 |
+
"step": step.name,
|
| 314 |
+
"result": str(result)[:500],
|
| 315 |
+
"success": True,
|
| 316 |
+
}, session_id=session_id)
|
| 317 |
+
return result
|
| 318 |
+
except Exception as e:
|
| 319 |
+
await self.ws.emit(task_id, "tool_result", {
|
| 320 |
+
"tool": step.tool,
|
| 321 |
+
"step": step.name,
|
| 322 |
+
"error": str(e),
|
| 323 |
+
"success": False,
|
| 324 |
+
}, session_id=session_id)
|
| 325 |
+
return f"Error in {step.name}: {str(e)}"
|
| 326 |
+
|
| 327 |
+
# βββ Finalize ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 328 |
+
|
| 329 |
+
async def finalize(
|
| 330 |
+
self,
|
| 331 |
+
goal: str,
|
| 332 |
+
steps: List[TaskStep],
|
| 333 |
+
results: List[str],
|
| 334 |
+
task_id: str,
|
| 335 |
+
session_id: str = "",
|
| 336 |
+
) -> str:
|
| 337 |
+
"""Compile final result summary."""
|
| 338 |
+
steps_summary = "\n".join([
|
| 339 |
+
f"- {s.name}: {r[:200]}" for s, r in zip(steps, results)
|
| 340 |
+
])
|
| 341 |
+
messages = [
|
| 342 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 343 |
+
{"role": "user", "content": (
|
| 344 |
+
f"Summarize the completion of this goal:\n"
|
| 345 |
+
f"Goal: {goal}\n\n"
|
| 346 |
+
f"Steps completed:\n{steps_summary}\n\n"
|
| 347 |
+
f"Write a concise success summary with key outcomes."
|
| 348 |
+
)},
|
| 349 |
+
]
|
| 350 |
+
result = await self.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 351 |
+
return result or f"β
Completed: {goal}"
|
| 352 |
+
|
| 353 |
+
# βββ Chat ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 354 |
+
|
| 355 |
+
async def stream_chat(self, session_id: str, user_message: str):
|
| 356 |
+
"""Stream a conversational chat response."""
|
| 357 |
+
# Save user message to memory
|
| 358 |
+
await save_memory(
|
| 359 |
+
content=user_message,
|
| 360 |
+
memory_type="conversation",
|
| 361 |
+
session_id=session_id,
|
| 362 |
+
key="user_message",
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
# Get conversation history
|
| 366 |
+
history = await get_history(session_id, limit=10)
|
| 367 |
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
| 368 |
+
for h in reversed(history[-10:]):
|
| 369 |
+
messages.append({"role": "user", "content": h["content"]})
|
| 370 |
+
|
| 371 |
+
messages.append({"role": "user", "content": user_message})
|
| 372 |
+
|
| 373 |
+
await self.ws.emit_chat(session_id, "stream_start", {
|
| 374 |
+
"status": "generating",
|
| 375 |
+
})
|
| 376 |
+
|
| 377 |
+
response = await self.llm_stream(messages, session_id=session_id)
|
| 378 |
+
|
| 379 |
+
# Save assistant response to memory
|
| 380 |
+
await save_memory(
|
| 381 |
+
content=response,
|
| 382 |
+
memory_type="conversation",
|
| 383 |
+
session_id=session_id,
|
| 384 |
+
key="assistant_response",
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
await self.ws.emit_chat(session_id, "stream_end", {
|
| 388 |
+
"full_response": response,
|
| 389 |
+
"status": "complete",
|
| 390 |
+
})
|
| 391 |
+
|
| 392 |
+
return response
|
backend/core/models.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic Models β Task, Chat, Memory, GitHub
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
from enum import Enum
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def gen_id(prefix: str = "") -> str:
|
| 13 |
+
return f"{prefix}{uuid.uuid4().hex[:12]}"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# βββ Enums βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
+
|
| 18 |
+
class TaskStatus(str, Enum):
|
| 19 |
+
queued = "queued"
|
| 20 |
+
initializing = "initializing"
|
| 21 |
+
planning = "planning"
|
| 22 |
+
executing = "executing"
|
| 23 |
+
streaming = "streaming"
|
| 24 |
+
waiting_input = "waiting_input"
|
| 25 |
+
retrying = "retrying"
|
| 26 |
+
finalizing = "finalizing"
|
| 27 |
+
completed = "completed"
|
| 28 |
+
failed = "failed"
|
| 29 |
+
cancelled = "cancelled"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class EventType(str, Enum):
|
| 33 |
+
task_created = "task_created"
|
| 34 |
+
task_queued = "task_queued"
|
| 35 |
+
task_started = "task_started"
|
| 36 |
+
plan_generated = "plan_generated"
|
| 37 |
+
step_started = "step_started"
|
| 38 |
+
step_progress = "step_progress"
|
| 39 |
+
tool_called = "tool_called"
|
| 40 |
+
tool_result = "tool_result"
|
| 41 |
+
llm_chunk = "llm_chunk"
|
| 42 |
+
memory_updated = "memory_updated"
|
| 43 |
+
retry_attempt = "retry_attempt"
|
| 44 |
+
step_completed = "step_completed"
|
| 45 |
+
warning = "warning"
|
| 46 |
+
error = "error"
|
| 47 |
+
task_completed = "task_completed"
|
| 48 |
+
task_failed = "task_failed"
|
| 49 |
+
heartbeat = "heartbeat"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class MemoryType(str, Enum):
|
| 53 |
+
conversation = "conversation"
|
| 54 |
+
task = "task"
|
| 55 |
+
project = "project"
|
| 56 |
+
execution = "execution"
|
| 57 |
+
tool = "tool"
|
| 58 |
+
error = "error"
|
| 59 |
+
repo = "repo"
|
| 60 |
+
planning = "planning"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# βββ Task Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
class TaskCreateRequest(BaseModel):
|
| 66 |
+
goal: str = Field(..., min_length=1, max_length=10000, description="What should the agent do?")
|
| 67 |
+
session_id: str = Field(default_factory=lambda: gen_id("sess_"))
|
| 68 |
+
project_id: str = Field(default="")
|
| 69 |
+
stream: bool = True
|
| 70 |
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 71 |
+
github_repo: Optional[str] = None
|
| 72 |
+
auto_commit: bool = False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class TaskStep(BaseModel):
|
| 76 |
+
id: str = Field(default_factory=lambda: gen_id("step_"))
|
| 77 |
+
name: str
|
| 78 |
+
description: str = ""
|
| 79 |
+
tool: Optional[str] = None
|
| 80 |
+
status: str = "pending"
|
| 81 |
+
output: Optional[str] = None
|
| 82 |
+
error: Optional[str] = None
|
| 83 |
+
started_at: Optional[float] = None
|
| 84 |
+
completed_at: Optional[float] = None
|
| 85 |
+
duration_ms: Optional[float] = None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TaskPlan(BaseModel):
|
| 89 |
+
goal: str
|
| 90 |
+
steps: List[TaskStep]
|
| 91 |
+
estimated_duration: int = 0
|
| 92 |
+
tools_needed: List[str] = []
|
| 93 |
+
created_at: float = Field(default_factory=time.time)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class TaskResponse(BaseModel):
|
| 97 |
+
id: str
|
| 98 |
+
goal: str
|
| 99 |
+
status: TaskStatus
|
| 100 |
+
session_id: str
|
| 101 |
+
project_id: str
|
| 102 |
+
plan: Optional[TaskPlan] = None
|
| 103 |
+
result: Optional[str] = None
|
| 104 |
+
error: Optional[str] = None
|
| 105 |
+
created_at: float
|
| 106 |
+
started_at: Optional[float] = None
|
| 107 |
+
completed_at: Optional[float] = None
|
| 108 |
+
retry_count: int = 0
|
| 109 |
+
stream_url: Optional[str] = None
|
| 110 |
+
ws_url: Optional[str] = None
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class TaskCancelRequest(BaseModel):
|
| 114 |
+
reason: str = "User cancelled"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class TaskRetryRequest(BaseModel):
|
| 118 |
+
reset_state: bool = True
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# βββ Chat Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
class ChatMessage(BaseModel):
|
| 124 |
+
role: str = Field(..., pattern="^(user|assistant|system)$")
|
| 125 |
+
content: str
|
| 126 |
+
timestamp: float = Field(default_factory=time.time)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class ChatRequest(BaseModel):
|
| 130 |
+
messages: List[ChatMessage]
|
| 131 |
+
session_id: str = Field(default_factory=lambda: gen_id("sess_"))
|
| 132 |
+
project_id: str = ""
|
| 133 |
+
stream: bool = True
|
| 134 |
+
model: str = "gpt-4o"
|
| 135 |
+
temperature: float = 0.7
|
| 136 |
+
max_tokens: int = 4096
|
| 137 |
+
system_prompt: Optional[str] = None
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class GoalRequest(BaseModel):
|
| 141 |
+
goal: str = Field(..., min_length=1, max_length=10000)
|
| 142 |
+
session_id: str = Field(default_factory=lambda: gen_id("sess_"))
|
| 143 |
+
project_id: str = ""
|
| 144 |
+
stream: bool = True
|
| 145 |
+
auto_execute: bool = True
|
| 146 |
+
github_repo: Optional[str] = None
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# βββ Memory Models βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
|
| 151 |
+
class MemorySaveRequest(BaseModel):
|
| 152 |
+
content: str
|
| 153 |
+
memory_type: MemoryType
|
| 154 |
+
session_id: str = ""
|
| 155 |
+
project_id: str = ""
|
| 156 |
+
key: str = ""
|
| 157 |
+
metadata: Dict[str, Any] = {}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class MemorySearchRequest(BaseModel):
|
| 161 |
+
query: str
|
| 162 |
+
session_id: str = ""
|
| 163 |
+
project_id: str = ""
|
| 164 |
+
limit: int = 20
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# βββ GitHub Models βββββββββββββββοΏ½οΏ½βββββββββββββββββββββββββββββββββββββββββββββ
|
| 168 |
+
|
| 169 |
+
class GitHubCloneRequest(BaseModel):
|
| 170 |
+
repo_url: str
|
| 171 |
+
branch: str = "main"
|
| 172 |
+
local_path: Optional[str] = None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class GitHubCreateRepoRequest(BaseModel):
|
| 176 |
+
name: str
|
| 177 |
+
description: str = ""
|
| 178 |
+
private: bool = False
|
| 179 |
+
auto_init: bool = True
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class GitHubCommitRequest(BaseModel):
|
| 183 |
+
repo: str
|
| 184 |
+
branch: str = "main"
|
| 185 |
+
files: Dict[str, str] # path β content
|
| 186 |
+
message: str
|
| 187 |
+
create_branch: bool = False
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class GitHubPRRequest(BaseModel):
|
| 191 |
+
repo: str
|
| 192 |
+
title: str
|
| 193 |
+
body: str = ""
|
| 194 |
+
head: str
|
| 195 |
+
base: str = "main"
|
| 196 |
+
draft: bool = False
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class GitHubIssueRequest(BaseModel):
|
| 200 |
+
repo: str
|
| 201 |
+
title: str
|
| 202 |
+
body: str = ""
|
| 203 |
+
labels: List[str] = []
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# βββ Event Schema (unified) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 207 |
+
|
| 208 |
+
class StreamEvent(BaseModel):
|
| 209 |
+
type: str
|
| 210 |
+
task_id: str = ""
|
| 211 |
+
session_id: str = ""
|
| 212 |
+
timestamp: float = Field(default_factory=time.time)
|
| 213 |
+
data: Dict[str, Any] = {}
|
backend/core/task_engine.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Task Engine β Heart of the Autonomous Agent
|
| 3 |
+
Manages task lifecycle, planning, execution, self-healing
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import Dict, Optional, List
|
| 12 |
+
|
| 13 |
+
import structlog
|
| 14 |
+
|
| 15 |
+
from core.models import TaskStatus, TaskPlan, TaskStep, TaskCreateRequest
|
| 16 |
+
from api.websocket_manager import WebSocketManager
|
| 17 |
+
from memory.db import (
|
| 18 |
+
create_task, update_task_status, get_task, save_task_event,
|
| 19 |
+
save_memory, get_task_events
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
log = structlog.get_logger()
|
| 23 |
+
|
| 24 |
+
MAX_RETRIES = 3
|
| 25 |
+
MAX_CONCURRENT = 5
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class TaskEngine:
|
| 29 |
+
def __init__(self, ws_manager: WebSocketManager):
|
| 30 |
+
self.ws = ws_manager
|
| 31 |
+
self._queue: asyncio.Queue = asyncio.Queue()
|
| 32 |
+
self._active: Dict[str, asyncio.Task] = {}
|
| 33 |
+
self._running = False
|
| 34 |
+
self._workers: List[asyncio.Task] = []
|
| 35 |
+
|
| 36 |
+
async def start(self):
|
| 37 |
+
self._running = True
|
| 38 |
+
for i in range(MAX_CONCURRENT):
|
| 39 |
+
worker = asyncio.create_task(self._worker(i))
|
| 40 |
+
self._workers.append(worker)
|
| 41 |
+
log.info("TaskEngine started", workers=MAX_CONCURRENT)
|
| 42 |
+
|
| 43 |
+
async def stop(self):
|
| 44 |
+
self._running = False
|
| 45 |
+
for w in self._workers:
|
| 46 |
+
w.cancel()
|
| 47 |
+
log.info("TaskEngine stopped")
|
| 48 |
+
|
| 49 |
+
# βββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 50 |
+
|
| 51 |
+
async def submit(self, req: TaskCreateRequest) -> str:
|
| 52 |
+
task_id = f"task_{uuid.uuid4().hex[:10]}"
|
| 53 |
+
await create_task(
|
| 54 |
+
task_id=task_id,
|
| 55 |
+
goal=req.goal,
|
| 56 |
+
session_id=req.session_id,
|
| 57 |
+
project_id=req.project_id,
|
| 58 |
+
metadata={**req.metadata, "github_repo": req.github_repo, "auto_commit": req.auto_commit},
|
| 59 |
+
)
|
| 60 |
+
await self.ws.emit(task_id, "task_created", {
|
| 61 |
+
"goal": req.goal,
|
| 62 |
+
"session_id": req.session_id,
|
| 63 |
+
"stream_url": f"/api/v1/tasks/{task_id}/stream",
|
| 64 |
+
"ws_url": f"/ws/tasks/{task_id}",
|
| 65 |
+
}, session_id=req.session_id)
|
| 66 |
+
await self._queue.put((task_id, req))
|
| 67 |
+
await self.ws.emit(task_id, "task_queued", {
|
| 68 |
+
"position": self._queue.qsize(),
|
| 69 |
+
}, session_id=req.session_id)
|
| 70 |
+
log.info("Task submitted", task_id=task_id, goal=req.goal[:60])
|
| 71 |
+
return task_id
|
| 72 |
+
|
| 73 |
+
async def cancel(self, task_id: str, reason: str = "User cancelled"):
|
| 74 |
+
if task_id in self._active:
|
| 75 |
+
self._active[task_id].cancel()
|
| 76 |
+
del self._active[task_id]
|
| 77 |
+
await update_task_status(task_id, "cancelled", error=reason)
|
| 78 |
+
await self.ws.emit(task_id, "task_failed", {"reason": reason, "status": "cancelled"})
|
| 79 |
+
|
| 80 |
+
async def retry(self, task_id: str):
|
| 81 |
+
task = await get_task(task_id)
|
| 82 |
+
if not task:
|
| 83 |
+
return
|
| 84 |
+
req = TaskCreateRequest(
|
| 85 |
+
goal=task["goal"],
|
| 86 |
+
session_id=task["session_id"] or "",
|
| 87 |
+
project_id=task["project_id"] or "",
|
| 88 |
+
metadata=task.get("metadata") or {},
|
| 89 |
+
)
|
| 90 |
+
retry_count = (task.get("retry_count") or 0) + 1
|
| 91 |
+
await update_task_status(task_id, "queued", retry_count=retry_count)
|
| 92 |
+
await self.ws.emit(task_id, "retry_attempt", {"count": retry_count})
|
| 93 |
+
await self._queue.put((task_id, req))
|
| 94 |
+
|
| 95 |
+
async def handle_chat_message(self, session_id: str, content: str, websocket=None):
|
| 96 |
+
"""Handle real-time chat message with streaming response."""
|
| 97 |
+
from core.agent import AgentCore
|
| 98 |
+
agent = AgentCore(self.ws)
|
| 99 |
+
await agent.stream_chat(session_id=session_id, user_message=content)
|
| 100 |
+
|
| 101 |
+
# βββ Worker Loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
|
| 103 |
+
async def _worker(self, worker_id: int):
|
| 104 |
+
log.info(f"Worker {worker_id} started")
|
| 105 |
+
while self._running:
|
| 106 |
+
try:
|
| 107 |
+
task_id, req = await asyncio.wait_for(self._queue.get(), timeout=1.0)
|
| 108 |
+
worker_task = asyncio.create_task(self._execute(task_id, req))
|
| 109 |
+
self._active[task_id] = worker_task
|
| 110 |
+
await worker_task
|
| 111 |
+
self._active.pop(task_id, None)
|
| 112 |
+
self._queue.task_done()
|
| 113 |
+
except asyncio.TimeoutError:
|
| 114 |
+
continue
|
| 115 |
+
except asyncio.CancelledError:
|
| 116 |
+
break
|
| 117 |
+
except Exception as e:
|
| 118 |
+
log.error(f"Worker {worker_id} error", error=str(e))
|
| 119 |
+
|
| 120 |
+
async def _execute(self, task_id: str, req: TaskCreateRequest):
|
| 121 |
+
"""Full task execution lifecycle."""
|
| 122 |
+
from core.agent import AgentCore
|
| 123 |
+
agent = AgentCore(self.ws)
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
# ββ Initializing ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
+
await update_task_status(task_id, "initializing")
|
| 128 |
+
await self.ws.emit(task_id, "task_started", {
|
| 129 |
+
"goal": req.goal,
|
| 130 |
+
"status": "initializing",
|
| 131 |
+
}, session_id=req.session_id)
|
| 132 |
+
await save_task_event(task_id, "task_started", {"goal": req.goal})
|
| 133 |
+
|
| 134 |
+
# ββ Planning ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 135 |
+
await update_task_status(task_id, "planning")
|
| 136 |
+
await self.ws.emit(task_id, "step_started", {
|
| 137 |
+
"step": "Planning",
|
| 138 |
+
"status": "planning",
|
| 139 |
+
"description": "Generating execution plan...",
|
| 140 |
+
}, session_id=req.session_id)
|
| 141 |
+
|
| 142 |
+
plan = await agent.plan(goal=req.goal, task_id=task_id, session_id=req.session_id)
|
| 143 |
+
|
| 144 |
+
await update_task_status(task_id, "executing", plan=plan.model_dump())
|
| 145 |
+
await self.ws.emit(task_id, "plan_generated", {
|
| 146 |
+
"steps": [s.model_dump() for s in plan.steps],
|
| 147 |
+
"estimated_duration": plan.estimated_duration,
|
| 148 |
+
"tools_needed": plan.tools_needed,
|
| 149 |
+
}, session_id=req.session_id)
|
| 150 |
+
await save_task_event(task_id, "plan_generated", {"steps_count": len(plan.steps)})
|
| 151 |
+
|
| 152 |
+
# ββ Execute Steps ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 153 |
+
results = []
|
| 154 |
+
for i, step in enumerate(plan.steps):
|
| 155 |
+
await self.ws.emit(task_id, "step_started", {
|
| 156 |
+
"step": step.name,
|
| 157 |
+
"step_id": step.id,
|
| 158 |
+
"index": i,
|
| 159 |
+
"total": len(plan.steps),
|
| 160 |
+
"tool": step.tool,
|
| 161 |
+
}, session_id=req.session_id)
|
| 162 |
+
|
| 163 |
+
step_result = await agent.execute_step(
|
| 164 |
+
step=step,
|
| 165 |
+
task_id=task_id,
|
| 166 |
+
session_id=req.session_id,
|
| 167 |
+
context={"goal": req.goal, "previous_results": results},
|
| 168 |
+
)
|
| 169 |
+
results.append(step_result)
|
| 170 |
+
|
| 171 |
+
await self.ws.emit(task_id, "step_completed", {
|
| 172 |
+
"step": step.name,
|
| 173 |
+
"step_id": step.id,
|
| 174 |
+
"index": i,
|
| 175 |
+
"output": step_result[:500] if isinstance(step_result, str) else str(step_result)[:500],
|
| 176 |
+
"status": "completed",
|
| 177 |
+
}, session_id=req.session_id)
|
| 178 |
+
await save_task_event(task_id, "step_completed", {"step": step.name, "index": i})
|
| 179 |
+
|
| 180 |
+
# ββ Finalize βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 181 |
+
await update_task_status(task_id, "finalizing")
|
| 182 |
+
await self.ws.emit(task_id, "step_started", {
|
| 183 |
+
"step": "Finalizing",
|
| 184 |
+
"description": "Compiling results...",
|
| 185 |
+
}, session_id=req.session_id)
|
| 186 |
+
|
| 187 |
+
final_result = await agent.finalize(
|
| 188 |
+
goal=req.goal,
|
| 189 |
+
steps=plan.steps,
|
| 190 |
+
results=results,
|
| 191 |
+
task_id=task_id,
|
| 192 |
+
session_id=req.session_id,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
await update_task_status(task_id, "completed", result=final_result)
|
| 196 |
+
await self.ws.emit(task_id, "task_completed", {
|
| 197 |
+
"result": final_result,
|
| 198 |
+
"steps_completed": len(plan.steps),
|
| 199 |
+
"duration": time.time(),
|
| 200 |
+
}, session_id=req.session_id)
|
| 201 |
+
|
| 202 |
+
# Save to memory
|
| 203 |
+
await save_memory(
|
| 204 |
+
content=f"Task: {req.goal}\nResult: {final_result}",
|
| 205 |
+
memory_type="task",
|
| 206 |
+
session_id=req.session_id,
|
| 207 |
+
project_id=req.project_id,
|
| 208 |
+
key=task_id,
|
| 209 |
+
)
|
| 210 |
+
await self.ws.emit(task_id, "memory_updated", {
|
| 211 |
+
"type": "task",
|
| 212 |
+
"key": task_id,
|
| 213 |
+
}, session_id=req.session_id)
|
| 214 |
+
|
| 215 |
+
log.info("Task completed", task_id=task_id)
|
| 216 |
+
|
| 217 |
+
except asyncio.CancelledError:
|
| 218 |
+
await update_task_status(task_id, "cancelled")
|
| 219 |
+
await self.ws.emit(task_id, "task_failed", {"reason": "cancelled"})
|
| 220 |
+
except Exception as e:
|
| 221 |
+
log.error("Task failed", task_id=task_id, error=str(e))
|
| 222 |
+
task_data = await get_task(task_id)
|
| 223 |
+
retry_count = (task_data or {}).get("retry_count", 0)
|
| 224 |
+
|
| 225 |
+
await self.ws.emit(task_id, "error", {
|
| 226 |
+
"error": str(e),
|
| 227 |
+
"retry_count": retry_count,
|
| 228 |
+
"will_retry": retry_count < MAX_RETRIES,
|
| 229 |
+
}, session_id=req.session_id)
|
| 230 |
+
|
| 231 |
+
if retry_count < MAX_RETRIES:
|
| 232 |
+
await update_task_status(task_id, "retrying", retry_count=retry_count + 1)
|
| 233 |
+
await asyncio.sleep(2 ** retry_count)
|
| 234 |
+
await self.ws.emit(task_id, "retry_attempt", {"count": retry_count + 1})
|
| 235 |
+
await self._execute(task_id, req)
|
| 236 |
+
else:
|
| 237 |
+
await update_task_status(task_id, "failed", error=str(e))
|
| 238 |
+
await self.ws.emit(task_id, "task_failed", {
|
| 239 |
+
"error": str(e),
|
| 240 |
+
"retry_count": retry_count,
|
| 241 |
+
}, session_id=req.session_id)
|
backend/ecosystem.config.cjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
apps: [
|
| 3 |
+
{
|
| 4 |
+
name: 'devin-backend',
|
| 5 |
+
script: 'uvicorn',
|
| 6 |
+
args: 'main:app --host 0.0.0.0 --port 7860 --loop asyncio --log-level info',
|
| 7 |
+
interpreter: 'python3',
|
| 8 |
+
cwd: '/home/user/devin-agent/backend',
|
| 9 |
+
watch: false,
|
| 10 |
+
instances: 1,
|
| 11 |
+
exec_mode: 'fork',
|
| 12 |
+
env: {
|
| 13 |
+
PORT: 7860,
|
| 14 |
+
HOST: '0.0.0.0',
|
| 15 |
+
DB_PATH: '/tmp/devin_agent.db',
|
| 16 |
+
PYTHONUNBUFFERED: '1',
|
| 17 |
+
},
|
| 18 |
+
},
|
| 19 |
+
],
|
| 20 |
+
}
|
backend/github/__init__.py
ADDED
|
File without changes
|
backend/main.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
π Devin-Style Autonomous AI Engineering Platform
|
| 3 |
+
Production-Grade FastAPI + WebSocket Backend
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from contextlib import asynccontextmanager
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
import structlog
|
| 16 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Request
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 19 |
+
from fastapi.responses import JSONResponse
|
| 20 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 21 |
+
from slowapi.util import get_remote_address
|
| 22 |
+
from slowapi.errors import RateLimitExceeded
|
| 23 |
+
|
| 24 |
+
from api.routes import tasks, chat, memory, github, health
|
| 25 |
+
from api.websocket_manager import WebSocketManager
|
| 26 |
+
from core.task_engine import TaskEngine
|
| 27 |
+
from memory.db import init_db
|
| 28 |
+
|
| 29 |
+
# βββ Structured Logging ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
+
structlog.configure(
|
| 31 |
+
processors=[
|
| 32 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 33 |
+
structlog.stdlib.add_log_level,
|
| 34 |
+
structlog.processors.StackInfoRenderer(),
|
| 35 |
+
structlog.dev.ConsoleRenderer(),
|
| 36 |
+
]
|
| 37 |
+
)
|
| 38 |
+
log = structlog.get_logger()
|
| 39 |
+
|
| 40 |
+
# βββ Rate Limiter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 42 |
+
|
| 43 |
+
# βββ Global Managers (shared state) βββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
ws_manager = WebSocketManager()
|
| 45 |
+
task_engine = TaskEngine(ws_manager)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@asynccontextmanager
|
| 49 |
+
async def lifespan(app: FastAPI):
|
| 50 |
+
"""Startup + Shutdown lifecycle."""
|
| 51 |
+
log.info("π Starting Devin Agent Platform...")
|
| 52 |
+
await init_db()
|
| 53 |
+
await task_engine.start()
|
| 54 |
+
asyncio.create_task(ws_manager.heartbeat_loop())
|
| 55 |
+
log.info("β
Platform ready")
|
| 56 |
+
yield
|
| 57 |
+
log.info("π Shutting down...")
|
| 58 |
+
await task_engine.stop()
|
| 59 |
+
log.info("β
Shutdown complete")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# βββ FastAPI App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
app = FastAPI(
|
| 64 |
+
title="π€ Devin Agent Platform",
|
| 65 |
+
description="Production-Grade Autonomous AI Engineering Platform",
|
| 66 |
+
version="2.0.0",
|
| 67 |
+
lifespan=lifespan,
|
| 68 |
+
docs_url="/api/docs",
|
| 69 |
+
redoc_url="/api/redoc",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
app.state.limiter = limiter
|
| 73 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 74 |
+
|
| 75 |
+
# βββ Middleware ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
app.add_middleware(
|
| 77 |
+
CORSMiddleware,
|
| 78 |
+
allow_origins=["*"],
|
| 79 |
+
allow_credentials=True,
|
| 80 |
+
allow_methods=["*"],
|
| 81 |
+
allow_headers=["*"],
|
| 82 |
+
)
|
| 83 |
+
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# βββ Request Logging βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 87 |
+
@app.middleware("http")
|
| 88 |
+
async def log_requests(request: Request, call_next):
|
| 89 |
+
start = time.time()
|
| 90 |
+
response = await call_next(request)
|
| 91 |
+
duration = round((time.time() - start) * 1000, 2)
|
| 92 |
+
log.info("HTTP", method=request.method, path=request.url.path, status=response.status_code, ms=duration)
|
| 93 |
+
return response
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# βββ Inject shared state into routes ββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
app.state.ws_manager = ws_manager
|
| 98 |
+
app.state.task_engine = task_engine
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# βββ REST API Routers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
app.include_router(health.router, prefix="/api/v1", tags=["health"])
|
| 103 |
+
app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"])
|
| 104 |
+
app.include_router(chat.router, prefix="/api/v1", tags=["chat"])
|
| 105 |
+
app.include_router(memory.router, prefix="/api/v1/memory", tags=["memory"])
|
| 106 |
+
app.include_router(github.router, prefix="/api/v1/github", tags=["github"])
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# βββ WebSocket Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 110 |
+
@app.websocket("/ws/tasks/{task_id}")
|
| 111 |
+
async def ws_task(websocket: WebSocket, task_id: str):
|
| 112 |
+
"""Live streaming for specific task execution."""
|
| 113 |
+
await ws_manager.connect(websocket, room=f"task:{task_id}")
|
| 114 |
+
try:
|
| 115 |
+
while True:
|
| 116 |
+
data = await websocket.receive_text()
|
| 117 |
+
msg = json.loads(data)
|
| 118 |
+
if msg.get("type") == "ping":
|
| 119 |
+
await websocket.send_json({"type": "pong", "timestamp": time.time()})
|
| 120 |
+
except WebSocketDisconnect:
|
| 121 |
+
ws_manager.disconnect(websocket, room=f"task:{task_id}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.websocket("/ws/logs")
|
| 125 |
+
async def ws_logs(websocket: WebSocket):
|
| 126 |
+
"""Global live log stream."""
|
| 127 |
+
await ws_manager.connect(websocket, room="logs")
|
| 128 |
+
try:
|
| 129 |
+
while True:
|
| 130 |
+
data = await websocket.receive_text()
|
| 131 |
+
msg = json.loads(data)
|
| 132 |
+
if msg.get("type") == "ping":
|
| 133 |
+
await websocket.send_json({"type": "pong", "timestamp": time.time()})
|
| 134 |
+
except WebSocketDisconnect:
|
| 135 |
+
ws_manager.disconnect(websocket, room="logs")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@app.websocket("/ws/chat/{session_id}")
|
| 139 |
+
async def ws_chat(websocket: WebSocket, session_id: str):
|
| 140 |
+
"""Real-time chat streaming per session."""
|
| 141 |
+
await ws_manager.connect(websocket, room=f"chat:{session_id}")
|
| 142 |
+
try:
|
| 143 |
+
while True:
|
| 144 |
+
data = await websocket.receive_text()
|
| 145 |
+
msg = json.loads(data)
|
| 146 |
+
if msg.get("type") == "ping":
|
| 147 |
+
await websocket.send_json({"type": "pong", "timestamp": time.time()})
|
| 148 |
+
elif msg.get("type") == "chat_message":
|
| 149 |
+
# Trigger streaming chat response
|
| 150 |
+
asyncio.create_task(
|
| 151 |
+
task_engine.handle_chat_message(session_id, msg.get("content", ""), websocket)
|
| 152 |
+
)
|
| 153 |
+
except WebSocketDisconnect:
|
| 154 |
+
ws_manager.disconnect(websocket, room=f"chat:{session_id}")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@app.websocket("/ws/agent/status")
|
| 158 |
+
async def ws_agent_status(websocket: WebSocket):
|
| 159 |
+
"""Global agent status stream."""
|
| 160 |
+
await ws_manager.connect(websocket, room="agent_status")
|
| 161 |
+
try:
|
| 162 |
+
while True:
|
| 163 |
+
data = await websocket.receive_text()
|
| 164 |
+
msg = json.loads(data)
|
| 165 |
+
if msg.get("type") == "ping":
|
| 166 |
+
await websocket.send_json({"type": "pong", "timestamp": time.time()})
|
| 167 |
+
except WebSocketDisconnect:
|
| 168 |
+
ws_manager.disconnect(websocket, room="agent_status")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# βββ Root ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 172 |
+
@app.get("/")
|
| 173 |
+
async def root():
|
| 174 |
+
return {
|
| 175 |
+
"name": "π€ Devin Agent Platform",
|
| 176 |
+
"version": "2.0.0",
|
| 177 |
+
"status": "operational",
|
| 178 |
+
"docs": "/api/docs",
|
| 179 |
+
"websockets": ["/ws/tasks/{task_id}", "/ws/logs", "/ws/chat/{session_id}", "/ws/agent/status"],
|
| 180 |
+
}
|
backend/memory/__init__.py
ADDED
|
File without changes
|
backend/memory/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (146 Bytes). View file
|
|
|
backend/memory/__pycache__/db.cpython-312.pyc
ADDED
|
Binary file (19.5 kB). View file
|
|
|
backend/memory/db.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Production SQLite Database β Async via aiosqlite
|
| 3 |
+
Handles tasks, memory, sessions, events
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import aiosqlite
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
from typing import Optional, List, Dict, Any
|
| 11 |
+
import structlog
|
| 12 |
+
|
| 13 |
+
log = structlog.get_logger()
|
| 14 |
+
|
| 15 |
+
DB_PATH = os.environ.get("DB_PATH", "/tmp/devin_agent.db")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def get_db() -> aiosqlite.Connection:
|
| 19 |
+
db = await aiosqlite.connect(DB_PATH)
|
| 20 |
+
db.row_factory = aiosqlite.Row
|
| 21 |
+
await db.execute("PRAGMA journal_mode=WAL")
|
| 22 |
+
await db.execute("PRAGMA foreign_keys=ON")
|
| 23 |
+
return db
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def init_db():
|
| 27 |
+
"""Initialize all tables."""
|
| 28 |
+
log.info("Initializing database", path=DB_PATH)
|
| 29 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 30 |
+
await db.execute("PRAGMA journal_mode=WAL")
|
| 31 |
+
await db.execute("PRAGMA foreign_keys=ON")
|
| 32 |
+
|
| 33 |
+
# Tasks table
|
| 34 |
+
await db.execute("""
|
| 35 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 36 |
+
id TEXT PRIMARY KEY,
|
| 37 |
+
session_id TEXT,
|
| 38 |
+
project_id TEXT,
|
| 39 |
+
goal TEXT NOT NULL,
|
| 40 |
+
status TEXT DEFAULT 'queued',
|
| 41 |
+
plan TEXT,
|
| 42 |
+
result TEXT,
|
| 43 |
+
error TEXT,
|
| 44 |
+
metadata TEXT DEFAULT '{}',
|
| 45 |
+
created_at REAL,
|
| 46 |
+
started_at REAL,
|
| 47 |
+
completed_at REAL,
|
| 48 |
+
retry_count INTEGER DEFAULT 0
|
| 49 |
+
)
|
| 50 |
+
""")
|
| 51 |
+
|
| 52 |
+
# Task events table
|
| 53 |
+
await db.execute("""
|
| 54 |
+
CREATE TABLE IF NOT EXISTS task_events (
|
| 55 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 56 |
+
task_id TEXT NOT NULL,
|
| 57 |
+
event_type TEXT NOT NULL,
|
| 58 |
+
data TEXT DEFAULT '{}',
|
| 59 |
+
timestamp REAL,
|
| 60 |
+
FOREIGN KEY (task_id) REFERENCES tasks(id)
|
| 61 |
+
)
|
| 62 |
+
""")
|
| 63 |
+
|
| 64 |
+
# Memory table
|
| 65 |
+
await db.execute("""
|
| 66 |
+
CREATE TABLE IF NOT EXISTS memory (
|
| 67 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 68 |
+
session_id TEXT,
|
| 69 |
+
project_id TEXT,
|
| 70 |
+
memory_type TEXT NOT NULL,
|
| 71 |
+
key TEXT,
|
| 72 |
+
content TEXT NOT NULL,
|
| 73 |
+
metadata TEXT DEFAULT '{}',
|
| 74 |
+
embedding TEXT,
|
| 75 |
+
created_at REAL,
|
| 76 |
+
updated_at REAL
|
| 77 |
+
)
|
| 78 |
+
""")
|
| 79 |
+
|
| 80 |
+
# Sessions table
|
| 81 |
+
await db.execute("""
|
| 82 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 83 |
+
id TEXT PRIMARY KEY,
|
| 84 |
+
project_id TEXT,
|
| 85 |
+
user_id TEXT,
|
| 86 |
+
metadata TEXT DEFAULT '{}',
|
| 87 |
+
created_at REAL,
|
| 88 |
+
last_active REAL
|
| 89 |
+
)
|
| 90 |
+
""")
|
| 91 |
+
|
| 92 |
+
# GitHub operations table
|
| 93 |
+
await db.execute("""
|
| 94 |
+
CREATE TABLE IF NOT EXISTS github_ops (
|
| 95 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 96 |
+
task_id TEXT,
|
| 97 |
+
operation TEXT NOT NULL,
|
| 98 |
+
repo TEXT,
|
| 99 |
+
branch TEXT,
|
| 100 |
+
status TEXT DEFAULT 'pending',
|
| 101 |
+
result TEXT,
|
| 102 |
+
created_at REAL
|
| 103 |
+
)
|
| 104 |
+
""")
|
| 105 |
+
|
| 106 |
+
# Indexes
|
| 107 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id)")
|
| 108 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
|
| 109 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id)")
|
| 110 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_memory_session ON memory(session_id)")
|
| 111 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_memory_project ON memory(project_id)")
|
| 112 |
+
await db.execute("CREATE INDEX IF NOT EXISTS idx_memory_type ON memory(memory_type)")
|
| 113 |
+
|
| 114 |
+
await db.commit()
|
| 115 |
+
log.info("β
Database initialized")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# βββ Task CRUD βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 119 |
+
|
| 120 |
+
async def create_task(task_id: str, goal: str, session_id: str = "", project_id: str = "", metadata: dict = {}):
|
| 121 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 122 |
+
await db.execute("""
|
| 123 |
+
INSERT INTO tasks (id, session_id, project_id, goal, status, metadata, created_at)
|
| 124 |
+
VALUES (?, ?, ?, ?, 'queued', ?, ?)
|
| 125 |
+
""", (task_id, session_id, project_id, goal, json.dumps(metadata), time.time()))
|
| 126 |
+
await db.commit()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
async def update_task_status(task_id: str, status: str, **kwargs):
|
| 130 |
+
fields = ["status = ?"]
|
| 131 |
+
values = [status]
|
| 132 |
+
if status == "executing":
|
| 133 |
+
fields.append("started_at = ?")
|
| 134 |
+
values.append(time.time())
|
| 135 |
+
if status in ("completed", "failed", "cancelled"):
|
| 136 |
+
fields.append("completed_at = ?")
|
| 137 |
+
values.append(time.time())
|
| 138 |
+
for k, v in kwargs.items():
|
| 139 |
+
if k in ("plan", "result", "error"):
|
| 140 |
+
fields.append(f"{k} = ?")
|
| 141 |
+
values.append(v if isinstance(v, str) else json.dumps(v))
|
| 142 |
+
elif k == "retry_count":
|
| 143 |
+
fields.append("retry_count = ?")
|
| 144 |
+
values.append(v)
|
| 145 |
+
values.append(task_id)
|
| 146 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 147 |
+
await db.execute(f"UPDATE tasks SET {', '.join(fields)} WHERE id = ?", values)
|
| 148 |
+
await db.commit()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def get_task(task_id: str) -> Optional[Dict]:
|
| 152 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 153 |
+
db.row_factory = aiosqlite.Row
|
| 154 |
+
async with db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)) as cursor:
|
| 155 |
+
row = await cursor.fetchone()
|
| 156 |
+
if row:
|
| 157 |
+
d = dict(row)
|
| 158 |
+
d["metadata"] = json.loads(d.get("metadata") or "{}")
|
| 159 |
+
d["plan"] = json.loads(d["plan"]) if d.get("plan") else None
|
| 160 |
+
return d
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
async def list_tasks(session_id: str = "", limit: int = 50) -> List[Dict]:
|
| 165 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 166 |
+
db.row_factory = aiosqlite.Row
|
| 167 |
+
if session_id:
|
| 168 |
+
async with db.execute(
|
| 169 |
+
"SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
|
| 170 |
+
(session_id, limit)
|
| 171 |
+
) as cursor:
|
| 172 |
+
rows = await cursor.fetchall()
|
| 173 |
+
else:
|
| 174 |
+
async with db.execute(
|
| 175 |
+
"SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?", (limit,)
|
| 176 |
+
) as cursor:
|
| 177 |
+
rows = await cursor.fetchall()
|
| 178 |
+
return [dict(r) for r in rows]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def save_task_event(task_id: str, event_type: str, data: dict = {}):
|
| 182 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 183 |
+
await db.execute("""
|
| 184 |
+
INSERT INTO task_events (task_id, event_type, data, timestamp)
|
| 185 |
+
VALUES (?, ?, ?, ?)
|
| 186 |
+
""", (task_id, event_type, json.dumps(data), time.time()))
|
| 187 |
+
await db.commit()
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def get_task_events(task_id: str) -> List[Dict]:
|
| 191 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 192 |
+
db.row_factory = aiosqlite.Row
|
| 193 |
+
async with db.execute(
|
| 194 |
+
"SELECT * FROM task_events WHERE task_id = ? ORDER BY timestamp ASC", (task_id,)
|
| 195 |
+
) as cursor:
|
| 196 |
+
rows = await cursor.fetchall()
|
| 197 |
+
return [dict(r) for r in rows]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# βββ Memory CRUD βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 201 |
+
|
| 202 |
+
async def save_memory(
|
| 203 |
+
content: str,
|
| 204 |
+
memory_type: str,
|
| 205 |
+
session_id: str = "",
|
| 206 |
+
project_id: str = "",
|
| 207 |
+
key: str = "",
|
| 208 |
+
metadata: dict = {}
|
| 209 |
+
):
|
| 210 |
+
now = time.time()
|
| 211 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 212 |
+
await db.execute("""
|
| 213 |
+
INSERT INTO memory (session_id, project_id, memory_type, key, content, metadata, created_at, updated_at)
|
| 214 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 215 |
+
""", (session_id, project_id, memory_type, key, content, json.dumps(metadata), now, now))
|
| 216 |
+
await db.commit()
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def search_memory(query: str, session_id: str = "", project_id: str = "", limit: int = 20) -> List[Dict]:
|
| 220 |
+
"""Simple keyword search (upgrade to vector search in production)."""
|
| 221 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 222 |
+
db.row_factory = aiosqlite.Row
|
| 223 |
+
q = f"%{query}%"
|
| 224 |
+
if session_id:
|
| 225 |
+
async with db.execute(
|
| 226 |
+
"SELECT * FROM memory WHERE session_id = ? AND content LIKE ? ORDER BY updated_at DESC LIMIT ?",
|
| 227 |
+
(session_id, q, limit)
|
| 228 |
+
) as cursor:
|
| 229 |
+
rows = await cursor.fetchall()
|
| 230 |
+
elif project_id:
|
| 231 |
+
async with db.execute(
|
| 232 |
+
"SELECT * FROM memory WHERE project_id = ? AND content LIKE ? ORDER BY updated_at DESC LIMIT ?",
|
| 233 |
+
(project_id, q, limit)
|
| 234 |
+
) as cursor:
|
| 235 |
+
rows = await cursor.fetchall()
|
| 236 |
+
else:
|
| 237 |
+
async with db.execute(
|
| 238 |
+
"SELECT * FROM memory WHERE content LIKE ? ORDER BY updated_at DESC LIMIT ?",
|
| 239 |
+
(q, limit)
|
| 240 |
+
) as cursor:
|
| 241 |
+
rows = await cursor.fetchall()
|
| 242 |
+
return [dict(r) for r in rows]
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
async def get_project_memory(project_id: str, memory_type: str = "", limit: int = 100) -> List[Dict]:
|
| 246 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 247 |
+
db.row_factory = aiosqlite.Row
|
| 248 |
+
if memory_type:
|
| 249 |
+
async with db.execute(
|
| 250 |
+
"SELECT * FROM memory WHERE project_id = ? AND memory_type = ? ORDER BY updated_at DESC LIMIT ?",
|
| 251 |
+
(project_id, memory_type, limit)
|
| 252 |
+
) as cursor:
|
| 253 |
+
rows = await cursor.fetchall()
|
| 254 |
+
else:
|
| 255 |
+
async with db.execute(
|
| 256 |
+
"SELECT * FROM memory WHERE project_id = ? ORDER BY updated_at DESC LIMIT ?",
|
| 257 |
+
(project_id, limit)
|
| 258 |
+
) as cursor:
|
| 259 |
+
rows = await cursor.fetchall()
|
| 260 |
+
return [dict(r) for r in rows]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def get_history(session_id: str, limit: int = 50) -> List[Dict]:
|
| 264 |
+
async with aiosqlite.connect(DB_PATH) as db:
|
| 265 |
+
db.row_factory = aiosqlite.Row
|
| 266 |
+
async with db.execute(
|
| 267 |
+
"SELECT * FROM memory WHERE session_id = ? AND memory_type = 'conversation' ORDER BY created_at DESC LIMIT ?",
|
| 268 |
+
(session_id, limit)
|
| 269 |
+
) as cursor:
|
| 270 |
+
rows = await cursor.fetchall()
|
| 271 |
+
return [dict(r) for r in rows]
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
websockets==12.0
|
| 4 |
+
pydantic==2.7.1
|
| 5 |
+
pydantic-settings==2.2.1
|
| 6 |
+
python-jose[cryptography]==3.3.0
|
| 7 |
+
python-multipart==0.0.9
|
| 8 |
+
aiohttp==3.9.5
|
| 9 |
+
aiosqlite==0.20.0
|
| 10 |
+
sqlalchemy[asyncio]==2.0.30
|
| 11 |
+
alembic==1.13.1
|
| 12 |
+
httpx==0.27.0
|
| 13 |
+
openai==1.30.1
|
| 14 |
+
anthropic==0.26.1
|
| 15 |
+
gitpython==3.1.43
|
| 16 |
+
pygithub==2.3.0
|
| 17 |
+
python-dotenv==1.0.1
|
| 18 |
+
slowapi==0.1.9
|
| 19 |
+
structlog==24.1.0
|
| 20 |
+
rich==13.7.1
|
| 21 |
+
asyncio-mqtt==0.16.2
|
| 22 |
+
redis==5.0.4
|
| 23 |
+
celery==5.3.6
|
| 24 |
+
passlib[bcrypt]==1.7.4
|
| 25 |
+
cryptography==42.0.7
|
| 26 |
+
typer==0.12.3
|
| 27 |
+
watchfiles==0.21.0
|
| 28 |
+
psutil==5.9.8
|
backend/tools/__init__.py
ADDED
|
File without changes
|
backend/tools/executor.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tool Executor β Routes tool calls to the right implementation
|
| 3 |
+
Supports: code, shell, file, browser, github, memory, search, test, none
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import tempfile
|
| 10 |
+
import time
|
| 11 |
+
from typing import Any, List, Optional
|
| 12 |
+
|
| 13 |
+
import structlog
|
| 14 |
+
|
| 15 |
+
from api.websocket_manager import WebSocketManager
|
| 16 |
+
|
| 17 |
+
log = structlog.get_logger()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ToolExecutor:
|
| 21 |
+
def __init__(self, ws_manager: WebSocketManager):
|
| 22 |
+
self.ws = ws_manager
|
| 23 |
+
|
| 24 |
+
async def run(
|
| 25 |
+
self,
|
| 26 |
+
tool: str,
|
| 27 |
+
task: str,
|
| 28 |
+
goal: str = "",
|
| 29 |
+
previous: List = [],
|
| 30 |
+
task_id: str = "",
|
| 31 |
+
session_id: str = "",
|
| 32 |
+
) -> str:
|
| 33 |
+
tool = (tool or "none").lower().strip()
|
| 34 |
+
|
| 35 |
+
dispatch = {
|
| 36 |
+
"code": self._tool_code,
|
| 37 |
+
"shell": self._tool_shell,
|
| 38 |
+
"file": self._tool_file,
|
| 39 |
+
"github": self._tool_github,
|
| 40 |
+
"memory": self._tool_memory,
|
| 41 |
+
"search": self._tool_search,
|
| 42 |
+
"test": self._tool_test,
|
| 43 |
+
"browser": self._tool_browser,
|
| 44 |
+
"none": self._tool_none,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
fn = dispatch.get(tool, self._tool_none)
|
| 48 |
+
return await fn(task=task, goal=goal, previous=previous, task_id=task_id, session_id=session_id)
|
| 49 |
+
|
| 50 |
+
# βββ Code Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
+
async def _tool_code(self, task, goal, previous, task_id, session_id) -> str:
|
| 52 |
+
"""Generate code using LLM."""
|
| 53 |
+
from core.agent import AgentCore
|
| 54 |
+
agent = AgentCore(self.ws)
|
| 55 |
+
messages = [
|
| 56 |
+
{"role": "system", "content": "You are an expert software engineer. Write clean, production-quality code. Return only the code with minimal explanation."},
|
| 57 |
+
{"role": "user", "content": f"Task: {task}\nGoal: {goal}\n\nWrite the code to accomplish this."},
|
| 58 |
+
]
|
| 59 |
+
result = await agent.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 60 |
+
return result or f"# Code for: {task}"
|
| 61 |
+
|
| 62 |
+
# βββ Shell Tool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
async def _tool_shell(self, task, goal, previous, task_id, session_id) -> str:
|
| 64 |
+
"""Execute shell commands safely in a temp workspace."""
|
| 65 |
+
# Extract command from task description
|
| 66 |
+
from core.agent import AgentCore
|
| 67 |
+
agent = AgentCore(self.ws)
|
| 68 |
+
messages = [
|
| 69 |
+
{"role": "system", "content": "Extract the shell command to run. Return ONLY the command, nothing else."},
|
| 70 |
+
{"role": "user", "content": f"Task: {task}"},
|
| 71 |
+
]
|
| 72 |
+
cmd = await agent.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 73 |
+
cmd = cmd.strip().strip("`").strip()
|
| 74 |
+
|
| 75 |
+
# Safety: block dangerous commands
|
| 76 |
+
blocked = ["rm -rf /", ":(){ :|:& };:", "mkfs", "dd if=", "shutdown", "reboot", "halt"]
|
| 77 |
+
for b in blocked:
|
| 78 |
+
if b in cmd:
|
| 79 |
+
return f"β Blocked dangerous command: {cmd}"
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
await self.ws.emit(task_id, "step_progress", {
|
| 83 |
+
"action": "shell_exec",
|
| 84 |
+
"command": cmd[:200],
|
| 85 |
+
}, session_id=session_id)
|
| 86 |
+
proc = await asyncio.create_subprocess_shell(
|
| 87 |
+
cmd,
|
| 88 |
+
stdout=asyncio.subprocess.PIPE,
|
| 89 |
+
stderr=asyncio.subprocess.PIPE,
|
| 90 |
+
cwd="/tmp",
|
| 91 |
+
)
|
| 92 |
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
| 93 |
+
output = stdout.decode()[:2000] + (stderr.decode()[:500] if stderr else "")
|
| 94 |
+
return output or "Command executed (no output)"
|
| 95 |
+
except asyncio.TimeoutError:
|
| 96 |
+
return "β οΈ Command timed out after 30s"
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return f"β Shell error: {str(e)}"
|
| 99 |
+
|
| 100 |
+
# βββ File Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 101 |
+
async def _tool_file(self, task, goal, previous, task_id, session_id) -> str:
|
| 102 |
+
"""Create or modify files."""
|
| 103 |
+
from core.agent import AgentCore
|
| 104 |
+
agent = AgentCore(self.ws)
|
| 105 |
+
messages = [
|
| 106 |
+
{"role": "system", "content": "Generate file content. Respond with JSON: {\"filename\": \"...\", \"content\": \"...\"}"},
|
| 107 |
+
{"role": "user", "content": f"Task: {task}\nGoal: {goal}"},
|
| 108 |
+
]
|
| 109 |
+
raw = await agent.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 110 |
+
try:
|
| 111 |
+
import json
|
| 112 |
+
start = raw.find("{")
|
| 113 |
+
end = raw.rfind("}") + 1
|
| 114 |
+
data = json.loads(raw[start:end])
|
| 115 |
+
filename = data.get("filename", "output.txt")
|
| 116 |
+
content = data.get("content", raw)
|
| 117 |
+
path = f"/tmp/workspace/{filename}"
|
| 118 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 119 |
+
with open(path, "w") as f:
|
| 120 |
+
f.write(content)
|
| 121 |
+
await self.ws.emit(task_id, "step_progress", {
|
| 122 |
+
"action": "file_written",
|
| 123 |
+
"filename": filename,
|
| 124 |
+
"size": len(content),
|
| 125 |
+
}, session_id=session_id)
|
| 126 |
+
return f"β
File written: {filename} ({len(content)} chars)"
|
| 127 |
+
except Exception as e:
|
| 128 |
+
return f"File task result: {raw[:500]}"
|
| 129 |
+
|
| 130 |
+
# βββ GitHub Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
async def _tool_github(self, task, goal, previous, task_id, session_id) -> str:
|
| 132 |
+
"""Perform GitHub operations."""
|
| 133 |
+
return f"GitHub: {task}\n(Set GITHUB_TOKEN to enable real GitHub operations)"
|
| 134 |
+
|
| 135 |
+
# βββ Memory Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
async def _tool_memory(self, task, goal, previous, task_id, session_id) -> str:
|
| 137 |
+
"""Save/retrieve from memory."""
|
| 138 |
+
from memory.db import save_memory, search_memory
|
| 139 |
+
results = await search_memory(task[:50], session_id=session_id)
|
| 140 |
+
if results:
|
| 141 |
+
return "\n".join([r["content"][:300] for r in results[:3]])
|
| 142 |
+
return "No relevant memories found"
|
| 143 |
+
|
| 144 |
+
# βββ Search Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 145 |
+
async def _tool_search(self, task, goal, previous, task_id, session_id) -> str:
|
| 146 |
+
"""Web search using available APIs."""
|
| 147 |
+
return f"Search result for: {task}\n(Integrate search API for real results)"
|
| 148 |
+
|
| 149 |
+
# βββ Test Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 150 |
+
async def _tool_test(self, task, goal, previous, task_id, session_id) -> str:
|
| 151 |
+
"""Generate and run tests."""
|
| 152 |
+
from core.agent import AgentCore
|
| 153 |
+
agent = AgentCore(self.ws)
|
| 154 |
+
messages = [
|
| 155 |
+
{"role": "system", "content": "Write test cases for the given task. Use pytest format."},
|
| 156 |
+
{"role": "user", "content": f"Write tests for: {task}\nContext: {goal}"},
|
| 157 |
+
]
|
| 158 |
+
result = await agent.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 159 |
+
return result or f"# Tests for: {task}"
|
| 160 |
+
|
| 161 |
+
# βββ Browser Tool ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
async def _tool_browser(self, task, goal, previous, task_id, session_id) -> str:
|
| 163 |
+
"""Browser automation (stub β extend with playwright)."""
|
| 164 |
+
return f"Browser task: {task}\n(Install playwright for real browser automation)"
|
| 165 |
+
|
| 166 |
+
# βββ None Tool βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
+
async def _tool_none(self, task, goal, previous, task_id, session_id) -> str:
|
| 168 |
+
"""Use LLM directly without tools."""
|
| 169 |
+
from core.agent import AgentCore
|
| 170 |
+
agent = AgentCore(self.ws)
|
| 171 |
+
messages = [
|
| 172 |
+
{"role": "system", "content": "You are an expert engineer. Complete the task thoroughly."},
|
| 173 |
+
{"role": "user", "content": f"Task: {task}\nGoal context: {goal}"},
|
| 174 |
+
]
|
| 175 |
+
result = await agent.llm_stream(messages, task_id=task_id, session_id=session_id)
|
| 176 |
+
return result or f"Completed: {task}"
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--bg-primary: #0f1017;
|
| 9 |
+
--bg-secondary: #13141c;
|
| 10 |
+
--bg-tertiary: #1a1b26;
|
| 11 |
+
--border: #2a2b3d;
|
| 12 |
+
--text-primary: #e2e8f0;
|
| 13 |
+
--text-secondary: #94a3b8;
|
| 14 |
+
--accent: #4f6ef7;
|
| 15 |
+
--accent-glow: rgba(79, 110, 247, 0.3);
|
| 16 |
+
--success: #4ade80;
|
| 17 |
+
--warning: #facc15;
|
| 18 |
+
--error: #f87171;
|
| 19 |
+
--terminal-bg: #0a0b10;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 23 |
+
|
| 24 |
+
html, body {
|
| 25 |
+
height: 100%;
|
| 26 |
+
background-color: var(--bg-primary);
|
| 27 |
+
color: var(--text-primary);
|
| 28 |
+
font-family: 'Inter', system-ui, sans-serif;
|
| 29 |
+
font-size: 14px;
|
| 30 |
+
line-height: 1.6;
|
| 31 |
+
overflow: hidden;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Scrollbar */
|
| 35 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 36 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 37 |
+
::-webkit-scrollbar-thumb { background: #2a2b3d; border-radius: 2px; }
|
| 38 |
+
::-webkit-scrollbar-thumb:hover { background: #3a3b5a; }
|
| 39 |
+
|
| 40 |
+
/* Selection */
|
| 41 |
+
::selection { background: var(--accent-glow); color: var(--text-primary); }
|
| 42 |
+
|
| 43 |
+
/* Focus */
|
| 44 |
+
*:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
|
| 45 |
+
|
| 46 |
+
/* Animations */
|
| 47 |
+
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
| 48 |
+
@keyframes shimmer {
|
| 49 |
+
0% { background-position: -200% 0; }
|
| 50 |
+
100% { background-position: 200% 0; }
|
| 51 |
+
}
|
| 52 |
+
@keyframes scan {
|
| 53 |
+
0% { transform: translateY(-100%); }
|
| 54 |
+
100% { transform: translateY(100vh); }
|
| 55 |
+
}
|
| 56 |
+
@keyframes fadeSlideIn {
|
| 57 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 58 |
+
to { opacity: 1; transform: translateY(0); }
|
| 59 |
+
}
|
| 60 |
+
@keyframes pulseRing {
|
| 61 |
+
0% { transform: scale(1); opacity: 1; }
|
| 62 |
+
100% { transform: scale(2); opacity: 0; }
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* Cursor blink */
|
| 66 |
+
.cursor-blink::after {
|
| 67 |
+
content: 'β';
|
| 68 |
+
animation: blink 1s step-end infinite;
|
| 69 |
+
color: var(--accent);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/* Loading shimmer */
|
| 73 |
+
.shimmer {
|
| 74 |
+
background: linear-gradient(90deg, #1a1b26 25%, #2a2b3d 50%, #1a1b26 75%);
|
| 75 |
+
background-size: 200% 100%;
|
| 76 |
+
animation: shimmer 1.5s infinite;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* Glass effect */
|
| 80 |
+
.glass {
|
| 81 |
+
background: rgba(26, 27, 38, 0.7);
|
| 82 |
+
backdrop-filter: blur(12px);
|
| 83 |
+
-webkit-backdrop-filter: blur(12px);
|
| 84 |
+
border: 1px solid rgba(42, 43, 61, 0.8);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* Code blocks */
|
| 88 |
+
pre, code {
|
| 89 |
+
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/* Prose override for dark */
|
| 93 |
+
.prose-dark {
|
| 94 |
+
color: var(--text-primary);
|
| 95 |
+
}
|
| 96 |
+
.prose-dark h1, .prose-dark h2, .prose-dark h3 { color: #e2e8f0; }
|
| 97 |
+
.prose-dark code {
|
| 98 |
+
background: #1a1b26;
|
| 99 |
+
padding: 2px 6px;
|
| 100 |
+
border-radius: 4px;
|
| 101 |
+
font-size: 0.85em;
|
| 102 |
+
color: #c084fc;
|
| 103 |
+
}
|
| 104 |
+
.prose-dark pre {
|
| 105 |
+
background: #0a0b10 !important;
|
| 106 |
+
border: 1px solid #2a2b3d;
|
| 107 |
+
border-radius: 8px;
|
| 108 |
+
}
|
| 109 |
+
.prose-dark blockquote {
|
| 110 |
+
border-left: 3px solid var(--accent);
|
| 111 |
+
padding-left: 1rem;
|
| 112 |
+
color: var(--text-secondary);
|
| 113 |
+
}
|
| 114 |
+
.prose-dark a { color: var(--accent); }
|
| 115 |
+
.prose-dark strong { color: #e2e8f0; }
|
| 116 |
+
.prose-dark hr { border-color: #2a2b3d; }
|
| 117 |
+
.prose-dark ul li::marker { color: var(--accent); }
|
| 118 |
+
.prose-dark table th { background: #1a1b26; }
|
| 119 |
+
.prose-dark table td { border-color: #2a2b3d; }
|
| 120 |
+
|
| 121 |
+
/* Step status colors */
|
| 122 |
+
.step-running { color: #60a5fa; }
|
| 123 |
+
.step-completed { color: #4ade80; }
|
| 124 |
+
.step-failed { color: #f87171; }
|
| 125 |
+
.step-pending { color: #94a3b8; }
|
| 126 |
+
|
| 127 |
+
/* Typing indicator */
|
| 128 |
+
.typing-dot {
|
| 129 |
+
width: 6px; height: 6px;
|
| 130 |
+
border-radius: 50%;
|
| 131 |
+
background: var(--accent);
|
| 132 |
+
animation: pulseDot 1.4s ease-in-out infinite;
|
| 133 |
+
}
|
| 134 |
+
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
| 135 |
+
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
| 136 |
+
@keyframes pulseDot {
|
| 137 |
+
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
| 138 |
+
40% { transform: scale(1); opacity: 1; }
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* Message animation */
|
| 142 |
+
.message-enter {
|
| 143 |
+
animation: fadeSlideIn 0.3s ease-out forwards;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* Status badge */
|
| 147 |
+
.status-queued { background: #1e293b; color: #94a3b8; }
|
| 148 |
+
.status-planning { background: #1a1f3a; color: #818cf8; }
|
| 149 |
+
.status-executing { background: #1a2d3a; color: #38bdf8; }
|
| 150 |
+
.status-completed { background: #162b1e; color: #4ade80; }
|
| 151 |
+
.status-failed { background: #2b1619; color: #f87171; }
|
| 152 |
+
.status-retrying { background: #2b2419; color: #facc15; }
|
| 153 |
+
|
| 154 |
+
/* Glow border */
|
| 155 |
+
.glow-border {
|
| 156 |
+
box-shadow: 0 0 0 1px var(--accent), 0 0 12px var(--accent-glow);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/* Terminal window */
|
| 160 |
+
.terminal {
|
| 161 |
+
background: var(--terminal-bg);
|
| 162 |
+
border-radius: 8px;
|
| 163 |
+
border: 1px solid #2a2b3d;
|
| 164 |
+
font-family: 'JetBrains Mono', monospace;
|
| 165 |
+
font-size: 12px;
|
| 166 |
+
line-height: 1.5;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Scan line effect */
|
| 170 |
+
.scan-line::before {
|
| 171 |
+
content: '';
|
| 172 |
+
position: absolute;
|
| 173 |
+
top: 0; left: 0; right: 0;
|
| 174 |
+
height: 2px;
|
| 175 |
+
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
| 176 |
+
opacity: 0.3;
|
| 177 |
+
animation: scan 4s linear infinite;
|
| 178 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from 'next'
|
| 2 |
+
import './globals.css'
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: 'π€ Devin Agent β Autonomous AI Engineering Platform',
|
| 6 |
+
description: 'Production-grade autonomous AI coding agent with real-time streaming, WebSocket execution, GitHub automation, and persistent memory.',
|
| 7 |
+
keywords: ['AI agent', 'autonomous coding', 'Devin', 'Manus', 'streaming AI'],
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
| 11 |
+
return (
|
| 12 |
+
<html lang="en" className="dark">
|
| 13 |
+
<head>
|
| 14 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 15 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 16 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
| 17 |
+
</head>
|
| 18 |
+
<body className="antialiased overflow-hidden h-screen">{children}</body>
|
| 19 |
+
</html>
|
| 20 |
+
)
|
| 21 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react'
|
| 4 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 5 |
+
import { useAgentWebSocket } from '@/hooks/useWebSocket'
|
| 6 |
+
import TopBar from '@/components/layout/TopBar'
|
| 7 |
+
import Sidebar from '@/components/layout/Sidebar'
|
| 8 |
+
import ChatPanel from '@/components/chat/ChatPanel'
|
| 9 |
+
import ExecutionTimeline from '@/components/timeline/ExecutionTimeline'
|
| 10 |
+
import TasksPanel from '@/components/layout/TasksPanel'
|
| 11 |
+
import MemoryPanel from '@/components/layout/MemoryPanel'
|
| 12 |
+
|
| 13 |
+
export default function HomePage() {
|
| 14 |
+
const { activePanel, activeTaskId } = useAgentStore()
|
| 15 |
+
const [mounted, setMounted] = useState(false)
|
| 16 |
+
useEffect(() => setMounted(true), [])
|
| 17 |
+
|
| 18 |
+
// Connect to global log stream + active task stream
|
| 19 |
+
useAgentWebSocket(undefined)
|
| 20 |
+
useAgentWebSocket(activeTaskId || undefined)
|
| 21 |
+
|
| 22 |
+
if (!mounted) return (
|
| 23 |
+
<div className="flex items-center justify-center h-screen bg-[#0f1017]">
|
| 24 |
+
<div className="text-center">
|
| 25 |
+
<div className="text-4xl mb-4">π€</div>
|
| 26 |
+
<div className="text-slate-400 text-sm">Loading Devin Agent...</div>
|
| 27 |
+
<div className="flex gap-1 justify-center mt-3">
|
| 28 |
+
{[0,1,2].map(i => <div key={i} className="typing-dot" style={{animationDelay:`${i*0.2}s`}} />)}
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
const RightPanel = () => {
|
| 35 |
+
switch (activePanel) {
|
| 36 |
+
case 'timeline': return <ExecutionTimeline />
|
| 37 |
+
case 'tasks': return <TasksPanel />
|
| 38 |
+
case 'memory': return <MemoryPanel />
|
| 39 |
+
default: return <ExecutionTimeline />
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="flex flex-col h-screen overflow-hidden bg-[#0f1017]">
|
| 45 |
+
{/* Top bar */}
|
| 46 |
+
<TopBar />
|
| 47 |
+
|
| 48 |
+
{/* Main layout */}
|
| 49 |
+
<div className="flex flex-1 overflow-hidden">
|
| 50 |
+
{/* Left sidebar */}
|
| 51 |
+
<Sidebar />
|
| 52 |
+
|
| 53 |
+
{/* Center: Chat */}
|
| 54 |
+
<div className="flex-1 min-w-0 border-r border-[#2a2b3d]">
|
| 55 |
+
<ChatPanel />
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
{/* Right: Timeline / Tasks / Memory */}
|
| 59 |
+
<div className="w-[420px] flex-shrink-0 hidden lg:block">
|
| 60 |
+
<RightPanel />
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
)
|
| 65 |
+
}
|
frontend/components/chat/ChatPanel.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
| 4 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 5 |
+
import { streamChatSSE } from '@/lib/websocket'
|
| 6 |
+
import { createTask } from '@/lib/api'
|
| 7 |
+
import MessageBubble from './MessageBubble'
|
| 8 |
+
import { Send, Loader2, Zap, Code2, GitBranch, Brain, Square } from 'lucide-react'
|
| 9 |
+
|
| 10 |
+
const QUICK_ACTIONS = [
|
| 11 |
+
{ icon: Code2, label: 'Build a REST API', prompt: 'Build a production-ready REST API with FastAPI, SQLite, authentication, and CRUD endpoints for a todo app' },
|
| 12 |
+
{ icon: GitBranch, label: 'Create GitHub repo', prompt: 'Create a new GitHub repository, initialize it with a README, add a .gitignore for Python, and push initial code' },
|
| 13 |
+
{ icon: Brain, label: 'Analyze codebase', prompt: 'Analyze the current project structure and suggest improvements for code quality, performance, and maintainability' },
|
| 14 |
+
{ icon: Zap, label: 'Deploy to Vercel', prompt: 'Deploy this application to Vercel with proper environment variables and generate a production URL' },
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
export default function ChatPanel() {
|
| 18 |
+
const [input, setInput] = useState('')
|
| 19 |
+
const [mode, setMode] = useState<'chat' | 'agent'>('agent')
|
| 20 |
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
| 21 |
+
const inputRef = useRef<HTMLTextAreaElement>(null)
|
| 22 |
+
const abortRef = useRef<AbortController | null>(null)
|
| 23 |
+
|
| 24 |
+
const store = useAgentStore()
|
| 25 |
+
const { messages, sessionId, isStreaming, addMessage, setStreaming, appendChunk, updateMessage } = store
|
| 26 |
+
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 29 |
+
}, [messages])
|
| 30 |
+
|
| 31 |
+
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
|
| 32 |
+
e?.preventDefault()
|
| 33 |
+
const text = input.trim()
|
| 34 |
+
if (!text || isStreaming) return
|
| 35 |
+
|
| 36 |
+
setInput('')
|
| 37 |
+
inputRef.current?.focus()
|
| 38 |
+
|
| 39 |
+
// Add user message
|
| 40 |
+
addMessage({ role: 'user', content: text })
|
| 41 |
+
|
| 42 |
+
if (mode === 'agent') {
|
| 43 |
+
// Create autonomous task
|
| 44 |
+
try {
|
| 45 |
+
const assistantId = addMessage({
|
| 46 |
+
role: 'assistant',
|
| 47 |
+
content: '',
|
| 48 |
+
streaming: true,
|
| 49 |
+
metadata: { mode: 'agent' },
|
| 50 |
+
})
|
| 51 |
+
setStreaming(true, assistantId)
|
| 52 |
+
|
| 53 |
+
const result = await createTask(text, sessionId)
|
| 54 |
+
|
| 55 |
+
updateMessage(assistantId, {
|
| 56 |
+
content: `π **Task Created** \`${result.task_id}\`\n\nConnecting to execution stream... Watch the timeline β\n\n**Goal:** ${text}`,
|
| 57 |
+
streaming: false,
|
| 58 |
+
metadata: { task_id: result.task_id, mode: 'agent' },
|
| 59 |
+
})
|
| 60 |
+
setStreaming(false, null)
|
| 61 |
+
} catch (err: any) {
|
| 62 |
+
const id = addMessage({
|
| 63 |
+
role: 'assistant',
|
| 64 |
+
content: `β Failed to create task: ${err.message}\n\nMake sure the backend is running at \`${process.env.NEXT_PUBLIC_API_URL}\``,
|
| 65 |
+
metadata: { error: true },
|
| 66 |
+
})
|
| 67 |
+
setStreaming(false, null)
|
| 68 |
+
}
|
| 69 |
+
} else {
|
| 70 |
+
// Streaming chat mode
|
| 71 |
+
const assistantId = addMessage({
|
| 72 |
+
role: 'assistant',
|
| 73 |
+
content: '',
|
| 74 |
+
streaming: true,
|
| 75 |
+
metadata: { mode: 'chat' },
|
| 76 |
+
})
|
| 77 |
+
setStreaming(true, assistantId)
|
| 78 |
+
|
| 79 |
+
const chatMessages = [
|
| 80 |
+
...messages.filter(m => !m.streaming).slice(-10).map(m => ({
|
| 81 |
+
role: m.role as 'user' | 'assistant',
|
| 82 |
+
content: m.content,
|
| 83 |
+
})),
|
| 84 |
+
{ role: 'user' as const, content: text },
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
await streamChatSSE(
|
| 88 |
+
chatMessages,
|
| 89 |
+
sessionId,
|
| 90 |
+
(chunk) => appendChunk(assistantId, chunk),
|
| 91 |
+
(full) => {
|
| 92 |
+
updateMessage(assistantId, { content: full, streaming: false })
|
| 93 |
+
setStreaming(false, null)
|
| 94 |
+
},
|
| 95 |
+
(err) => {
|
| 96 |
+
updateMessage(assistantId, {
|
| 97 |
+
content: `β Stream error: ${err}`,
|
| 98 |
+
streaming: false,
|
| 99 |
+
metadata: { error: true },
|
| 100 |
+
})
|
| 101 |
+
setStreaming(false, null)
|
| 102 |
+
}
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
}, [input, isStreaming, mode, messages, sessionId, addMessage, setStreaming, appendChunk, updateMessage])
|
| 106 |
+
|
| 107 |
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
| 108 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 109 |
+
e.preventDefault()
|
| 110 |
+
handleSubmit()
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
const stopStreaming = () => {
|
| 115 |
+
abortRef.current?.abort()
|
| 116 |
+
setStreaming(false, null)
|
| 117 |
+
if (store.streamingMessageId) {
|
| 118 |
+
updateMessage(store.streamingMessageId, { streaming: false, content: store.messages.find(m => m.id === store.streamingMessageId)?.content + ' [stopped]' })
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return (
|
| 123 |
+
<div className="flex flex-col h-full bg-[#0f1017]">
|
| 124 |
+
{/* Header */}
|
| 125 |
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2b3d]">
|
| 126 |
+
<div className="flex items-center gap-2">
|
| 127 |
+
<div className="w-2 h-2 rounded-full bg-terminal-green animate-pulse" />
|
| 128 |
+
<span className="text-sm font-semibold text-slate-200">Agent Chat</span>
|
| 129 |
+
<span className="text-xs text-slate-500 font-mono">{sessionId.slice(0, 12)}...</span>
|
| 130 |
+
</div>
|
| 131 |
+
{/* Mode switcher */}
|
| 132 |
+
<div className="flex bg-[#1a1b26] rounded-lg p-0.5 border border-[#2a2b3d]">
|
| 133 |
+
{(['agent', 'chat'] as const).map((m) => (
|
| 134 |
+
<button
|
| 135 |
+
key={m}
|
| 136 |
+
onClick={() => setMode(m)}
|
| 137 |
+
className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
|
| 138 |
+
mode === m
|
| 139 |
+
? 'bg-brand-500 text-white shadow'
|
| 140 |
+
: 'text-slate-400 hover:text-slate-200'
|
| 141 |
+
}`}
|
| 142 |
+
>
|
| 143 |
+
{m === 'agent' ? 'β‘ Agent' : 'π¬ Chat'}
|
| 144 |
+
</button>
|
| 145 |
+
))}
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Messages */}
|
| 150 |
+
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
|
| 151 |
+
{messages.length === 0 && (
|
| 152 |
+
<div className="flex flex-col items-center justify-center h-full gap-6 py-8">
|
| 153 |
+
<div className="text-center">
|
| 154 |
+
<div className="text-5xl mb-4">π€</div>
|
| 155 |
+
<h2 className="text-xl font-bold text-slate-200 mb-2">Devin Agent</h2>
|
| 156 |
+
<p className="text-sm text-slate-400 max-w-xs">
|
| 157 |
+
Autonomous AI engineering platform. Give me a goal and I'll plan, code, and execute it.
|
| 158 |
+
</p>
|
| 159 |
+
</div>
|
| 160 |
+
<div className="grid grid-cols-2 gap-2 w-full max-w-sm">
|
| 161 |
+
{QUICK_ACTIONS.map(({ icon: Icon, label, prompt }) => (
|
| 162 |
+
<button
|
| 163 |
+
key={label}
|
| 164 |
+
onClick={() => { setInput(prompt); inputRef.current?.focus() }}
|
| 165 |
+
className="flex items-center gap-2 p-3 rounded-xl bg-[#1a1b26] border border-[#2a2b3d] hover:border-brand-500 hover:bg-[#1e2035] transition-all text-left group"
|
| 166 |
+
>
|
| 167 |
+
<Icon size={14} className="text-brand-400 flex-shrink-0" />
|
| 168 |
+
<span className="text-xs text-slate-300 group-hover:text-slate-100">{label}</span>
|
| 169 |
+
</button>
|
| 170 |
+
))}
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
|
| 175 |
+
{messages.map((msg) => (
|
| 176 |
+
<MessageBubble key={msg.id} message={msg} />
|
| 177 |
+
))}
|
| 178 |
+
<div ref={messagesEndRef} />
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
{/* Input */}
|
| 182 |
+
<div className="px-4 pb-4 pt-2 border-t border-[#2a2b3d]">
|
| 183 |
+
<form onSubmit={handleSubmit} className="relative">
|
| 184 |
+
<div className={`relative rounded-xl border transition-all ${
|
| 185 |
+
isStreaming ? 'border-brand-500/50 bg-[#1a1b26]' : 'border-[#2a2b3d] bg-[#1a1b26] hover:border-[#3a3b5a] focus-within:border-brand-500'
|
| 186 |
+
}`}>
|
| 187 |
+
<textarea
|
| 188 |
+
ref={inputRef}
|
| 189 |
+
value={input}
|
| 190 |
+
onChange={(e) => setInput(e.target.value)}
|
| 191 |
+
onKeyDown={handleKeyDown}
|
| 192 |
+
placeholder={
|
| 193 |
+
mode === 'agent'
|
| 194 |
+
? "Give me a goal... (e.g. 'Build a REST API with auth')"
|
| 195 |
+
: "Ask anything... (Shift+Enter for newline)"
|
| 196 |
+
}
|
| 197 |
+
disabled={isStreaming}
|
| 198 |
+
rows={1}
|
| 199 |
+
className="w-full bg-transparent text-slate-200 placeholder-slate-500 text-sm px-4 py-3 pr-12 resize-none outline-none max-h-32 overflow-auto"
|
| 200 |
+
style={{ minHeight: '44px' }}
|
| 201 |
+
/>
|
| 202 |
+
<div className="absolute right-2 bottom-2">
|
| 203 |
+
{isStreaming ? (
|
| 204 |
+
<button
|
| 205 |
+
type="button"
|
| 206 |
+
onClick={stopStreaming}
|
| 207 |
+
className="p-2 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-400 transition-all"
|
| 208 |
+
title="Stop"
|
| 209 |
+
>
|
| 210 |
+
<Square size={14} />
|
| 211 |
+
</button>
|
| 212 |
+
) : (
|
| 213 |
+
<button
|
| 214 |
+
type="submit"
|
| 215 |
+
disabled={!input.trim()}
|
| 216 |
+
className="p-2 rounded-lg bg-brand-500 hover:bg-brand-600 disabled:opacity-30 disabled:cursor-not-allowed text-white transition-all"
|
| 217 |
+
>
|
| 218 |
+
<Send size={14} />
|
| 219 |
+
</button>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
<div className="flex items-center justify-between mt-1.5 px-1">
|
| 224 |
+
<span className="text-[10px] text-slate-600">
|
| 225 |
+
{mode === 'agent' ? 'β‘ Agent mode β creates autonomous tasks' : 'π¬ Chat mode β direct conversation'}
|
| 226 |
+
</span>
|
| 227 |
+
<span className="text-[10px] text-slate-600">Enter to send</span>
|
| 228 |
+
</div>
|
| 229 |
+
</form>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
+
)
|
| 233 |
+
}
|
frontend/components/chat/MessageBubble.tsx
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { Message } from '@/types'
|
| 4 |
+
import ReactMarkdown from 'react-markdown'
|
| 5 |
+
import remarkGfm from 'remark-gfm'
|
| 6 |
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
|
| 7 |
+
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
|
| 8 |
+
import { Copy, Check, Bot, User } from 'lucide-react'
|
| 9 |
+
import { useState, memo } from 'react'
|
| 10 |
+
import { formatDistanceToNow } from 'date-fns'
|
| 11 |
+
|
| 12 |
+
interface Props { message: Message }
|
| 13 |
+
|
| 14 |
+
const CopyButton = ({ text }: { text: string }) => {
|
| 15 |
+
const [copied, setCopied] = useState(false)
|
| 16 |
+
const copy = () => {
|
| 17 |
+
navigator.clipboard.writeText(text)
|
| 18 |
+
setCopied(true)
|
| 19 |
+
setTimeout(() => setCopied(false), 2000)
|
| 20 |
+
}
|
| 21 |
+
return (
|
| 22 |
+
<button onClick={copy} className="absolute top-2 right-2 p-1.5 rounded-md bg-[#2a2b3d] hover:bg-[#3a3b5a] opacity-0 group-hover:opacity-100 transition-all">
|
| 23 |
+
{copied ? <Check size={12} className="text-terminal-green" /> : <Copy size={12} className="text-slate-400" />}
|
| 24 |
+
</button>
|
| 25 |
+
)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const TypingIndicator = () => (
|
| 29 |
+
<div className="flex gap-1 items-center h-5 px-1">
|
| 30 |
+
{[0,1,2].map(i => (
|
| 31 |
+
<div key={i} className="typing-dot" style={{ animationDelay: `${i * 0.2}s` }} />
|
| 32 |
+
))}
|
| 33 |
+
</div>
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
const MessageBubble = memo(({ message }: Props) => {
|
| 37 |
+
const isUser = message.role === 'user'
|
| 38 |
+
const isSystem = message.role === 'system'
|
| 39 |
+
const isStreaming = message.streaming
|
| 40 |
+
const isEmpty = !message.content && isStreaming
|
| 41 |
+
|
| 42 |
+
const time = formatDistanceToNow(new Date(message.timestamp * 1000), { addSuffix: true })
|
| 43 |
+
|
| 44 |
+
if (isSystem) {
|
| 45 |
+
return (
|
| 46 |
+
<div className="flex justify-center my-2">
|
| 47 |
+
<div className="px-3 py-1 rounded-full bg-[#1a1b26] border border-[#2a2b3d] text-xs text-slate-500">
|
| 48 |
+
{message.content}
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<div className={`flex gap-3 py-2 px-1 message-enter ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
|
| 56 |
+
{/* Avatar */}
|
| 57 |
+
<div className={`flex-shrink-0 w-7 h-7 rounded-lg flex items-center justify-center ${
|
| 58 |
+
isUser ? 'bg-brand-500/20 border border-brand-500/30' : 'bg-[#1a1b26] border border-[#2a2b3d]'
|
| 59 |
+
}`}>
|
| 60 |
+
{isUser
|
| 61 |
+
? <User size={14} className="text-brand-400" />
|
| 62 |
+
: <Bot size={14} className="text-terminal-green" />
|
| 63 |
+
}
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
{/* Bubble */}
|
| 67 |
+
<div className={`flex-1 max-w-[85%] ${isUser ? 'items-end flex flex-col' : ''}`}>
|
| 68 |
+
<div className={`rounded-xl px-4 py-3 relative ${
|
| 69 |
+
isUser
|
| 70 |
+
? 'bg-brand-500/15 border border-brand-500/25 text-slate-200'
|
| 71 |
+
: 'bg-[#1a1b26] border border-[#2a2b3d] text-slate-200'
|
| 72 |
+
} ${isStreaming ? 'glow-border' : ''}`}>
|
| 73 |
+
{isEmpty ? (
|
| 74 |
+
<TypingIndicator />
|
| 75 |
+
) : (
|
| 76 |
+
<div className={`prose-dark text-sm leading-relaxed ${isStreaming ? 'cursor-blink' : ''}`}>
|
| 77 |
+
<ReactMarkdown
|
| 78 |
+
remarkPlugins={[remarkGfm]}
|
| 79 |
+
components={{
|
| 80 |
+
code({ node, inline, className, children, ...props }: any) {
|
| 81 |
+
const match = /language-(\w+)/.exec(className || '')
|
| 82 |
+
const code = String(children).replace(/\n$/, '')
|
| 83 |
+
return !inline && match ? (
|
| 84 |
+
<div className="relative group my-2">
|
| 85 |
+
<div className="flex items-center justify-between px-3 py-1.5 bg-[#0a0b10] border border-[#2a2b3d] rounded-t-lg border-b-0">
|
| 86 |
+
<span className="text-[10px] text-slate-500 font-mono uppercase">{match[1]}</span>
|
| 87 |
+
<CopyButton text={code} />
|
| 88 |
+
</div>
|
| 89 |
+
<SyntaxHighlighter
|
| 90 |
+
style={oneDark}
|
| 91 |
+
language={match[1]}
|
| 92 |
+
PreTag="div"
|
| 93 |
+
customStyle={{
|
| 94 |
+
margin: 0,
|
| 95 |
+
borderRadius: '0 0 8px 8px',
|
| 96 |
+
fontSize: '12px',
|
| 97 |
+
background: '#0a0b10',
|
| 98 |
+
border: '1px solid #2a2b3d',
|
| 99 |
+
borderTop: 'none',
|
| 100 |
+
}}
|
| 101 |
+
{...props}
|
| 102 |
+
>
|
| 103 |
+
{code}
|
| 104 |
+
</SyntaxHighlighter>
|
| 105 |
+
</div>
|
| 106 |
+
) : (
|
| 107 |
+
<code className="bg-[#1a1b26] text-purple-300 px-1.5 py-0.5 rounded text-xs font-mono border border-[#2a2b3d]" {...props}>
|
| 108 |
+
{children}
|
| 109 |
+
</code>
|
| 110 |
+
)
|
| 111 |
+
},
|
| 112 |
+
p: ({ children }) => <p className="mb-2 last:mb-0 text-slate-200">{children}</p>,
|
| 113 |
+
ul: ({ children }) => <ul className="list-disc list-inside mb-2 space-y-1 text-slate-300">{children}</ul>,
|
| 114 |
+
ol: ({ children }) => <ol className="list-decimal list-inside mb-2 space-y-1 text-slate-300">{children}</ol>,
|
| 115 |
+
li: ({ children }) => <li className="text-slate-300 text-sm">{children}</li>,
|
| 116 |
+
h1: ({ children }) => <h1 className="text-lg font-bold text-slate-100 mb-2">{children}</h1>,
|
| 117 |
+
h2: ({ children }) => <h2 className="text-base font-semibold text-slate-100 mb-2">{children}</h2>,
|
| 118 |
+
h3: ({ children }) => <h3 className="text-sm font-semibold text-slate-200 mb-1">{children}</h3>,
|
| 119 |
+
blockquote: ({ children }) => (
|
| 120 |
+
<blockquote className="border-l-2 border-brand-500 pl-3 text-slate-400 italic my-2">{children}</blockquote>
|
| 121 |
+
),
|
| 122 |
+
strong: ({ children }) => <strong className="font-semibold text-slate-100">{children}</strong>,
|
| 123 |
+
a: ({ href, children }) => (
|
| 124 |
+
<a href={href} target="_blank" rel="noopener noreferrer" className="text-brand-400 hover:underline">{children}</a>
|
| 125 |
+
),
|
| 126 |
+
table: ({ children }) => (
|
| 127 |
+
<div className="overflow-x-auto my-2">
|
| 128 |
+
<table className="text-xs border-collapse w-full">{children}</table>
|
| 129 |
+
</div>
|
| 130 |
+
),
|
| 131 |
+
th: ({ children }) => <th className="bg-[#1a1b26] px-3 py-1.5 text-left text-slate-300 border border-[#2a2b3d]">{children}</th>,
|
| 132 |
+
td: ({ children }) => <td className="px-3 py-1.5 text-slate-400 border border-[#2a2b3d]">{children}</td>,
|
| 133 |
+
hr: () => <hr className="border-[#2a2b3d] my-3" />,
|
| 134 |
+
}}
|
| 135 |
+
>
|
| 136 |
+
{message.content}
|
| 137 |
+
</ReactMarkdown>
|
| 138 |
+
</div>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
{/* Metadata row */}
|
| 143 |
+
<div className={`flex items-center gap-2 mt-1 px-1 ${isUser ? 'flex-row-reverse' : ''}`}>
|
| 144 |
+
<span className="text-[10px] text-slate-600">{time}</span>
|
| 145 |
+
{message.metadata?.task_id && (
|
| 146 |
+
<span className="text-[10px] font-mono text-brand-400/70">
|
| 147 |
+
{message.metadata.task_id}
|
| 148 |
+
</span>
|
| 149 |
+
)}
|
| 150 |
+
{isStreaming && (
|
| 151 |
+
<span className="text-[10px] text-brand-400 flex items-center gap-1">
|
| 152 |
+
<span className="w-1.5 h-1.5 rounded-full bg-brand-400 animate-pulse" />
|
| 153 |
+
streaming
|
| 154 |
+
</span>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
)
|
| 160 |
+
})
|
| 161 |
+
MessageBubble.displayName = 'MessageBubble'
|
| 162 |
+
export default MessageBubble
|
frontend/components/layout/MemoryPanel.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 4 |
+
import { searchMemory } from '@/lib/api'
|
| 5 |
+
import { useState } from 'react'
|
| 6 |
+
import { Search, Brain, Loader2 } from 'lucide-react'
|
| 7 |
+
import { formatDistanceToNow } from 'date-fns'
|
| 8 |
+
|
| 9 |
+
const TYPE_COLORS: Record<string, string> = {
|
| 10 |
+
conversation: 'text-blue-400 bg-blue-400/10',
|
| 11 |
+
task: 'text-green-400 bg-green-400/10',
|
| 12 |
+
project: 'text-purple-400 bg-purple-400/10',
|
| 13 |
+
execution: 'text-cyan-400 bg-cyan-400/10',
|
| 14 |
+
tool: 'text-yellow-400 bg-yellow-400/10',
|
| 15 |
+
error: 'text-red-400 bg-red-400/10',
|
| 16 |
+
repo: 'text-orange-400 bg-orange-400/10',
|
| 17 |
+
planning: 'text-indigo-400 bg-indigo-400/10',
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function MemoryPanel() {
|
| 21 |
+
const { sessionId } = useAgentStore()
|
| 22 |
+
const [query, setQuery] = useState('')
|
| 23 |
+
const [results, setResults] = useState<any[]>([])
|
| 24 |
+
const [loading, setLoading] = useState(false)
|
| 25 |
+
|
| 26 |
+
const handleSearch = async (e: React.FormEvent) => {
|
| 27 |
+
e.preventDefault()
|
| 28 |
+
if (!query.trim()) return
|
| 29 |
+
setLoading(true)
|
| 30 |
+
try {
|
| 31 |
+
const data = await searchMemory(query, sessionId)
|
| 32 |
+
setResults(data.results || [])
|
| 33 |
+
} catch {
|
| 34 |
+
setResults([])
|
| 35 |
+
} finally {
|
| 36 |
+
setLoading(false)
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="flex flex-col h-full bg-[#0f1017]">
|
| 42 |
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2b3d]">
|
| 43 |
+
<div className="flex items-center gap-2">
|
| 44 |
+
<Brain size={14} className="text-brand-400" />
|
| 45 |
+
<span className="text-sm font-semibold text-slate-200">Agent Memory</span>
|
| 46 |
+
</div>
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
{/* Search */}
|
| 50 |
+
<div className="px-4 py-3 border-b border-[#2a2b3d]">
|
| 51 |
+
<form onSubmit={handleSearch} className="flex gap-2">
|
| 52 |
+
<div className="flex-1 relative">
|
| 53 |
+
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
| 54 |
+
<input
|
| 55 |
+
value={query}
|
| 56 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 57 |
+
placeholder="Search memories..."
|
| 58 |
+
className="w-full bg-[#1a1b26] border border-[#2a2b3d] text-slate-200 text-xs rounded-lg pl-7 pr-3 py-2 outline-none focus:border-brand-500 placeholder-slate-600"
|
| 59 |
+
/>
|
| 60 |
+
</div>
|
| 61 |
+
<button
|
| 62 |
+
type="submit"
|
| 63 |
+
disabled={loading}
|
| 64 |
+
className="px-3 py-2 bg-brand-500 hover:bg-brand-600 disabled:opacity-50 text-white text-xs rounded-lg transition-all flex items-center gap-1"
|
| 65 |
+
>
|
| 66 |
+
{loading ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} />}
|
| 67 |
+
</button>
|
| 68 |
+
</form>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{/* Results */}
|
| 72 |
+
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
| 73 |
+
{results.length === 0 && (
|
| 74 |
+
<div className="flex flex-col items-center justify-center h-full text-center">
|
| 75 |
+
<Brain size={32} className="text-slate-700 mb-3" />
|
| 76 |
+
<p className="text-sm text-slate-500">Search agent memories</p>
|
| 77 |
+
<p className="text-xs text-slate-600 mt-1">Tasks, conversations, code, plans</p>
|
| 78 |
+
</div>
|
| 79 |
+
)}
|
| 80 |
+
{results.map((mem) => {
|
| 81 |
+
const typeStyle = TYPE_COLORS[mem.memory_type] || 'text-slate-400 bg-slate-400/10'
|
| 82 |
+
const time = formatDistanceToNow(new Date(mem.created_at * 1000), { addSuffix: true })
|
| 83 |
+
return (
|
| 84 |
+
<div key={mem.id} className="rounded-lg border border-[#2a2b3d] bg-[#13141c] p-3">
|
| 85 |
+
<div className="flex items-center justify-between mb-1.5">
|
| 86 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${typeStyle}`}>
|
| 87 |
+
{mem.memory_type}
|
| 88 |
+
</span>
|
| 89 |
+
<span className="text-[10px] text-slate-600">{time}</span>
|
| 90 |
+
</div>
|
| 91 |
+
<p className="text-[11px] text-slate-300 leading-relaxed line-clamp-3">
|
| 92 |
+
{mem.content}
|
| 93 |
+
</p>
|
| 94 |
+
{mem.key && (
|
| 95 |
+
<p className="text-[10px] text-slate-600 font-mono mt-1">{mem.key}</p>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
)
|
| 99 |
+
})}
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
)
|
| 103 |
+
}
|
frontend/components/layout/Sidebar.tsx
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 4 |
+
import { Task } from '@/types'
|
| 5 |
+
import { formatDistanceToNow } from 'date-fns'
|
| 6 |
+
import {
|
| 7 |
+
MessageSquare, Clock, ListTodo, Brain, Settings,
|
| 8 |
+
Plus, ChevronLeft, ChevronRight, Circle, Wifi, WifiOff,
|
| 9 |
+
RefreshCcw, Trash2
|
| 10 |
+
} from 'lucide-react'
|
| 11 |
+
import { retryTask, cancelTask } from '@/lib/api'
|
| 12 |
+
|
| 13 |
+
const STATUS_DOT: Record<string, string> = {
|
| 14 |
+
queued: 'bg-slate-500',
|
| 15 |
+
initializing: 'bg-blue-400 animate-pulse',
|
| 16 |
+
planning: 'bg-purple-400 animate-pulse',
|
| 17 |
+
executing: 'bg-blue-400 animate-pulse',
|
| 18 |
+
streaming: 'bg-cyan-400 animate-pulse',
|
| 19 |
+
retrying: 'bg-yellow-400 animate-pulse',
|
| 20 |
+
completed: 'bg-green-400',
|
| 21 |
+
failed: 'bg-red-400',
|
| 22 |
+
cancelled: 'bg-slate-600',
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function TaskItem({ task }: { task: Task }) {
|
| 26 |
+
const store = useAgentStore()
|
| 27 |
+
const isActive = store.activeTaskId === task.id
|
| 28 |
+
const time = formatDistanceToNow(new Date(task.created_at * 1000), { addSuffix: true })
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<button
|
| 32 |
+
onClick={() => store.setActiveTask(task.id)}
|
| 33 |
+
className={`w-full text-left px-3 py-2.5 rounded-lg transition-all group ${
|
| 34 |
+
isActive
|
| 35 |
+
? 'bg-brand-500/15 border border-brand-500/30'
|
| 36 |
+
: 'hover:bg-[#1a1b26] border border-transparent'
|
| 37 |
+
}`}
|
| 38 |
+
>
|
| 39 |
+
<div className="flex items-start gap-2">
|
| 40 |
+
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 flex-shrink-0 ${STATUS_DOT[task.status] || 'bg-slate-500'}`} />
|
| 41 |
+
<div className="flex-1 min-w-0">
|
| 42 |
+
<p className="text-[11px] text-slate-300 truncate leading-relaxed">{task.goal.slice(0, 60)}</p>
|
| 43 |
+
<div className="flex items-center gap-1.5 mt-0.5">
|
| 44 |
+
<span className="text-[10px] text-slate-600">{time}</span>
|
| 45 |
+
{task.retry_count > 0 && (
|
| 46 |
+
<span className="text-[10px] text-yellow-500">β»{task.retry_count}</span>
|
| 47 |
+
)}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
{task.status === 'failed' && (
|
| 51 |
+
<button
|
| 52 |
+
onClick={(e) => { e.stopPropagation(); retryTask(task.id) }}
|
| 53 |
+
className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-[#2a2b3d] transition-all"
|
| 54 |
+
title="Retry"
|
| 55 |
+
>
|
| 56 |
+
<RefreshCcw size={10} className="text-yellow-400" />
|
| 57 |
+
</button>
|
| 58 |
+
)}
|
| 59 |
+
</div>
|
| 60 |
+
</button>
|
| 61 |
+
)
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export default function Sidebar() {
|
| 65 |
+
const store = useAgentStore()
|
| 66 |
+
const { sidebarOpen, setSidebarOpen, activePanel, setActivePanel, tasks, wsConnected, wsRetries, clearMessages, clearTimeline } = store
|
| 67 |
+
|
| 68 |
+
const NAV_ITEMS = [
|
| 69 |
+
{ id: 'chat' as const, icon: MessageSquare, label: 'Chat' },
|
| 70 |
+
{ id: 'timeline' as const, icon: Clock, label: 'Timeline' },
|
| 71 |
+
{ id: 'tasks' as const, icon: ListTodo, label: 'Tasks' },
|
| 72 |
+
{ id: 'memory' as const, icon: Brain, label: 'Memory' },
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
const runningTasks = tasks.filter(t => ['executing', 'planning', 'retrying'].includes(t.status))
|
| 76 |
+
const recentTasks = tasks.slice(0, 15)
|
| 77 |
+
|
| 78 |
+
return (
|
| 79 |
+
<>
|
| 80 |
+
{/* Collapsed sidebar β icon rail */}
|
| 81 |
+
<div className={`flex flex-col h-full bg-[#0c0d12] border-r border-[#2a2b3d] transition-all duration-200 ${sidebarOpen ? 'w-60' : 'w-12'}`}>
|
| 82 |
+
{/* Logo + Toggle */}
|
| 83 |
+
<div className="flex items-center justify-between px-3 py-3 border-b border-[#2a2b3d]">
|
| 84 |
+
{sidebarOpen && (
|
| 85 |
+
<div className="flex items-center gap-2">
|
| 86 |
+
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-brand-500 to-blue-500 flex items-center justify-center text-[10px] font-bold text-white shadow-lg">
|
| 87 |
+
D
|
| 88 |
+
</div>
|
| 89 |
+
<div>
|
| 90 |
+
<div className="text-xs font-bold text-slate-200">Devin Agent</div>
|
| 91 |
+
<div className="text-[9px] text-slate-600">v2.0 Production</div>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
)}
|
| 95 |
+
<button
|
| 96 |
+
onClick={() => setSidebarOpen(!sidebarOpen)}
|
| 97 |
+
className="p-1 rounded hover:bg-[#1a1b26] text-slate-500 hover:text-slate-300 transition-all ml-auto"
|
| 98 |
+
>
|
| 99 |
+
{sidebarOpen ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
|
| 100 |
+
</button>
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
{/* Nav items */}
|
| 104 |
+
<nav className="flex flex-col gap-1 px-2 py-3">
|
| 105 |
+
{NAV_ITEMS.map(({ id, icon: Icon, label }) => (
|
| 106 |
+
<button
|
| 107 |
+
key={id}
|
| 108 |
+
onClick={() => setActivePanel(id)}
|
| 109 |
+
title={!sidebarOpen ? label : undefined}
|
| 110 |
+
className={`flex items-center gap-2.5 px-2 py-2 rounded-lg transition-all ${
|
| 111 |
+
activePanel === id
|
| 112 |
+
? 'bg-brand-500/15 text-brand-400 border border-brand-500/30'
|
| 113 |
+
: 'text-slate-500 hover:text-slate-300 hover:bg-[#1a1b26]'
|
| 114 |
+
}`}
|
| 115 |
+
>
|
| 116 |
+
<Icon size={14} className="flex-shrink-0" />
|
| 117 |
+
{sidebarOpen && <span className="text-xs font-medium">{label}</span>}
|
| 118 |
+
</button>
|
| 119 |
+
))}
|
| 120 |
+
</nav>
|
| 121 |
+
|
| 122 |
+
{sidebarOpen && (
|
| 123 |
+
<>
|
| 124 |
+
<div className="px-3 mb-2">
|
| 125 |
+
<div className="h-px bg-[#2a2b3d]" />
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
{/* Active tasks */}
|
| 129 |
+
{runningTasks.length > 0 && (
|
| 130 |
+
<div className="px-3 mb-3">
|
| 131 |
+
<div className="flex items-center gap-1.5 mb-2">
|
| 132 |
+
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
| 133 |
+
<span className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide">
|
| 134 |
+
Running ({runningTasks.length})
|
| 135 |
+
</span>
|
| 136 |
+
</div>
|
| 137 |
+
<div className="space-y-1">
|
| 138 |
+
{runningTasks.map(t => <TaskItem key={t.id} task={t} />)}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
)}
|
| 142 |
+
|
| 143 |
+
{/* Recent tasks */}
|
| 144 |
+
{recentTasks.length > 0 && (
|
| 145 |
+
<div className="px-3 flex-1 overflow-y-auto">
|
| 146 |
+
<div className="flex items-center justify-between mb-2">
|
| 147 |
+
<span className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide">
|
| 148 |
+
Recent Tasks
|
| 149 |
+
</span>
|
| 150 |
+
<span className="text-[10px] text-slate-600">{recentTasks.length}</span>
|
| 151 |
+
</div>
|
| 152 |
+
<div className="space-y-1">
|
| 153 |
+
{recentTasks.map(t => <TaskItem key={t.id} task={t} />)}
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
</>
|
| 158 |
+
)}
|
| 159 |
+
|
| 160 |
+
{/* Connection status + actions */}
|
| 161 |
+
<div className={`mt-auto border-t border-[#2a2b3d] px-2 py-2 ${sidebarOpen ? '' : 'flex flex-col items-center gap-2'}`}>
|
| 162 |
+
{sidebarOpen ? (
|
| 163 |
+
<div className="flex items-center justify-between">
|
| 164 |
+
<div className="flex items-center gap-1.5">
|
| 165 |
+
{wsConnected
|
| 166 |
+
? <Wifi size={11} className="text-terminal-green" />
|
| 167 |
+
: <WifiOff size={11} className="text-red-400" />
|
| 168 |
+
}
|
| 169 |
+
<span className={`text-[10px] ${wsConnected ? 'text-terminal-green' : 'text-red-400'}`}>
|
| 170 |
+
{wsConnected ? 'Connected' : `Reconnecting${wsRetries > 0 ? ` (${wsRetries})` : ''}`}
|
| 171 |
+
</span>
|
| 172 |
+
</div>
|
| 173 |
+
<button
|
| 174 |
+
onClick={() => { clearMessages(); clearTimeline() }}
|
| 175 |
+
title="Clear session"
|
| 176 |
+
className="p-1 rounded hover:bg-[#1a1b26] text-slate-600 hover:text-slate-400 transition-all"
|
| 177 |
+
>
|
| 178 |
+
<Trash2 size={11} />
|
| 179 |
+
</button>
|
| 180 |
+
</div>
|
| 181 |
+
) : (
|
| 182 |
+
<div title={wsConnected ? 'Connected' : 'Disconnected'}>
|
| 183 |
+
{wsConnected
|
| 184 |
+
? <Wifi size={12} className="text-terminal-green" />
|
| 185 |
+
: <WifiOff size={12} className="text-red-400" />
|
| 186 |
+
}
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
</>
|
| 192 |
+
)
|
| 193 |
+
}
|
frontend/components/layout/TasksPanel.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 4 |
+
import { Task } from '@/types'
|
| 5 |
+
import { formatDistanceToNow } from 'date-fns'
|
| 6 |
+
import { RefreshCcw, XCircle, ChevronDown, ChevronUp, Terminal } from 'lucide-react'
|
| 7 |
+
import { useState } from 'react'
|
| 8 |
+
import { retryTask, cancelTask } from '@/lib/api'
|
| 9 |
+
|
| 10 |
+
const STATUS_BADGE: Record<string, string> = {
|
| 11 |
+
queued: 'bg-slate-800 text-slate-400 border-slate-700',
|
| 12 |
+
initializing: 'bg-blue-900/30 text-blue-400 border-blue-700/30',
|
| 13 |
+
planning: 'bg-purple-900/30 text-purple-400 border-purple-700/30',
|
| 14 |
+
executing: 'bg-cyan-900/30 text-cyan-400 border-cyan-700/30',
|
| 15 |
+
retrying: 'bg-yellow-900/30 text-yellow-400 border-yellow-700/30',
|
| 16 |
+
completed: 'bg-green-900/30 text-green-400 border-green-700/30',
|
| 17 |
+
failed: 'bg-red-900/30 text-red-400 border-red-700/30',
|
| 18 |
+
cancelled: 'bg-slate-800 text-slate-500 border-slate-700',
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function TaskCard({ task }: { task: Task }) {
|
| 22 |
+
const [expanded, setExpanded] = useState(false)
|
| 23 |
+
const store = useAgentStore()
|
| 24 |
+
const isActive = store.activeTaskId === task.id
|
| 25 |
+
const time = formatDistanceToNow(new Date(task.created_at * 1000), { addSuffix: true })
|
| 26 |
+
const duration = task.completed_at && task.started_at
|
| 27 |
+
? `${Math.round(task.completed_at - task.started_at)}s`
|
| 28 |
+
: null
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className={`rounded-xl border transition-all ${
|
| 32 |
+
isActive ? 'border-brand-500/40 bg-[#1a1f3a]' : 'border-[#2a2b3d] bg-[#13141c] hover:border-[#3a3b5a]'
|
| 33 |
+
}`}>
|
| 34 |
+
<div
|
| 35 |
+
className="p-3 cursor-pointer"
|
| 36 |
+
onClick={() => { store.setActiveTask(task.id); setExpanded(!expanded) }}
|
| 37 |
+
>
|
| 38 |
+
<div className="flex items-start justify-between gap-2">
|
| 39 |
+
<div className="flex-1 min-w-0">
|
| 40 |
+
<p className="text-xs text-slate-200 leading-relaxed line-clamp-2">{task.goal}</p>
|
| 41 |
+
<div className="flex items-center gap-2 mt-1.5">
|
| 42 |
+
<span className={`text-[10px] px-1.5 py-0.5 rounded border font-medium ${STATUS_BADGE[task.status] || STATUS_BADGE.queued}`}>
|
| 43 |
+
{task.status}
|
| 44 |
+
</span>
|
| 45 |
+
<span className="text-[10px] text-slate-600 font-mono">{task.id.slice(0, 14)}</span>
|
| 46 |
+
{duration && <span className="text-[10px] text-slate-600">β± {duration}</span>}
|
| 47 |
+
{task.retry_count > 0 && (
|
| 48 |
+
<span className="text-[10px] text-yellow-500">β» {task.retry_count}</span>
|
| 49 |
+
)}
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
<div className="flex items-center gap-1 flex-shrink-0">
|
| 53 |
+
{task.status === 'failed' && (
|
| 54 |
+
<button
|
| 55 |
+
onClick={(e) => { e.stopPropagation(); retryTask(task.id) }}
|
| 56 |
+
className="p-1.5 rounded-lg bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 transition-all"
|
| 57 |
+
title="Retry"
|
| 58 |
+
>
|
| 59 |
+
<RefreshCcw size={11} />
|
| 60 |
+
</button>
|
| 61 |
+
)}
|
| 62 |
+
{['queued','executing','planning'].includes(task.status) && (
|
| 63 |
+
<button
|
| 64 |
+
onClick={(e) => { e.stopPropagation(); cancelTask(task.id) }}
|
| 65 |
+
className="p-1.5 rounded-lg bg-red-500/10 hover:bg-red-500/20 text-red-400 transition-all"
|
| 66 |
+
title="Cancel"
|
| 67 |
+
>
|
| 68 |
+
<XCircle size={11} />
|
| 69 |
+
</button>
|
| 70 |
+
)}
|
| 71 |
+
{expanded ? <ChevronUp size={12} className="text-slate-500" /> : <ChevronDown size={12} className="text-slate-500" />}
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{expanded && (
|
| 77 |
+
<div className="px-3 pb-3 border-t border-[#2a2b3d] pt-2.5 space-y-2">
|
| 78 |
+
{/* Plan steps */}
|
| 79 |
+
{task.plan?.steps && task.plan.steps.length > 0 && (
|
| 80 |
+
<div>
|
| 81 |
+
<p className="text-[10px] text-slate-500 font-semibold mb-1.5">π Plan ({task.plan.steps.length} steps)</p>
|
| 82 |
+
<div className="space-y-1">
|
| 83 |
+
{task.plan.steps.map((step, i) => (
|
| 84 |
+
<div key={step.id} className="flex items-center gap-2 text-[11px]">
|
| 85 |
+
<span className="text-slate-600 font-mono w-4">{i+1}.</span>
|
| 86 |
+
<span className={
|
| 87 |
+
step.status === 'completed' ? 'text-terminal-green' :
|
| 88 |
+
step.status === 'running' ? 'text-blue-400' :
|
| 89 |
+
step.status === 'failed' ? 'text-red-400' :
|
| 90 |
+
'text-slate-500'
|
| 91 |
+
}>{step.name}</span>
|
| 92 |
+
{step.tool && <span className="text-[9px] text-slate-600 bg-[#1a1b26] px-1 rounded">{step.tool}</span>}
|
| 93 |
+
</div>
|
| 94 |
+
))}
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
|
| 99 |
+
{/* Result */}
|
| 100 |
+
{task.result && (
|
| 101 |
+
<div>
|
| 102 |
+
<p className="text-[10px] text-slate-500 font-semibold mb-1">β
Result</p>
|
| 103 |
+
<div className="terminal p-2 text-[11px] text-terminal-green max-h-24 overflow-y-auto">
|
| 104 |
+
{task.result.slice(0, 500)}
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
)}
|
| 108 |
+
|
| 109 |
+
{/* Error */}
|
| 110 |
+
{task.error && (
|
| 111 |
+
<div>
|
| 112 |
+
<p className="text-[10px] text-slate-500 font-semibold mb-1">β Error</p>
|
| 113 |
+
<div className="terminal p-2 text-[11px] text-red-400 max-h-24 overflow-y-auto">
|
| 114 |
+
{task.error}
|
| 115 |
+
</div>
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
|
| 119 |
+
<div className="flex items-center gap-3 text-[10px] text-slate-600 pt-1">
|
| 120 |
+
<span>Created {time}</span>
|
| 121 |
+
{task.session_id && <span className="font-mono">sess: {task.session_id.slice(0,10)}</span>}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
</div>
|
| 126 |
+
)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
export default function TasksPanel() {
|
| 130 |
+
const { tasks } = useAgentStore()
|
| 131 |
+
const active = tasks.filter(t => ['queued','initializing','planning','executing','retrying'].includes(t.status))
|
| 132 |
+
const done = tasks.filter(t => ['completed','failed','cancelled'].includes(t.status))
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="flex flex-col h-full bg-[#0f1017]">
|
| 136 |
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2b3d]">
|
| 137 |
+
<span className="text-sm font-semibold text-slate-200">Task Manager</span>
|
| 138 |
+
<span className="text-[10px] text-slate-600">{tasks.length} total</span>
|
| 139 |
+
</div>
|
| 140 |
+
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
| 141 |
+
{tasks.length === 0 ? (
|
| 142 |
+
<div className="flex flex-col items-center justify-center h-full text-center">
|
| 143 |
+
<Terminal size={32} className="text-slate-700 mb-3" />
|
| 144 |
+
<p className="text-sm text-slate-500">No tasks yet</p>
|
| 145 |
+
<p className="text-xs text-slate-600 mt-1">Submit a goal to create tasks</p>
|
| 146 |
+
</div>
|
| 147 |
+
) : (
|
| 148 |
+
<>
|
| 149 |
+
{active.length > 0 && (
|
| 150 |
+
<div>
|
| 151 |
+
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
|
| 152 |
+
Active ({active.length})
|
| 153 |
+
</p>
|
| 154 |
+
<div className="space-y-2">{active.map(t => <TaskCard key={t.id} task={t} />)}</div>
|
| 155 |
+
</div>
|
| 156 |
+
)}
|
| 157 |
+
{done.length > 0 && (
|
| 158 |
+
<div>
|
| 159 |
+
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1 mt-3">
|
| 160 |
+
Completed ({done.length})
|
| 161 |
+
</p>
|
| 162 |
+
<div className="space-y-2">{done.map(t => <TaskCard key={t.id} task={t} />)}</div>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
</>
|
| 166 |
+
)}
|
| 167 |
+
</div>
|
| 168 |
+
</div>
|
| 169 |
+
)
|
| 170 |
+
}
|
frontend/components/layout/TopBar.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 4 |
+
import { useEffect, useState } from 'react'
|
| 5 |
+
import { getHealth } from '@/lib/api'
|
| 6 |
+
import { Activity, Cpu, MemoryStick, Wifi, WifiOff, Github, ExternalLink } from 'lucide-react'
|
| 7 |
+
|
| 8 |
+
export default function TopBar() {
|
| 9 |
+
const { wsConnected, backendHealth, setBackendHealth, sessionId } = useAgentStore()
|
| 10 |
+
const [metrics, setMetrics] = useState<any>(null)
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
const fetchHealth = async () => {
|
| 14 |
+
const h = await getHealth()
|
| 15 |
+
setBackendHealth(h)
|
| 16 |
+
}
|
| 17 |
+
fetchHealth()
|
| 18 |
+
const interval = setInterval(fetchHealth, 30000)
|
| 19 |
+
return () => clearInterval(interval)
|
| 20 |
+
}, [])
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<header className="h-10 bg-[#0c0d12] border-b border-[#2a2b3d] flex items-center px-4 gap-4 flex-shrink-0">
|
| 24 |
+
{/* Brand */}
|
| 25 |
+
<div className="flex items-center gap-2">
|
| 26 |
+
<div className="w-5 h-5 rounded bg-gradient-to-br from-brand-500 to-blue-500 flex items-center justify-center text-[9px] font-bold text-white">D</div>
|
| 27 |
+
<span className="text-xs font-semibold text-slate-300 hidden sm:block">Devin Agent Platform</span>
|
| 28 |
+
<span className="text-[10px] text-slate-600 hidden md:block">v2.0</span>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<div className="h-4 w-px bg-[#2a2b3d]" />
|
| 32 |
+
|
| 33 |
+
{/* Backend Status */}
|
| 34 |
+
<div className="flex items-center gap-3">
|
| 35 |
+
<div className="flex items-center gap-1.5">
|
| 36 |
+
{backendHealth ? (
|
| 37 |
+
<div className="w-1.5 h-1.5 rounded-full bg-terminal-green animate-pulse" />
|
| 38 |
+
) : (
|
| 39 |
+
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
| 40 |
+
)}
|
| 41 |
+
<span className={`text-[10px] ${backendHealth ? 'text-terminal-green' : 'text-red-400'}`}>
|
| 42 |
+
{backendHealth ? 'API Online' : 'API Offline'}
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{/* WS Status */}
|
| 47 |
+
<div className="flex items-center gap-1">
|
| 48 |
+
{wsConnected
|
| 49 |
+
? <Wifi size={10} className="text-blue-400" />
|
| 50 |
+
: <WifiOff size={10} className="text-slate-500" />
|
| 51 |
+
}
|
| 52 |
+
<span className={`text-[10px] ${wsConnected ? 'text-blue-400' : 'text-slate-500'}`}>
|
| 53 |
+
{wsConnected ? 'WS Live' : 'WS Off'}
|
| 54 |
+
</span>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
{/* LLM status */}
|
| 58 |
+
{backendHealth?.llm && (
|
| 59 |
+
<div className="items-center gap-1 hidden md:flex">
|
| 60 |
+
<div className={`w-1.5 h-1.5 rounded-full ${backendHealth.llm.openai || backendHealth.llm.anthropic ? 'bg-purple-400' : 'bg-slate-600'}`} />
|
| 61 |
+
<span className={`text-[10px] ${backendHealth.llm.openai || backendHealth.llm.anthropic ? 'text-purple-400' : 'text-slate-500'}`}>
|
| 62 |
+
{backendHealth.llm.openai ? 'GPT-4' : backendHealth.llm.anthropic ? 'Claude' : 'Demo Mode'}
|
| 63 |
+
</span>
|
| 64 |
+
</div>
|
| 65 |
+
)}
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
{/* Spacer */}
|
| 69 |
+
<div className="flex-1" />
|
| 70 |
+
|
| 71 |
+
{/* Session ID */}
|
| 72 |
+
<div className="hidden lg:flex items-center gap-1.5 bg-[#1a1b26] px-2 py-1 rounded border border-[#2a2b3d]">
|
| 73 |
+
<span className="text-[10px] text-slate-600">Session:</span>
|
| 74 |
+
<span className="text-[10px] font-mono text-slate-400">{sessionId.slice(0, 14)}</span>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
{/* GitHub */}
|
| 78 |
+
{backendHealth?.github && (
|
| 79 |
+
<div className="flex items-center gap-1 text-slate-400">
|
| 80 |
+
<Github size={12} />
|
| 81 |
+
<span className="text-[10px] text-terminal-green hidden sm:block">GitHub</span>
|
| 82 |
+
</div>
|
| 83 |
+
)}
|
| 84 |
+
|
| 85 |
+
{/* Docs link */}
|
| 86 |
+
<a
|
| 87 |
+
href={`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:7860'}/api/docs`}
|
| 88 |
+
target="_blank"
|
| 89 |
+
rel="noopener noreferrer"
|
| 90 |
+
className="flex items-center gap-1 text-slate-500 hover:text-slate-300 transition-colors"
|
| 91 |
+
title="API Docs"
|
| 92 |
+
>
|
| 93 |
+
<ExternalLink size={11} />
|
| 94 |
+
<span className="text-[10px] hidden sm:block">API</span>
|
| 95 |
+
</a>
|
| 96 |
+
</header>
|
| 97 |
+
)
|
| 98 |
+
}
|
frontend/components/timeline/ExecutionTimeline.tsx
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useAgentStore } from '@/hooks/useAgentStore'
|
| 4 |
+
import { TimelineEvent, TaskStep } from '@/types'
|
| 5 |
+
import { formatDistanceToNow } from 'date-fns'
|
| 6 |
+
import { memo, useState } from 'react'
|
| 7 |
+
import {
|
| 8 |
+
CheckCircle, XCircle, Clock, Loader2, AlertTriangle,
|
| 9 |
+
ChevronDown, ChevronRight, Code2, Terminal, GitBranch,
|
| 10 |
+
Brain, Search, TestTube, Globe, Zap, Database
|
| 11 |
+
} from 'lucide-react'
|
| 12 |
+
|
| 13 |
+
const TOOL_ICONS: Record<string, any> = {
|
| 14 |
+
code: Code2, shell: Terminal, github: GitBranch, memory: Brain,
|
| 15 |
+
search: Search, test: TestTube, browser: Globe, file: Database,
|
| 16 |
+
none: Zap,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const STATUS_STYLES: Record<string, string> = {
|
| 20 |
+
running: 'text-blue-400 bg-blue-400/10 border-blue-400/30',
|
| 21 |
+
completed: 'text-green-400 bg-green-400/10 border-green-400/30',
|
| 22 |
+
failed: 'text-red-400 bg-red-400/10 border-red-400/30',
|
| 23 |
+
warning: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
| 24 |
+
pending: 'text-slate-500 bg-slate-500/10 border-slate-500/30',
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const StatusIcon = ({ status }: { status: string }) => {
|
| 28 |
+
if (status === 'running') return <Loader2 size={12} className="text-blue-400 animate-spin" />
|
| 29 |
+
if (status === 'completed') return <CheckCircle size={12} className="text-green-400" />
|
| 30 |
+
if (status === 'failed') return <XCircle size={12} className="text-red-400" />
|
| 31 |
+
if (status === 'warning') return <AlertTriangle size={12} className="text-yellow-400" />
|
| 32 |
+
return <Clock size={12} className="text-slate-500" />
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const TimelineItem = memo(({ event, isLast }: { event: TimelineEvent; isLast: boolean }) => {
|
| 36 |
+
const [expanded, setExpanded] = useState(false)
|
| 37 |
+
const ToolIcon = event.tool ? (TOOL_ICONS[event.tool] || Zap) : null
|
| 38 |
+
const hasData = event.data && Object.keys(event.data).length > 0
|
| 39 |
+
const time = formatDistanceToNow(new Date(event.timestamp * 1000), { addSuffix: true })
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="flex gap-3 group">
|
| 43 |
+
{/* Connector line */}
|
| 44 |
+
<div className="flex flex-col items-center flex-shrink-0">
|
| 45 |
+
<div className={`w-6 h-6 rounded-full flex items-center justify-center border flex-shrink-0 ${STATUS_STYLES[event.status] || STATUS_STYLES.pending}`}>
|
| 46 |
+
<StatusIcon status={event.status} />
|
| 47 |
+
</div>
|
| 48 |
+
{!isLast && <div className="w-px flex-1 bg-[#2a2b3d] mt-1 min-h-[16px]" />}
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
{/* Content */}
|
| 52 |
+
<div className="pb-3 flex-1 min-w-0">
|
| 53 |
+
<div
|
| 54 |
+
className={`rounded-lg p-2.5 border transition-all ${
|
| 55 |
+
event.status === 'running'
|
| 56 |
+
? 'bg-[#1a1f3a] border-blue-500/30'
|
| 57 |
+
: 'bg-[#13141c] border-[#2a2b3d] hover:border-[#3a3b5a]'
|
| 58 |
+
} ${hasData ? 'cursor-pointer' : ''}`}
|
| 59 |
+
onClick={() => hasData && setExpanded(!expanded)}
|
| 60 |
+
>
|
| 61 |
+
<div className="flex items-start justify-between gap-2">
|
| 62 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 63 |
+
{ToolIcon && (
|
| 64 |
+
<span className="flex-shrink-0 p-1 rounded bg-[#1a1b26]">
|
| 65 |
+
<ToolIcon size={10} className="text-brand-400" />
|
| 66 |
+
</span>
|
| 67 |
+
)}
|
| 68 |
+
<span className="text-xs font-medium text-slate-200 truncate">{event.label}</span>
|
| 69 |
+
</div>
|
| 70 |
+
<div className="flex items-center gap-1.5 flex-shrink-0">
|
| 71 |
+
<span className="text-[10px] text-slate-600">{time}</span>
|
| 72 |
+
{hasData && (
|
| 73 |
+
expanded
|
| 74 |
+
? <ChevronDown size={10} className="text-slate-500" />
|
| 75 |
+
: <ChevronRight size={10} className="text-slate-500" />
|
| 76 |
+
)}
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{event.description && (
|
| 81 |
+
<p className="text-[11px] text-slate-500 mt-1 truncate">{event.description}</p>
|
| 82 |
+
)}
|
| 83 |
+
|
| 84 |
+
{/* Expanded data */}
|
| 85 |
+
{expanded && hasData && (
|
| 86 |
+
<div className="mt-2 pt-2 border-t border-[#2a2b3d]">
|
| 87 |
+
<pre className="text-[10px] text-slate-400 font-mono overflow-auto max-h-32 whitespace-pre-wrap break-all">
|
| 88 |
+
{JSON.stringify(event.data, null, 2)}
|
| 89 |
+
</pre>
|
| 90 |
+
</div>
|
| 91 |
+
)}
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
)
|
| 96 |
+
})
|
| 97 |
+
TimelineItem.displayName = 'TimelineItem'
|
| 98 |
+
|
| 99 |
+
// βββ Step Progress Bar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
|
| 101 |
+
const StepProgress = ({ steps }: { steps: TaskStep[] }) => {
|
| 102 |
+
if (!steps.length) return null
|
| 103 |
+
const completed = steps.filter(s => s.status === 'completed').length
|
| 104 |
+
const percent = Math.round((completed / steps.length) * 100)
|
| 105 |
+
|
| 106 |
+
return (
|
| 107 |
+
<div className="px-4 py-3 border-b border-[#2a2b3d] bg-[#13141c]">
|
| 108 |
+
<div className="flex items-center justify-between mb-1.5">
|
| 109 |
+
<span className="text-[11px] font-medium text-slate-400">Execution Progress</span>
|
| 110 |
+
<span className="text-[11px] font-mono text-brand-400">{completed}/{steps.length} steps</span>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="h-1.5 bg-[#2a2b3d] rounded-full overflow-hidden">
|
| 113 |
+
<div
|
| 114 |
+
className="h-full bg-gradient-to-r from-brand-500 to-blue-400 rounded-full transition-all duration-500"
|
| 115 |
+
style={{ width: `${percent}%` }}
|
| 116 |
+
/>
|
| 117 |
+
</div>
|
| 118 |
+
<div className="mt-2 flex flex-wrap gap-1">
|
| 119 |
+
{steps.map((step) => (
|
| 120 |
+
<div
|
| 121 |
+
key={step.id}
|
| 122 |
+
title={step.name}
|
| 123 |
+
className={`h-1.5 rounded-full flex-1 min-w-[8px] max-w-[32px] transition-all ${
|
| 124 |
+
step.status === 'completed' ? 'bg-terminal-green' :
|
| 125 |
+
step.status === 'running' ? 'bg-blue-400 animate-pulse' :
|
| 126 |
+
step.status === 'failed' ? 'bg-red-400' :
|
| 127 |
+
'bg-[#2a2b3d]'
|
| 128 |
+
}`}
|
| 129 |
+
/>
|
| 130 |
+
))}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// βββ Main Timeline Component ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 137 |
+
|
| 138 |
+
export default function ExecutionTimeline() {
|
| 139 |
+
const { timeline, activeSteps, activeTaskId, tasks, clearTimeline } = useAgentStore()
|
| 140 |
+
const activeTask = tasks.find(t => t.id === activeTaskId)
|
| 141 |
+
|
| 142 |
+
const getStatusBadge = (status: string) => {
|
| 143 |
+
const styles: Record<string, string> = {
|
| 144 |
+
queued: 'status-queued',
|
| 145 |
+
planning: 'status-planning',
|
| 146 |
+
executing: 'status-executing',
|
| 147 |
+
completed: 'status-completed',
|
| 148 |
+
failed: 'status-failed',
|
| 149 |
+
retrying: 'status-retrying',
|
| 150 |
+
}
|
| 151 |
+
return styles[status] || 'status-queued'
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return (
|
| 155 |
+
<div className="flex flex-col h-full bg-[#0f1017]">
|
| 156 |
+
{/* Header */}
|
| 157 |
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2b3d]">
|
| 158 |
+
<div className="flex items-center gap-2">
|
| 159 |
+
<span className="text-sm font-semibold text-slate-200">Execution Timeline</span>
|
| 160 |
+
{activeTask && (
|
| 161 |
+
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${getStatusBadge(activeTask.status)}`}>
|
| 162 |
+
{activeTask.status}
|
| 163 |
+
</span>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
<div className="flex items-center gap-2">
|
| 167 |
+
<span className="text-[10px] text-slate-600 font-mono">{timeline.length} events</span>
|
| 168 |
+
{timeline.length > 0 && (
|
| 169 |
+
<button
|
| 170 |
+
onClick={clearTimeline}
|
| 171 |
+
className="text-[10px] text-slate-500 hover:text-slate-300 transition-colors px-2 py-0.5 rounded border border-[#2a2b3d] hover:border-[#3a3b5a]"
|
| 172 |
+
>
|
| 173 |
+
Clear
|
| 174 |
+
</button>
|
| 175 |
+
)}
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
|
| 179 |
+
{/* Active task info */}
|
| 180 |
+
{activeTask && (
|
| 181 |
+
<div className="px-4 py-2.5 bg-[#13141c] border-b border-[#2a2b3d]">
|
| 182 |
+
<p className="text-[11px] text-slate-400 font-mono truncate">
|
| 183 |
+
<span className="text-slate-600">Goal: </span>{activeTask.goal.slice(0, 100)}
|
| 184 |
+
</p>
|
| 185 |
+
{activeTask.retry_count > 0 && (
|
| 186 |
+
<p className="text-[10px] text-yellow-400 mt-0.5">β» Retry #{activeTask.retry_count}</p>
|
| 187 |
+
)}
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
|
| 191 |
+
{/* Step progress */}
|
| 192 |
+
<StepProgress steps={activeSteps} />
|
| 193 |
+
|
| 194 |
+
{/* Timeline events */}
|
| 195 |
+
<div className="flex-1 overflow-y-auto p-4">
|
| 196 |
+
{timeline.length === 0 ? (
|
| 197 |
+
<div className="flex flex-col items-center justify-center h-full text-center">
|
| 198 |
+
<div className="text-4xl mb-3 opacity-30">β±οΈ</div>
|
| 199 |
+
<p className="text-sm text-slate-500">No events yet</p>
|
| 200 |
+
<p className="text-xs text-slate-600 mt-1">Submit a task to see live execution</p>
|
| 201 |
+
</div>
|
| 202 |
+
) : (
|
| 203 |
+
<div className="space-y-0">
|
| 204 |
+
{[...timeline].reverse().map((event, i) => (
|
| 205 |
+
<TimelineItem
|
| 206 |
+
key={event.id}
|
| 207 |
+
event={event}
|
| 208 |
+
isLast={i === timeline.length - 1}
|
| 209 |
+
/>
|
| 210 |
+
))}
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
{/* Active task result */}
|
| 216 |
+
{activeTask?.result && (
|
| 217 |
+
<div className="px-4 py-3 border-t border-[#2a2b3d] bg-[#13141c]">
|
| 218 |
+
<div className="text-[10px] text-terminal-green font-mono mb-1">β Result</div>
|
| 219 |
+
<p className="text-[11px] text-slate-300 line-clamp-3">{activeTask.result}</p>
|
| 220 |
+
</div>
|
| 221 |
+
)}
|
| 222 |
+
</div>
|
| 223 |
+
)
|
| 224 |
+
}
|
frontend/ecosystem.config.cjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
apps: [
|
| 3 |
+
{
|
| 4 |
+
name: 'devin-frontend',
|
| 5 |
+
script: 'npm',
|
| 6 |
+
args: 'start',
|
| 7 |
+
cwd: '/home/user/devin-agent/frontend',
|
| 8 |
+
watch: false,
|
| 9 |
+
instances: 1,
|
| 10 |
+
exec_mode: 'fork',
|
| 11 |
+
env: {
|
| 12 |
+
PORT: 3000,
|
| 13 |
+
NODE_ENV: 'production',
|
| 14 |
+
NEXT_PUBLIC_API_URL: 'http://localhost:7860',
|
| 15 |
+
NEXT_PUBLIC_WS_URL: 'ws://localhost:7860',
|
| 16 |
+
},
|
| 17 |
+
},
|
| 18 |
+
],
|
| 19 |
+
}
|
frontend/hooks/nanoid.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function nanoid(size = 10): string {
|
| 2 |
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
| 3 |
+
let result = ''
|
| 4 |
+
for (let i = 0; i < size; i++) {
|
| 5 |
+
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
| 6 |
+
}
|
| 7 |
+
return result
|
| 8 |
+
}
|