pyaesonegtckglay-dotcom commited on
Commit
666aab6
Β·
0 Parent(s):

πŸš€ Devin Agent Platform v2.0

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. backend/Dockerfile +30 -0
  2. backend/Dockerfile.hf +48 -0
  3. backend/README.md +58 -0
  4. backend/__pycache__/main.cpython-312.pyc +0 -0
  5. backend/api/__init__.py +0 -0
  6. backend/api/__pycache__/__init__.cpython-312.pyc +0 -0
  7. backend/api/__pycache__/websocket_manager.cpython-312.pyc +0 -0
  8. backend/api/routes/__init__.py +0 -0
  9. backend/api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  10. backend/api/routes/__pycache__/chat.cpython-312.pyc +0 -0
  11. backend/api/routes/__pycache__/github.cpython-312.pyc +0 -0
  12. backend/api/routes/__pycache__/health.cpython-312.pyc +0 -0
  13. backend/api/routes/__pycache__/memory.cpython-312.pyc +0 -0
  14. backend/api/routes/__pycache__/tasks.cpython-312.pyc +0 -0
  15. backend/api/routes/chat.py +214 -0
  16. backend/api/routes/github.py +336 -0
  17. backend/api/routes/health.py +53 -0
  18. backend/api/routes/memory.py +50 -0
  19. backend/api/routes/tasks.py +167 -0
  20. backend/api/websocket_manager.py +134 -0
  21. backend/core/__init__.py +0 -0
  22. backend/core/__pycache__/__init__.cpython-312.pyc +0 -0
  23. backend/core/__pycache__/agent.cpython-312.pyc +0 -0
  24. backend/core/__pycache__/models.cpython-312.pyc +0 -0
  25. backend/core/__pycache__/task_engine.cpython-312.pyc +0 -0
  26. backend/core/agent.py +392 -0
  27. backend/core/models.py +213 -0
  28. backend/core/task_engine.py +241 -0
  29. backend/ecosystem.config.cjs +20 -0
  30. backend/github/__init__.py +0 -0
  31. backend/main.py +180 -0
  32. backend/memory/__init__.py +0 -0
  33. backend/memory/__pycache__/__init__.cpython-312.pyc +0 -0
  34. backend/memory/__pycache__/db.cpython-312.pyc +0 -0
  35. backend/memory/db.py +271 -0
  36. backend/requirements.txt +28 -0
  37. backend/tools/__init__.py +0 -0
  38. backend/tools/executor.py +176 -0
  39. frontend/app/globals.css +178 -0
  40. frontend/app/layout.tsx +21 -0
  41. frontend/app/page.tsx +65 -0
  42. frontend/components/chat/ChatPanel.tsx +233 -0
  43. frontend/components/chat/MessageBubble.tsx +162 -0
  44. frontend/components/layout/MemoryPanel.tsx +103 -0
  45. frontend/components/layout/Sidebar.tsx +193 -0
  46. frontend/components/layout/TasksPanel.tsx +170 -0
  47. frontend/components/layout/TopBar.tsx +98 -0
  48. frontend/components/timeline/ExecutionTimeline.tsx +224 -0
  49. frontend/ecosystem.config.cjs +19 -0
  50. 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
+ }