Commit ·
900e69d
1
Parent(s): 3f8da9e
Implement custom task grading and upload functionality
Browse files- README.md +45 -1
- backend/app.py +135 -8
- backend/grader/grader.py +77 -1
- backend/grader/task_graders.py +19 -1
- backend/tasks/dynamic_loader.py +156 -0
- backend/tasks/dynamic_store.py +216 -0
- frontend/package-lock.json +5 -0
- frontend/src/App.jsx +22 -3
- frontend/src/components/TaskSelector.jsx +3 -2
- frontend/src/components/UploadPanel.jsx +266 -0
- frontend/src/index.css +271 -0
- frontend/src/services/api.js +24 -0
README.md
CHANGED
|
@@ -253,10 +253,54 @@ The environment implements the full OpenEnv interface:
|
|
| 253 |
|
| 254 |
| Method | Path | Description |
|
| 255 |
|--------|------|-------------|
|
| 256 |
-
| `GET` | `/api/tasks` | List all task metadata |
|
| 257 |
| `POST` | `/api/reset/{task_id}` | Start an episode |
|
| 258 |
| `POST` | `/api/step` | Execute an action |
|
| 259 |
| `GET` | `/api/state` | Get current state |
|
| 260 |
| `POST` | `/api/auto_action` | Baseline agent picks next action |
|
|
|
|
|
|
|
| 261 |
|
| 262 |
The container starts the FastAPI backend on port `7860`, which is compatible with a Hugging Face Docker Space contest deployment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
| Method | Path | Description |
|
| 255 |
|--------|------|-------------|
|
| 256 |
+
| `GET` | `/api/tasks` | List all task metadata (static + uploaded) |
|
| 257 |
| `POST` | `/api/reset/{task_id}` | Start an episode |
|
| 258 |
| `POST` | `/api/step` | Execute an action |
|
| 259 |
| `GET` | `/api/state` | Get current state |
|
| 260 |
| `POST` | `/api/auto_action` | Baseline agent picks next action |
|
| 261 |
+
| `POST` | `/api/upload` | Upload custom code for review |
|
| 262 |
+
| `DELETE` | `/api/upload/{task_id}` | Delete an uploaded task |
|
| 263 |
|
| 264 |
The container starts the FastAPI backend on port `7860`, which is compatible with a Hugging Face Docker Space contest deployment.
|
| 265 |
+
|
| 266 |
+
## Custom Code Upload
|
| 267 |
+
|
| 268 |
+
You can upload your own code files for the AI agent to review. This enables dynamic code review scenarios beyond the built-in tasks.
|
| 269 |
+
|
| 270 |
+
### Upload via UI
|
| 271 |
+
|
| 272 |
+
1. Click **"Upload Custom Code"** in the toolbar
|
| 273 |
+
2. Fill in the PR title and description
|
| 274 |
+
3. Drag & drop or select modified files
|
| 275 |
+
4. Optionally add original file versions for automatic diff generation
|
| 276 |
+
5. Click **"Upload & Create Task"**
|
| 277 |
+
6. Select the new task from the dropdown and start reviewing
|
| 278 |
+
|
| 279 |
+
### Upload via API
|
| 280 |
+
|
| 281 |
+
```bash
|
| 282 |
+
# Upload files for review
|
| 283 |
+
curl -X POST http://localhost:8000/api/upload \
|
| 284 |
+
-F "title=Fix authentication bug" \
|
| 285 |
+
-F "description=Remove admin check from public endpoint" \
|
| 286 |
+
-F "files=@path/to/modified_file.py" \
|
| 287 |
+
-F "original_files=@path/to/original_file.py"
|
| 288 |
+
|
| 289 |
+
# Response:
|
| 290 |
+
# {
|
| 291 |
+
# "task_id": "upload_20240407_120000_abc12345",
|
| 292 |
+
# "label": "Fix authentication bug",
|
| 293 |
+
# "changed_files": ["modified_file.py"],
|
| 294 |
+
# "message": "Task created successfully..."
|
| 295 |
+
# }
|
| 296 |
+
|
| 297 |
+
# Start review session with uploaded task
|
| 298 |
+
curl -X POST http://localhost:8000/api/reset/upload_20240407_120000_abc12345
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
### Upload Limits
|
| 302 |
+
|
| 303 |
+
- Maximum 10 files per upload
|
| 304 |
+
- Maximum 1MB per file
|
| 305 |
+
- Uploaded tasks expire after 1 hour (configurable)
|
| 306 |
+
- Grading uses "review_only" mode (scores coverage, not correctness)
|
backend/app.py
CHANGED
|
@@ -6,7 +6,7 @@ import logging
|
|
| 6 |
import os
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
-
from fastapi import FastAPI
|
| 10 |
from fastapi.responses import JSONResponse, FileResponse
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
|
@@ -16,6 +16,8 @@ import uvicorn
|
|
| 16 |
from baseline import BaselineAgent
|
| 17 |
from env.environment import CodeReviewEnv
|
| 18 |
from tasks.task_registry import get_available_tasks, get_task_catalog, load_task
|
|
|
|
|
|
|
| 19 |
from grader.task_graders import get_grader
|
| 20 |
|
| 21 |
# Setup logging
|
|
@@ -36,7 +38,7 @@ app = FastAPI(title="Code Review Assistant API")
|
|
| 36 |
app.add_middleware(
|
| 37 |
CORSMiddleware,
|
| 38 |
allow_origins=ALLOWED_ORIGINS,
|
| 39 |
-
allow_methods=["GET", "POST"],
|
| 40 |
allow_headers=["Content-Type"],
|
| 41 |
allow_credentials=True,
|
| 42 |
)
|
|
@@ -61,9 +63,10 @@ class ActionRequest(BaseModel):
|
|
| 61 |
|
| 62 |
@app.get("/api/tasks")
|
| 63 |
def get_tasks():
|
| 64 |
-
"""Get all available tasks with metadata (
|
| 65 |
global _task_catalog_cache
|
| 66 |
|
|
|
|
| 67 |
if _task_catalog_cache is None:
|
| 68 |
meta = {"easy": "🟢", "medium": "🟡", "hard": "🔴"}
|
| 69 |
tasks = []
|
|
@@ -81,19 +84,142 @@ def get_tasks():
|
|
| 81 |
)
|
| 82 |
_task_catalog_cache = {"tasks": tasks}
|
| 83 |
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
@app.post("/api/reset/{task_name}")
|
| 88 |
def reset(task_name: str):
|
| 89 |
-
"""Reset environment with a specific task."""
|
| 90 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
logger.warning(f"Unknown task requested: {task_name}")
|
| 92 |
return JSONResponse(status_code=400, content={"error": "Unknown task"})
|
| 93 |
|
| 94 |
try:
|
| 95 |
-
task = load_task(task_name)
|
| 96 |
-
grader = get_grader(task_name)
|
| 97 |
obs = _env.reset(task)
|
| 98 |
_session.update({"task_name": task_name, "task": task, "grader": grader, "obs": obs, "done": False})
|
| 99 |
logger.info(f"Task {task_name} reset successfully")
|
|
@@ -105,6 +231,7 @@ def reset(task_name: str):
|
|
| 105 |
"description": task["description"],
|
| 106 |
"issue_title": task["issue_title"],
|
| 107 |
"issue_body": task["issue_body"],
|
|
|
|
| 108 |
}
|
| 109 |
except Exception as e:
|
| 110 |
logger.error(f"Error resetting task {task_name}: {e}", exc_info=True)
|
|
|
|
| 6 |
import os
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
from fastapi import FastAPI, UploadFile, File, Form
|
| 10 |
from fastapi.responses import JSONResponse, FileResponse
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 16 |
from baseline import BaselineAgent
|
| 17 |
from env.environment import CodeReviewEnv
|
| 18 |
from tasks.task_registry import get_available_tasks, get_task_catalog, load_task
|
| 19 |
+
from tasks.dynamic_loader import create_dynamic_task, validate_uploaded_files
|
| 20 |
+
from tasks.dynamic_store import store_dynamic_task, get_dynamic_task, list_dynamic_tasks
|
| 21 |
from grader.task_graders import get_grader
|
| 22 |
|
| 23 |
# Setup logging
|
|
|
|
| 38 |
app.add_middleware(
|
| 39 |
CORSMiddleware,
|
| 40 |
allow_origins=ALLOWED_ORIGINS,
|
| 41 |
+
allow_methods=["GET", "POST", "DELETE"],
|
| 42 |
allow_headers=["Content-Type"],
|
| 43 |
allow_credentials=True,
|
| 44 |
)
|
|
|
|
| 63 |
|
| 64 |
@app.get("/api/tasks")
|
| 65 |
def get_tasks():
|
| 66 |
+
"""Get all available tasks with metadata (static + dynamic)."""
|
| 67 |
global _task_catalog_cache
|
| 68 |
|
| 69 |
+
# Build static tasks (cached)
|
| 70 |
if _task_catalog_cache is None:
|
| 71 |
meta = {"easy": "🟢", "medium": "🟡", "hard": "🔴"}
|
| 72 |
tasks = []
|
|
|
|
| 84 |
)
|
| 85 |
_task_catalog_cache = {"tasks": tasks}
|
| 86 |
|
| 87 |
+
# Add dynamic uploaded tasks (not cached)
|
| 88 |
+
result = {"tasks": _task_catalog_cache["tasks"].copy()}
|
| 89 |
+
dynamic_tasks = list_dynamic_tasks()
|
| 90 |
+
|
| 91 |
+
for dt in dynamic_tasks:
|
| 92 |
+
result["tasks"].append({
|
| 93 |
+
"id": dt["id"],
|
| 94 |
+
"label": dt["label"],
|
| 95 |
+
"difficulty": "custom",
|
| 96 |
+
"icon": "📤",
|
| 97 |
+
"desc": dt["description"],
|
| 98 |
+
"issue_title": dt["label"],
|
| 99 |
+
"pass_threshold": 0.5,
|
| 100 |
+
"is_custom_upload": True,
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
return result
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@app.post("/api/upload")
|
| 107 |
+
async def upload_code(
|
| 108 |
+
title: str = Form(...),
|
| 109 |
+
description: str = Form(...),
|
| 110 |
+
files: list[UploadFile] = File(...),
|
| 111 |
+
original_files: list[UploadFile] = File(default=[]),
|
| 112 |
+
):
|
| 113 |
+
"""
|
| 114 |
+
Upload custom code for review.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
title: PR/Issue title
|
| 118 |
+
description: PR description
|
| 119 |
+
files: Modified/new files to review
|
| 120 |
+
original_files: Original versions (optional, for diff generation)
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
Created task ID and metadata
|
| 124 |
+
"""
|
| 125 |
+
try:
|
| 126 |
+
# Validate file count
|
| 127 |
+
total_files = len(files) + len(original_files)
|
| 128 |
+
if total_files > 20:
|
| 129 |
+
return JSONResponse(status_code=400, content={"error": "Maximum 20 files allowed"})
|
| 130 |
+
|
| 131 |
+
# Read and validate files
|
| 132 |
+
changed_files = {}
|
| 133 |
+
max_size = 1 * 1024 * 1024 # 1MB
|
| 134 |
+
|
| 135 |
+
# Build original files map
|
| 136 |
+
originals = {}
|
| 137 |
+
for orig in original_files:
|
| 138 |
+
content = await orig.read()
|
| 139 |
+
if len(content) > max_size:
|
| 140 |
+
return JSONResponse(status_code=400, content={"error": f"File '{orig.filename}' exceeds 1MB limit"})
|
| 141 |
+
originals[orig.filename] = content.decode("utf-8", errors="replace")
|
| 142 |
+
|
| 143 |
+
# Process modified files
|
| 144 |
+
for f in files:
|
| 145 |
+
content = await f.read()
|
| 146 |
+
if len(content) > max_size:
|
| 147 |
+
return JSONResponse(status_code=400, content={"error": f"File '{f.filename}' exceeds 1MB limit"})
|
| 148 |
+
|
| 149 |
+
modified_content = content.decode("utf-8", errors="replace")
|
| 150 |
+
|
| 151 |
+
# Check if we have original version
|
| 152 |
+
if f.filename in originals:
|
| 153 |
+
changed_files[f.filename] = {
|
| 154 |
+
"original": originals[f.filename],
|
| 155 |
+
"modified": modified_content,
|
| 156 |
+
}
|
| 157 |
+
else:
|
| 158 |
+
# New file - no original
|
| 159 |
+
changed_files[f.filename] = {
|
| 160 |
+
"modified": modified_content,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if not changed_files:
|
| 164 |
+
return JSONResponse(status_code=400, content={"error": "No files uploaded"})
|
| 165 |
+
|
| 166 |
+
# Create dynamic task
|
| 167 |
+
task = create_dynamic_task(
|
| 168 |
+
title=title,
|
| 169 |
+
description=description,
|
| 170 |
+
changed_files=changed_files,
|
| 171 |
+
issue_body=description,
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
# Store task
|
| 175 |
+
task_id = store_dynamic_task(task)
|
| 176 |
+
|
| 177 |
+
logger.info(f"Created custom task: {task_id} with {len(changed_files)} files")
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"task_id": task_id,
|
| 181 |
+
"label": title,
|
| 182 |
+
"description": description,
|
| 183 |
+
"changed_files": list(changed_files.keys()),
|
| 184 |
+
"message": f"Task created successfully. Select '{task_id}' to start review.",
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Upload error: {e}", exc_info=True)
|
| 189 |
+
return JSONResponse(status_code=500, content={"error": str(e)})
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@app.delete("/api/upload/{task_id}")
|
| 193 |
+
def delete_upload(task_id: str):
|
| 194 |
+
"""Delete an uploaded task."""
|
| 195 |
+
from tasks.dynamic_store import delete_dynamic_task
|
| 196 |
+
|
| 197 |
+
if delete_dynamic_task(task_id):
|
| 198 |
+
logger.info(f"Deleted custom task: {task_id}")
|
| 199 |
+
return {"message": f"Task {task_id} deleted"}
|
| 200 |
+
else:
|
| 201 |
+
return JSONResponse(status_code=404, content={"error": "Task not found"})
|
| 202 |
|
| 203 |
|
| 204 |
@app.post("/api/reset/{task_name}")
|
| 205 |
def reset(task_name: str):
|
| 206 |
+
"""Reset environment with a specific task (static or dynamic)."""
|
| 207 |
+
# Check if it's a dynamic uploaded task
|
| 208 |
+
dynamic_task = get_dynamic_task(task_name)
|
| 209 |
+
|
| 210 |
+
if dynamic_task:
|
| 211 |
+
# Use the uploaded task
|
| 212 |
+
task = dynamic_task
|
| 213 |
+
grader = get_grader(task_name, is_custom=True)
|
| 214 |
+
elif task_name in get_available_tasks():
|
| 215 |
+
# Use static task
|
| 216 |
+
task = load_task(task_name)
|
| 217 |
+
grader = get_grader(task_name)
|
| 218 |
+
else:
|
| 219 |
logger.warning(f"Unknown task requested: {task_name}")
|
| 220 |
return JSONResponse(status_code=400, content={"error": "Unknown task"})
|
| 221 |
|
| 222 |
try:
|
|
|
|
|
|
|
| 223 |
obs = _env.reset(task)
|
| 224 |
_session.update({"task_name": task_name, "task": task, "grader": grader, "obs": obs, "done": False})
|
| 225 |
logger.info(f"Task {task_name} reset successfully")
|
|
|
|
| 231 |
"description": task["description"],
|
| 232 |
"issue_title": task["issue_title"],
|
| 233 |
"issue_body": task["issue_body"],
|
| 234 |
+
"is_custom_upload": task.get("is_custom_upload", False),
|
| 235 |
}
|
| 236 |
except Exception as e:
|
| 237 |
logger.error(f"Error resetting task {task_name}: {e}", exc_info=True)
|
backend/grader/grader.py
CHANGED
|
@@ -9,11 +9,30 @@ from env.reward import RewardEngine
|
|
| 9 |
class TaskGrader:
|
| 10 |
"""Grades a completed episode against hidden task metadata."""
|
| 11 |
|
| 12 |
-
def __init__(self, task: Dict[str, Any]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
self.task = task
|
|
|
|
| 14 |
self._last_report: Dict[str, Any] = {}
|
| 15 |
|
| 16 |
def grade_episode(self, actions_taken: List[Dict[str, Any]]) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
score, breakdown = RewardEngine.score_actions(self.task, actions_taken)
|
| 18 |
status = "PASS" if score >= float(self.task["pass_threshold"]) else "FAIL"
|
| 19 |
|
|
@@ -39,6 +58,63 @@ class TaskGrader:
|
|
| 39 |
"grade_status": status,
|
| 40 |
}
|
| 41 |
return score
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
def generate_grade_report(self) -> Dict[str, Any]:
|
| 44 |
"""Return the report from the last grade_episode call."""
|
|
|
|
| 9 |
class TaskGrader:
|
| 10 |
"""Grades a completed episode against hidden task metadata."""
|
| 11 |
|
| 12 |
+
def __init__(self, task: Dict[str, Any], review_only: bool = False):
|
| 13 |
+
"""
|
| 14 |
+
Initialize grader.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
task: Task dictionary
|
| 18 |
+
review_only: If True, grade without ground truth (custom uploads)
|
| 19 |
+
"""
|
| 20 |
self.task = task
|
| 21 |
+
self.review_only = review_only
|
| 22 |
self._last_report: Dict[str, Any] = {}
|
| 23 |
|
| 24 |
def grade_episode(self, actions_taken: List[Dict[str, Any]]) -> float:
|
| 25 |
+
"""
|
| 26 |
+
Grade the episode based on actions taken.
|
| 27 |
+
|
| 28 |
+
For review_only mode (custom uploads), grades based on:
|
| 29 |
+
- How many files were inspected
|
| 30 |
+
- Whether comments were made
|
| 31 |
+
- Whether a decision was reached
|
| 32 |
+
"""
|
| 33 |
+
if self.review_only:
|
| 34 |
+
return self._grade_review_only(actions_taken)
|
| 35 |
+
|
| 36 |
score, breakdown = RewardEngine.score_actions(self.task, actions_taken)
|
| 37 |
status = "PASS" if score >= float(self.task["pass_threshold"]) else "FAIL"
|
| 38 |
|
|
|
|
| 58 |
"grade_status": status,
|
| 59 |
}
|
| 60 |
return score
|
| 61 |
+
|
| 62 |
+
def _grade_review_only(self, actions_taken: List[Dict[str, Any]]) -> float:
|
| 63 |
+
"""Grade custom upload based on review completeness."""
|
| 64 |
+
inspected_diffs = set()
|
| 65 |
+
inspected_files = set()
|
| 66 |
+
comments = []
|
| 67 |
+
final_decision = None
|
| 68 |
+
|
| 69 |
+
changed_files = set(self.task.get("changed_files", []))
|
| 70 |
+
|
| 71 |
+
for action in actions_taken:
|
| 72 |
+
action_type = action.get("action_type")
|
| 73 |
+
path = action.get("path")
|
| 74 |
+
text = action.get("text", "")
|
| 75 |
+
|
| 76 |
+
if action_type == "inspect_diff" and path:
|
| 77 |
+
inspected_diffs.add(path)
|
| 78 |
+
elif action_type == "inspect_file" and path:
|
| 79 |
+
inspected_files.add(path)
|
| 80 |
+
elif action_type == "comment" and text:
|
| 81 |
+
comments.append(text)
|
| 82 |
+
elif action_type in ("approve", "reject", "escalate"):
|
| 83 |
+
final_decision = action_type
|
| 84 |
+
|
| 85 |
+
# Calculate coverage score
|
| 86 |
+
total_changed = len(changed_files) if changed_files else 1
|
| 87 |
+
coverage = len(inspected_diffs & changed_files) / total_changed
|
| 88 |
+
|
| 89 |
+
# Score components for review-only mode
|
| 90 |
+
coverage_score = min(coverage, 1.0) * 0.40 # 40% for file coverage
|
| 91 |
+
comment_score = min(len(comments) / 3, 1.0) * 0.30 # 30% for comments (up to 3)
|
| 92 |
+
decision_score = 0.30 if final_decision else 0.0 # 30% for making a decision
|
| 93 |
+
|
| 94 |
+
total_score = coverage_score + comment_score + decision_score
|
| 95 |
+
|
| 96 |
+
# Build report
|
| 97 |
+
self._last_report = {
|
| 98 |
+
"task_id": self.task["id"],
|
| 99 |
+
"difficulty": "custom",
|
| 100 |
+
"issue_title": self.task.get("issue_title", "Custom Review"),
|
| 101 |
+
"review_mode": "review_only",
|
| 102 |
+
"inspected_diffs": list(inspected_diffs),
|
| 103 |
+
"inspected_files": list(inspected_files),
|
| 104 |
+
"changed_files": list(changed_files),
|
| 105 |
+
"coverage": round(coverage, 2),
|
| 106 |
+
"comments_made": len(comments),
|
| 107 |
+
"comments_preview": [c[:100] + "..." if len(c) > 100 else c for c in comments[:3]],
|
| 108 |
+
"submitted_decision": final_decision,
|
| 109 |
+
"coverage_score": round(coverage_score, 4),
|
| 110 |
+
"comment_score": round(comment_score, 4),
|
| 111 |
+
"decision_score": round(decision_score, 4),
|
| 112 |
+
"final_score": round(total_score, 4),
|
| 113 |
+
"grade_status": "REVIEWED",
|
| 114 |
+
"note": "Custom uploads are graded on review completeness, not correctness.",
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return total_score
|
| 118 |
|
| 119 |
def generate_grade_report(self) -> Dict[str, Any]:
|
| 120 |
"""Return the report from the last grade_episode call."""
|
backend/grader/task_graders.py
CHANGED
|
@@ -6,5 +6,23 @@ from grader.grader import TaskGrader
|
|
| 6 |
from tasks.task_registry import load_task
|
| 7 |
|
| 8 |
|
| 9 |
-
def get_grader(task_id: str) -> TaskGrader:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
return TaskGrader(load_task(task_id))
|
|
|
|
| 6 |
from tasks.task_registry import load_task
|
| 7 |
|
| 8 |
|
| 9 |
+
def get_grader(task_id: str, is_custom: bool = False) -> TaskGrader:
|
| 10 |
+
"""
|
| 11 |
+
Get a grader for a task.
|
| 12 |
+
|
| 13 |
+
Args:
|
| 14 |
+
task_id: Task identifier
|
| 15 |
+
is_custom: If True, use custom task grading (review-only mode)
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
TaskGrader instance
|
| 19 |
+
"""
|
| 20 |
+
if is_custom:
|
| 21 |
+
# For custom uploads, get from dynamic store
|
| 22 |
+
from tasks.dynamic_store import get_dynamic_task
|
| 23 |
+
task = get_dynamic_task(task_id)
|
| 24 |
+
if task:
|
| 25 |
+
return TaskGrader(task, review_only=True)
|
| 26 |
+
raise ValueError(f"Custom task not found: {task_id}")
|
| 27 |
+
|
| 28 |
return TaskGrader(load_task(task_id))
|
backend/tasks/dynamic_loader.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dynamic task generation from user-uploaded code."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
import difflib
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def generate_task_id() -> str:
|
| 12 |
+
"""Generate a unique task ID for uploaded content."""
|
| 13 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 14 |
+
short_uuid = str(uuid.uuid4())[:8]
|
| 15 |
+
return f"upload_{timestamp}_{short_uuid}"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_diff(original: str, modified: str, filename: str) -> str:
|
| 19 |
+
"""Create unified diff from original and modified file contents."""
|
| 20 |
+
original_lines = original.splitlines(keepends=True)
|
| 21 |
+
modified_lines = modified.splitlines(keepends=True)
|
| 22 |
+
|
| 23 |
+
diff = difflib.unified_diff(
|
| 24 |
+
original_lines,
|
| 25 |
+
modified_lines,
|
| 26 |
+
fromfile=f"a/{filename}",
|
| 27 |
+
tofile=f"b/{filename}",
|
| 28 |
+
lineterm=""
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
return "".join(diff)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def create_dynamic_task(
|
| 35 |
+
title: str,
|
| 36 |
+
description: str,
|
| 37 |
+
changed_files: Dict[str, Dict[str, str]],
|
| 38 |
+
context_files: Dict[str, str] | None = None,
|
| 39 |
+
issue_body: str | None = None,
|
| 40 |
+
ground_truth: Dict[str, Any] | None = None,
|
| 41 |
+
) -> Dict[str, Any]:
|
| 42 |
+
"""
|
| 43 |
+
Create a task dictionary from uploaded content.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
title: PR/Issue title
|
| 47 |
+
description: PR description/summary
|
| 48 |
+
changed_files: Dict of {path: {"original": str, "modified": str}} or {path: {"diff": str}}
|
| 49 |
+
context_files: Optional dict of {path: content} for additional context
|
| 50 |
+
issue_body: Optional detailed issue description
|
| 51 |
+
ground_truth: Optional ground truth for scoring (if known)
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Task dictionary compatible with CodeReviewEnv
|
| 55 |
+
"""
|
| 56 |
+
task_id = generate_task_id()
|
| 57 |
+
|
| 58 |
+
# Process changed files and generate diffs
|
| 59 |
+
diffs = {}
|
| 60 |
+
files = {}
|
| 61 |
+
changed_paths = []
|
| 62 |
+
|
| 63 |
+
for path, content in changed_files.items():
|
| 64 |
+
changed_paths.append(path)
|
| 65 |
+
|
| 66 |
+
# If diff is provided directly, use it
|
| 67 |
+
if "diff" in content:
|
| 68 |
+
diffs[path] = content["diff"]
|
| 69 |
+
# Store modified version as the file content
|
| 70 |
+
if "modified" in content:
|
| 71 |
+
files[path] = content["modified"]
|
| 72 |
+
|
| 73 |
+
# If original and modified are provided, generate diff
|
| 74 |
+
elif "original" in content and "modified" in content:
|
| 75 |
+
diffs[path] = create_diff(content["original"], content["modified"], path)
|
| 76 |
+
files[path] = content["modified"]
|
| 77 |
+
|
| 78 |
+
# If only modified is provided (new file)
|
| 79 |
+
elif "modified" in content:
|
| 80 |
+
files[path] = content["modified"]
|
| 81 |
+
# Generate diff showing entire file as added
|
| 82 |
+
diffs[path] = create_diff("", content["modified"], path)
|
| 83 |
+
|
| 84 |
+
# Add context files
|
| 85 |
+
if context_files:
|
| 86 |
+
files.update(context_files)
|
| 87 |
+
|
| 88 |
+
# Build available files list (all files that can be inspected)
|
| 89 |
+
available_files = list(set(changed_paths + list(context_files.keys() if context_files else [])))
|
| 90 |
+
|
| 91 |
+
# Create task structure
|
| 92 |
+
task = {
|
| 93 |
+
"id": task_id,
|
| 94 |
+
"difficulty": "custom",
|
| 95 |
+
"label": title,
|
| 96 |
+
"description": description,
|
| 97 |
+
"issue_title": title,
|
| 98 |
+
"issue_body": issue_body or description,
|
| 99 |
+
"changed_files": changed_paths,
|
| 100 |
+
"available_files": available_files,
|
| 101 |
+
"diffs": diffs,
|
| 102 |
+
"files": files,
|
| 103 |
+
"pass_threshold": 0.5, # Default for custom uploads
|
| 104 |
+
"is_custom_upload": True,
|
| 105 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
# Add ground truth if provided (for testing)
|
| 109 |
+
if ground_truth:
|
| 110 |
+
task["ground_truth"] = ground_truth
|
| 111 |
+
|
| 112 |
+
return task
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def validate_uploaded_files(files: List[Dict[str, Any]], max_size_mb: float = 1.0, max_files: int = 10) -> tuple[bool, str | None]:
|
| 116 |
+
"""
|
| 117 |
+
Validate uploaded files for size and count limits.
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
(is_valid, error_message)
|
| 121 |
+
"""
|
| 122 |
+
if len(files) > max_files:
|
| 123 |
+
return False, f"Too many files: maximum {max_files} files allowed"
|
| 124 |
+
|
| 125 |
+
max_size_bytes = max_size_mb * 1024 * 1024
|
| 126 |
+
|
| 127 |
+
for file_info in files:
|
| 128 |
+
size = file_info.get("size", 0)
|
| 129 |
+
if size > max_size_bytes:
|
| 130 |
+
filename = file_info.get("filename", "unknown")
|
| 131 |
+
return False, f"File '{filename}' exceeds {max_size_mb}MB limit"
|
| 132 |
+
|
| 133 |
+
return True, None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# Example usage for testing
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
# Example: Simple file modification
|
| 139 |
+
test_task = create_dynamic_task(
|
| 140 |
+
title="Fix authentication bug",
|
| 141 |
+
description="Remove admin check from public endpoint",
|
| 142 |
+
changed_files={
|
| 143 |
+
"api/routes.py": {
|
| 144 |
+
"original": "@admin_required\ndef export_data():\n return data",
|
| 145 |
+
"modified": "def export_data():\n return data"
|
| 146 |
+
}
|
| 147 |
+
},
|
| 148 |
+
context_files={
|
| 149 |
+
"api/__init__.py": "from .routes import *"
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
print(f"Generated task: {test_task['id']}")
|
| 154 |
+
print(f"Changed files: {test_task['changed_files']}")
|
| 155 |
+
print(f"Available files: {test_task['available_files']}")
|
| 156 |
+
print(f"Diff sample:\n{test_task['diffs']['api/routes.py'][:200]}")
|
backend/tasks/dynamic_store.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Temporary storage for user-uploaded dynamic tasks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DynamicTaskStore:
|
| 12 |
+
"""Thread-safe in-memory storage for uploaded tasks with TTL."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, ttl_seconds: int = 3600):
|
| 15 |
+
"""
|
| 16 |
+
Initialize task store.
|
| 17 |
+
|
| 18 |
+
Args:
|
| 19 |
+
ttl_seconds: Time-to-live for tasks in seconds (default: 1 hour)
|
| 20 |
+
"""
|
| 21 |
+
self._tasks: Dict[str, Dict[str, Any]] = {}
|
| 22 |
+
self._timestamps: Dict[str, datetime] = {}
|
| 23 |
+
self._lock = threading.RLock()
|
| 24 |
+
self._ttl = timedelta(seconds=ttl_seconds)
|
| 25 |
+
self._cleanup_thread = None
|
| 26 |
+
self._running = False
|
| 27 |
+
|
| 28 |
+
def store_task(self, task: Dict[str, Any]) -> str:
|
| 29 |
+
"""
|
| 30 |
+
Store a task and return its ID.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
task: Task dictionary (must contain 'id' field)
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Task ID
|
| 37 |
+
"""
|
| 38 |
+
task_id = task["id"]
|
| 39 |
+
|
| 40 |
+
with self._lock:
|
| 41 |
+
self._tasks[task_id] = task
|
| 42 |
+
self._timestamps[task_id] = datetime.now()
|
| 43 |
+
|
| 44 |
+
return task_id
|
| 45 |
+
|
| 46 |
+
def get_task(self, task_id: str) -> Dict[str, Any] | None:
|
| 47 |
+
"""
|
| 48 |
+
Retrieve a task by ID.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
task_id: Task identifier
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
Task dictionary or None if not found/expired
|
| 55 |
+
"""
|
| 56 |
+
with self._lock:
|
| 57 |
+
if task_id not in self._tasks:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# Check if expired
|
| 61 |
+
if self._is_expired(task_id):
|
| 62 |
+
self._delete_task(task_id)
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
return self._tasks[task_id]
|
| 66 |
+
|
| 67 |
+
def delete_task(self, task_id: str) -> bool:
|
| 68 |
+
"""
|
| 69 |
+
Delete a task by ID.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
task_id: Task identifier
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
True if deleted, False if not found
|
| 76 |
+
"""
|
| 77 |
+
with self._lock:
|
| 78 |
+
return self._delete_task(task_id)
|
| 79 |
+
|
| 80 |
+
def _delete_task(self, task_id: str) -> bool:
|
| 81 |
+
"""Internal delete (assumes lock is held)."""
|
| 82 |
+
if task_id in self._tasks:
|
| 83 |
+
del self._tasks[task_id]
|
| 84 |
+
del self._timestamps[task_id]
|
| 85 |
+
return True
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
def list_tasks(self) -> List[Dict[str, Any]]:
|
| 89 |
+
"""
|
| 90 |
+
List all active (non-expired) tasks.
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
List of task dictionaries
|
| 94 |
+
"""
|
| 95 |
+
with self._lock:
|
| 96 |
+
self._cleanup_expired()
|
| 97 |
+
return [
|
| 98 |
+
{
|
| 99 |
+
"id": task["id"],
|
| 100 |
+
"label": task.get("label", task.get("id")),
|
| 101 |
+
"difficulty": task.get("difficulty", "custom"),
|
| 102 |
+
"description": task.get("description", ""),
|
| 103 |
+
"uploaded_at": task.get("uploaded_at"),
|
| 104 |
+
"is_custom_upload": True,
|
| 105 |
+
}
|
| 106 |
+
for task in self._tasks.values()
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
def cleanup_expired(self) -> int:
|
| 110 |
+
"""
|
| 111 |
+
Remove expired tasks.
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Number of tasks removed
|
| 115 |
+
"""
|
| 116 |
+
with self._lock:
|
| 117 |
+
return self._cleanup_expired()
|
| 118 |
+
|
| 119 |
+
def _cleanup_expired(self) -> int:
|
| 120 |
+
"""Internal cleanup (assumes lock is held)."""
|
| 121 |
+
expired = [
|
| 122 |
+
task_id for task_id in self._tasks.keys()
|
| 123 |
+
if self._is_expired(task_id)
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
for task_id in expired:
|
| 127 |
+
self._delete_task(task_id)
|
| 128 |
+
|
| 129 |
+
return len(expired)
|
| 130 |
+
|
| 131 |
+
def _is_expired(self, task_id: str) -> bool:
|
| 132 |
+
"""Check if a task has expired."""
|
| 133 |
+
if task_id not in self._timestamps:
|
| 134 |
+
return True
|
| 135 |
+
|
| 136 |
+
age = datetime.now() - self._timestamps[task_id]
|
| 137 |
+
return age > self._ttl
|
| 138 |
+
|
| 139 |
+
def start_background_cleanup(self, interval_seconds: int = 300):
|
| 140 |
+
"""
|
| 141 |
+
Start background thread that periodically cleans up expired tasks.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
interval_seconds: Cleanup interval (default: 5 minutes)
|
| 145 |
+
"""
|
| 146 |
+
if self._running:
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
self._running = True
|
| 150 |
+
|
| 151 |
+
def cleanup_loop():
|
| 152 |
+
while self._running:
|
| 153 |
+
time.sleep(interval_seconds)
|
| 154 |
+
if self._running:
|
| 155 |
+
removed = self.cleanup_expired()
|
| 156 |
+
if removed > 0:
|
| 157 |
+
print(f"DynamicTaskStore: Cleaned up {removed} expired task(s)")
|
| 158 |
+
|
| 159 |
+
self._cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True)
|
| 160 |
+
self._cleanup_thread.start()
|
| 161 |
+
|
| 162 |
+
def stop_background_cleanup(self):
|
| 163 |
+
"""Stop the background cleanup thread."""
|
| 164 |
+
self._running = False
|
| 165 |
+
if self._cleanup_thread:
|
| 166 |
+
self._cleanup_thread.join(timeout=1)
|
| 167 |
+
|
| 168 |
+
def clear(self):
|
| 169 |
+
"""Clear all tasks (for testing)."""
|
| 170 |
+
with self._lock:
|
| 171 |
+
self._tasks.clear()
|
| 172 |
+
self._timestamps.clear()
|
| 173 |
+
|
| 174 |
+
def __len__(self) -> int:
|
| 175 |
+
"""Return number of stored tasks."""
|
| 176 |
+
with self._lock:
|
| 177 |
+
return len(self._tasks)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# Global instance
|
| 181 |
+
_store = DynamicTaskStore()
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# Public API
|
| 185 |
+
def get_store() -> DynamicTaskStore:
|
| 186 |
+
"""Get the global task store instance."""
|
| 187 |
+
return _store
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def store_dynamic_task(task: Dict[str, Any]) -> str:
|
| 191 |
+
"""Store a dynamic task."""
|
| 192 |
+
return _store.store_task(task)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def get_dynamic_task(task_id: str) -> Dict[str, Any] | None:
|
| 196 |
+
"""Retrieve a dynamic task."""
|
| 197 |
+
return _store.get_task(task_id)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def delete_dynamic_task(task_id: str) -> bool:
|
| 201 |
+
"""Delete a dynamic task."""
|
| 202 |
+
return _store.delete_task(task_id)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def list_dynamic_tasks() -> List[Dict[str, Any]]:
|
| 206 |
+
"""List all dynamic tasks."""
|
| 207 |
+
return _store.list_tasks()
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def cleanup_expired_tasks() -> int:
|
| 211 |
+
"""Clean up expired tasks."""
|
| 212 |
+
return _store.cleanup_expired()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Initialize background cleanup on import
|
| 216 |
+
_store.start_background_cleanup()
|
frontend/package-lock.json
CHANGED
|
@@ -50,6 +50,7 @@
|
|
| 50 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 51 |
"dev": true,
|
| 52 |
"license": "MIT",
|
|
|
|
| 53 |
"dependencies": {
|
| 54 |
"@babel/code-frame": "^7.29.0",
|
| 55 |
"@babel/generator": "^7.29.0",
|
|
@@ -1164,6 +1165,7 @@
|
|
| 1164 |
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
| 1165 |
"dev": true,
|
| 1166 |
"license": "MIT",
|
|
|
|
| 1167 |
"dependencies": {
|
| 1168 |
"@types/prop-types": "*",
|
| 1169 |
"csstype": "^3.2.2"
|
|
@@ -1250,6 +1252,7 @@
|
|
| 1250 |
}
|
| 1251 |
],
|
| 1252 |
"license": "MIT",
|
|
|
|
| 1253 |
"dependencies": {
|
| 1254 |
"baseline-browser-mapping": "^2.10.12",
|
| 1255 |
"caniuse-lite": "^1.0.30001782",
|
|
@@ -1791,6 +1794,7 @@
|
|
| 1791 |
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1792 |
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1793 |
"license": "MIT",
|
|
|
|
| 1794 |
"dependencies": {
|
| 1795 |
"loose-envify": "^1.1.0"
|
| 1796 |
},
|
|
@@ -1932,6 +1936,7 @@
|
|
| 1932 |
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1933 |
"dev": true,
|
| 1934 |
"license": "MIT",
|
|
|
|
| 1935 |
"dependencies": {
|
| 1936 |
"esbuild": "^0.21.3",
|
| 1937 |
"postcss": "^8.4.43",
|
|
|
|
| 50 |
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
| 51 |
"dev": true,
|
| 52 |
"license": "MIT",
|
| 53 |
+
"peer": true,
|
| 54 |
"dependencies": {
|
| 55 |
"@babel/code-frame": "^7.29.0",
|
| 56 |
"@babel/generator": "^7.29.0",
|
|
|
|
| 1165 |
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
| 1166 |
"dev": true,
|
| 1167 |
"license": "MIT",
|
| 1168 |
+
"peer": true,
|
| 1169 |
"dependencies": {
|
| 1170 |
"@types/prop-types": "*",
|
| 1171 |
"csstype": "^3.2.2"
|
|
|
|
| 1252 |
}
|
| 1253 |
],
|
| 1254 |
"license": "MIT",
|
| 1255 |
+
"peer": true,
|
| 1256 |
"dependencies": {
|
| 1257 |
"baseline-browser-mapping": "^2.10.12",
|
| 1258 |
"caniuse-lite": "^1.0.30001782",
|
|
|
|
| 1794 |
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 1795 |
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 1796 |
"license": "MIT",
|
| 1797 |
+
"peer": true,
|
| 1798 |
"dependencies": {
|
| 1799 |
"loose-envify": "^1.1.0"
|
| 1800 |
},
|
|
|
|
| 1936 |
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 1937 |
"dev": true,
|
| 1938 |
"license": "MIT",
|
| 1939 |
+
"peer": true,
|
| 1940 |
"dependencies": {
|
| 1941 |
"esbuild": "^0.21.3",
|
| 1942 |
"postcss": "^8.4.43",
|
frontend/src/App.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { useState, useCallback } from 'react';
|
| 2 |
import Header from './components/Header';
|
| 3 |
import TaskSelector from './components/TaskSelector';
|
|
|
|
| 4 |
import PRViewer from './components/PRViewer';
|
| 5 |
import ActionPanel from './components/ActionPanel';
|
| 6 |
import StateViewer from './components/StateViewer';
|
|
@@ -19,6 +20,7 @@ export default function App() {
|
|
| 19 |
const [toast, setToast] = useState(null);
|
| 20 |
const [loading, setLoading] = useState(false);
|
| 21 |
const [autoPilotRunning, setAutoPilotRunning] = useState(false);
|
|
|
|
| 22 |
|
| 23 |
const showToast = (message, type = 'info') => {
|
| 24 |
setToast({ message, type });
|
|
@@ -51,12 +53,14 @@ export default function App() {
|
|
| 51 |
description: data.description,
|
| 52 |
issue_title: data.issue_title,
|
| 53 |
issue_body: data.issue_body,
|
|
|
|
| 54 |
});
|
| 55 |
setDone(false);
|
| 56 |
setScore(null);
|
| 57 |
setReport(null);
|
| 58 |
setState(data.state);
|
| 59 |
-
|
|
|
|
| 60 |
} catch (err) {
|
| 61 |
const msg = err.response?.data?.error || 'Failed to start task';
|
| 62 |
showToast(msg, 'error');
|
|
@@ -65,6 +69,14 @@ export default function App() {
|
|
| 65 |
}
|
| 66 |
};
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const handleAction = useCallback(async (actionType, payload) => {
|
| 69 |
try {
|
| 70 |
const data = await stepAction(actionType, payload);
|
|
@@ -76,9 +88,10 @@ export default function App() {
|
|
| 76 |
setScore(data.score);
|
| 77 |
setReport(data.report);
|
| 78 |
const status = data.report?.grade_status || '';
|
|
|
|
| 79 |
showToast(
|
| 80 |
`Episode finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
|
| 81 |
-
|
| 82 |
);
|
| 83 |
} else {
|
| 84 |
const suffix = payload?.path || payload?.text || actionType;
|
|
@@ -115,9 +128,10 @@ export default function App() {
|
|
| 115 |
setScore(data.score);
|
| 116 |
setReport(data.report);
|
| 117 |
const status = data.report?.grade_status || '';
|
|
|
|
| 118 |
showToast(
|
| 119 |
`Auto-pilot finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
|
| 120 |
-
|
| 121 |
);
|
| 122 |
}
|
| 123 |
} catch (err) {
|
|
@@ -149,6 +163,11 @@ export default function App() {
|
|
| 149 |
disabled={loading || autoPilotRunning}
|
| 150 |
loading={loading}
|
| 151 |
selectedTask={selectedTask}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
/>
|
| 153 |
{observation && !done && (
|
| 154 |
<button
|
|
|
|
| 1 |
import { useState, useCallback } from 'react';
|
| 2 |
import Header from './components/Header';
|
| 3 |
import TaskSelector from './components/TaskSelector';
|
| 4 |
+
import UploadPanel from './components/UploadPanel';
|
| 5 |
import PRViewer from './components/PRViewer';
|
| 6 |
import ActionPanel from './components/ActionPanel';
|
| 7 |
import StateViewer from './components/StateViewer';
|
|
|
|
| 20 |
const [toast, setToast] = useState(null);
|
| 21 |
const [loading, setLoading] = useState(false);
|
| 22 |
const [autoPilotRunning, setAutoPilotRunning] = useState(false);
|
| 23 |
+
const [refreshTasks, setRefreshTasks] = useState(0);
|
| 24 |
|
| 25 |
const showToast = (message, type = 'info') => {
|
| 26 |
setToast({ message, type });
|
|
|
|
| 53 |
description: data.description,
|
| 54 |
issue_title: data.issue_title,
|
| 55 |
issue_body: data.issue_body,
|
| 56 |
+
is_custom_upload: data.is_custom_upload,
|
| 57 |
});
|
| 58 |
setDone(false);
|
| 59 |
setScore(null);
|
| 60 |
setReport(null);
|
| 61 |
setState(data.state);
|
| 62 |
+
const taskType = data.is_custom_upload ? '📤 Custom' : '';
|
| 63 |
+
showToast(`${taskType} Task "${data.task_id}" loaded — ${data.issue_title}`, 'success');
|
| 64 |
} catch (err) {
|
| 65 |
const msg = err.response?.data?.error || 'Failed to start task';
|
| 66 |
showToast(msg, 'error');
|
|
|
|
| 69 |
}
|
| 70 |
};
|
| 71 |
|
| 72 |
+
const handleUploadComplete = (result) => {
|
| 73 |
+
showToast(`Uploaded! Task "${result.task_id}" created. Select it from the dropdown.`, 'success');
|
| 74 |
+
// Trigger task list refresh
|
| 75 |
+
setRefreshTasks(prev => prev + 1);
|
| 76 |
+
// Auto-select the new task
|
| 77 |
+
setSelectedTask(result.task_id);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
const handleAction = useCallback(async (actionType, payload) => {
|
| 81 |
try {
|
| 82 |
const data = await stepAction(actionType, payload);
|
|
|
|
| 88 |
setScore(data.score);
|
| 89 |
setReport(data.report);
|
| 90 |
const status = data.report?.grade_status || '';
|
| 91 |
+
const statusType = status === 'PASS' || status === 'REVIEWED' ? 'success' : 'error';
|
| 92 |
showToast(
|
| 93 |
`Episode finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
|
| 94 |
+
statusType
|
| 95 |
);
|
| 96 |
} else {
|
| 97 |
const suffix = payload?.path || payload?.text || actionType;
|
|
|
|
| 128 |
setScore(data.score);
|
| 129 |
setReport(data.report);
|
| 130 |
const status = data.report?.grade_status || '';
|
| 131 |
+
const statusType = status === 'PASS' || status === 'REVIEWED' ? 'success' : 'error';
|
| 132 |
showToast(
|
| 133 |
`Auto-pilot finished! Score: ${(data.score * 100).toFixed(1)}% — ${status}`,
|
| 134 |
+
statusType
|
| 135 |
);
|
| 136 |
}
|
| 137 |
} catch (err) {
|
|
|
|
| 163 |
disabled={loading || autoPilotRunning}
|
| 164 |
loading={loading}
|
| 165 |
selectedTask={selectedTask}
|
| 166 |
+
refreshKey={refreshTasks}
|
| 167 |
+
/>
|
| 168 |
+
<UploadPanel
|
| 169 |
+
onUploadComplete={handleUploadComplete}
|
| 170 |
+
disabled={loading || autoPilotRunning}
|
| 171 |
/>
|
| 172 |
{observation && !done && (
|
| 173 |
<button
|
frontend/src/components/TaskSelector.jsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { fetchTasks } from '../services/api';
|
| 3 |
|
| 4 |
-
export default function TaskSelector({ onSelect, onStart, disabled, loading, selectedTask }) {
|
| 5 |
const [tasks, setTasks] = useState([]);
|
| 6 |
const [fetchLoading, setFetchLoading] = useState(true);
|
| 7 |
const [error, setError] = useState(null);
|
| 8 |
|
| 9 |
useEffect(() => {
|
|
|
|
| 10 |
fetchTasks()
|
| 11 |
.then((data) => {
|
| 12 |
setTasks(data.tasks || []);
|
|
@@ -16,7 +17,7 @@ export default function TaskSelector({ onSelect, onStart, disabled, loading, sel
|
|
| 16 |
setError('Failed to load tasks');
|
| 17 |
setFetchLoading(false);
|
| 18 |
});
|
| 19 |
-
}, []);
|
| 20 |
|
| 21 |
const handleSelect = (e) => {
|
| 22 |
const val = e.target.value;
|
|
|
|
| 1 |
import { useState, useEffect } from 'react';
|
| 2 |
import { fetchTasks } from '../services/api';
|
| 3 |
|
| 4 |
+
export default function TaskSelector({ onSelect, onStart, disabled, loading, selectedTask, refreshKey = 0 }) {
|
| 5 |
const [tasks, setTasks] = useState([]);
|
| 6 |
const [fetchLoading, setFetchLoading] = useState(true);
|
| 7 |
const [error, setError] = useState(null);
|
| 8 |
|
| 9 |
useEffect(() => {
|
| 10 |
+
setFetchLoading(true);
|
| 11 |
fetchTasks()
|
| 12 |
.then((data) => {
|
| 13 |
setTasks(data.tasks || []);
|
|
|
|
| 17 |
setError('Failed to load tasks');
|
| 18 |
setFetchLoading(false);
|
| 19 |
});
|
| 20 |
+
}, [refreshKey]);
|
| 21 |
|
| 22 |
const handleSelect = (e) => {
|
| 23 |
const val = e.target.value;
|
frontend/src/components/UploadPanel.jsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from 'react';
|
| 2 |
+
import { uploadCode } from '../services/api';
|
| 3 |
+
|
| 4 |
+
export default function UploadPanel({ onUploadComplete, disabled }) {
|
| 5 |
+
const [isOpen, setIsOpen] = useState(false);
|
| 6 |
+
const [title, setTitle] = useState('');
|
| 7 |
+
const [description, setDescription] = useState('');
|
| 8 |
+
const [files, setFiles] = useState([]);
|
| 9 |
+
const [originalFiles, setOriginalFiles] = useState([]);
|
| 10 |
+
const [uploading, setUploading] = useState(false);
|
| 11 |
+
const [error, setError] = useState(null);
|
| 12 |
+
const [dragActive, setDragActive] = useState(false);
|
| 13 |
+
|
| 14 |
+
const fileInputRef = useRef(null);
|
| 15 |
+
const originalInputRef = useRef(null);
|
| 16 |
+
|
| 17 |
+
const handleDrag = (e) => {
|
| 18 |
+
e.preventDefault();
|
| 19 |
+
e.stopPropagation();
|
| 20 |
+
if (e.type === 'dragenter' || e.type === 'dragover') {
|
| 21 |
+
setDragActive(true);
|
| 22 |
+
} else if (e.type === 'dragleave') {
|
| 23 |
+
setDragActive(false);
|
| 24 |
+
}
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
const handleDrop = (e) => {
|
| 28 |
+
e.preventDefault();
|
| 29 |
+
e.stopPropagation();
|
| 30 |
+
setDragActive(false);
|
| 31 |
+
|
| 32 |
+
const droppedFiles = [...e.dataTransfer.files];
|
| 33 |
+
setFiles(prev => [...prev, ...droppedFiles]);
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const handleFileSelect = (e) => {
|
| 37 |
+
const selectedFiles = [...e.target.files];
|
| 38 |
+
setFiles(prev => [...prev, ...selectedFiles]);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const handleOriginalSelect = (e) => {
|
| 42 |
+
const selectedFiles = [...e.target.files];
|
| 43 |
+
setOriginalFiles(prev => [...prev, ...selectedFiles]);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const removeFile = (index, isOriginal = false) => {
|
| 47 |
+
if (isOriginal) {
|
| 48 |
+
setOriginalFiles(prev => prev.filter((_, i) => i !== index));
|
| 49 |
+
} else {
|
| 50 |
+
setFiles(prev => prev.filter((_, i) => i !== index));
|
| 51 |
+
}
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
const handleSubmit = async () => {
|
| 55 |
+
if (!title.trim()) {
|
| 56 |
+
setError('Title is required');
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
if (!description.trim()) {
|
| 60 |
+
setError('Description is required');
|
| 61 |
+
return;
|
| 62 |
+
}
|
| 63 |
+
if (files.length === 0) {
|
| 64 |
+
setError('At least one file is required');
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
setUploading(true);
|
| 69 |
+
setError(null);
|
| 70 |
+
|
| 71 |
+
try {
|
| 72 |
+
const result = await uploadCode(title, description, files, originalFiles);
|
| 73 |
+
|
| 74 |
+
// Reset form
|
| 75 |
+
setTitle('');
|
| 76 |
+
setDescription('');
|
| 77 |
+
setFiles([]);
|
| 78 |
+
setOriginalFiles([]);
|
| 79 |
+
setIsOpen(false);
|
| 80 |
+
|
| 81 |
+
// Notify parent
|
| 82 |
+
if (onUploadComplete) {
|
| 83 |
+
onUploadComplete(result);
|
| 84 |
+
}
|
| 85 |
+
} catch (err) {
|
| 86 |
+
setError(err.response?.data?.error || 'Upload failed');
|
| 87 |
+
} finally {
|
| 88 |
+
setUploading(false);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handleClear = () => {
|
| 93 |
+
setTitle('');
|
| 94 |
+
setDescription('');
|
| 95 |
+
setFiles([]);
|
| 96 |
+
setOriginalFiles([]);
|
| 97 |
+
setError(null);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const handleClose = () => {
|
| 101 |
+
setIsOpen(false);
|
| 102 |
+
setError(null);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<>
|
| 107 |
+
<button
|
| 108 |
+
className="btn btn-upload-toggle"
|
| 109 |
+
onClick={() => setIsOpen(true)}
|
| 110 |
+
disabled={disabled}
|
| 111 |
+
>
|
| 112 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 113 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 114 |
+
<polyline points="17 8 12 3 7 8"/>
|
| 115 |
+
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 116 |
+
</svg>
|
| 117 |
+
Upload Code
|
| 118 |
+
</button>
|
| 119 |
+
|
| 120 |
+
{isOpen && (
|
| 121 |
+
<div className="upload-modal-overlay" onClick={handleClose}>
|
| 122 |
+
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
|
| 123 |
+
<div className="upload-modal-header">
|
| 124 |
+
<h3>📤 Upload Custom Code for Review</h3>
|
| 125 |
+
<button className="btn btn-icon" onClick={handleClose} title="Close">
|
| 126 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 127 |
+
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
| 128 |
+
</svg>
|
| 129 |
+
</button>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<div className="upload-modal-body">
|
| 133 |
+
<div className="upload-form-row">
|
| 134 |
+
<div className="form-group">
|
| 135 |
+
<label>PR / Issue Title *</label>
|
| 136 |
+
<input
|
| 137 |
+
type="text"
|
| 138 |
+
value={title}
|
| 139 |
+
onChange={(e) => setTitle(e.target.value)}
|
| 140 |
+
placeholder="e.g., Fix authentication bug in login handler"
|
| 141 |
+
disabled={uploading}
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="upload-form-row">
|
| 147 |
+
<div className="form-group">
|
| 148 |
+
<label>Description *</label>
|
| 149 |
+
<textarea
|
| 150 |
+
value={description}
|
| 151 |
+
onChange={(e) => setDescription(e.target.value)}
|
| 152 |
+
placeholder="Describe what this PR/code change is about..."
|
| 153 |
+
rows={2}
|
| 154 |
+
disabled={uploading}
|
| 155 |
+
/>
|
| 156 |
+
</div>
|
| 157 |
+
</div>
|
| 158 |
+
|
| 159 |
+
<div className="upload-form-columns">
|
| 160 |
+
<div className="form-group">
|
| 161 |
+
<label>Modified Files * (drag & drop)</label>
|
| 162 |
+
<div
|
| 163 |
+
className={`drop-zone ${dragActive ? 'active' : ''}`}
|
| 164 |
+
onDragEnter={handleDrag}
|
| 165 |
+
onDragLeave={handleDrag}
|
| 166 |
+
onDragOver={handleDrag}
|
| 167 |
+
onDrop={handleDrop}
|
| 168 |
+
onClick={() => fileInputRef.current?.click()}
|
| 169 |
+
>
|
| 170 |
+
<input
|
| 171 |
+
ref={fileInputRef}
|
| 172 |
+
type="file"
|
| 173 |
+
multiple
|
| 174 |
+
onChange={handleFileSelect}
|
| 175 |
+
style={{ display: 'none' }}
|
| 176 |
+
/>
|
| 177 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 178 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 179 |
+
<polyline points="17 8 12 3 7 8"/>
|
| 180 |
+
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 181 |
+
</svg>
|
| 182 |
+
<span>Drop files or click</span>
|
| 183 |
+
</div>
|
| 184 |
+
{files.length > 0 && (
|
| 185 |
+
<div className="file-list">
|
| 186 |
+
{files.map((file, i) => (
|
| 187 |
+
<div key={i} className="file-item">
|
| 188 |
+
<span className="file-name">📄 {file.name}</span>
|
| 189 |
+
<button className="btn btn-icon-sm" onClick={() => removeFile(i)}>×</button>
|
| 190 |
+
</div>
|
| 191 |
+
))}
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<div className="form-group">
|
| 197 |
+
<label>Original Files (optional)</label>
|
| 198 |
+
<div
|
| 199 |
+
className="drop-zone drop-zone-secondary"
|
| 200 |
+
onClick={() => originalInputRef.current?.click()}
|
| 201 |
+
>
|
| 202 |
+
<input
|
| 203 |
+
ref={originalInputRef}
|
| 204 |
+
type="file"
|
| 205 |
+
multiple
|
| 206 |
+
onChange={handleOriginalSelect}
|
| 207 |
+
style={{ display: 'none' }}
|
| 208 |
+
/>
|
| 209 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 210 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 211 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 212 |
+
</svg>
|
| 213 |
+
<span>For diff generation</span>
|
| 214 |
+
</div>
|
| 215 |
+
{originalFiles.length > 0 && (
|
| 216 |
+
<div className="file-list file-list-original">
|
| 217 |
+
{originalFiles.map((file, i) => (
|
| 218 |
+
<div key={i} className="file-item">
|
| 219 |
+
<span className="file-name">📋 {file.name}</span>
|
| 220 |
+
<button className="btn btn-icon-sm" onClick={() => removeFile(i, true)}>×</button>
|
| 221 |
+
</div>
|
| 222 |
+
))}
|
| 223 |
+
</div>
|
| 224 |
+
)}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
{error && <div className="upload-error">{error}</div>}
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div className="upload-modal-footer">
|
| 232 |
+
<button
|
| 233 |
+
className="btn btn-secondary"
|
| 234 |
+
onClick={handleClear}
|
| 235 |
+
disabled={uploading}
|
| 236 |
+
>
|
| 237 |
+
Clear
|
| 238 |
+
</button>
|
| 239 |
+
<button
|
| 240 |
+
className="btn btn-primary"
|
| 241 |
+
onClick={handleSubmit}
|
| 242 |
+
disabled={uploading || files.length === 0}
|
| 243 |
+
>
|
| 244 |
+
{uploading ? (
|
| 245 |
+
<>
|
| 246 |
+
<span className="btn-spinner" />
|
| 247 |
+
Uploading...
|
| 248 |
+
</>
|
| 249 |
+
) : (
|
| 250 |
+
<>
|
| 251 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 252 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 253 |
+
<polyline points="17 8 12 3 7 8"/>
|
| 254 |
+
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 255 |
+
</svg>
|
| 256 |
+
Upload & Create Task
|
| 257 |
+
</>
|
| 258 |
+
)}
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
</div>
|
| 263 |
+
)}
|
| 264 |
+
</>
|
| 265 |
+
);
|
| 266 |
+
}
|
frontend/src/index.css
CHANGED
|
@@ -1116,3 +1116,274 @@ body {
|
|
| 1116 |
|
| 1117 |
.task-select { min-width: 180px; }
|
| 1118 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1116 |
|
| 1117 |
.task-select { min-width: 180px; }
|
| 1118 |
}
|
| 1119 |
+
|
| 1120 |
+
/* ═══════════ UPLOAD MODAL ═══════════ */
|
| 1121 |
+
.btn-upload-toggle {
|
| 1122 |
+
background: var(--surface-2);
|
| 1123 |
+
border: 1px dashed var(--border-hi);
|
| 1124 |
+
color: var(--text-2);
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
.btn-upload-toggle:hover:not(:disabled) {
|
| 1128 |
+
background: var(--surface-3);
|
| 1129 |
+
border-color: var(--accent);
|
| 1130 |
+
color: var(--accent);
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
+
.upload-modal-overlay {
|
| 1134 |
+
position: fixed;
|
| 1135 |
+
top: 0;
|
| 1136 |
+
left: 0;
|
| 1137 |
+
right: 0;
|
| 1138 |
+
bottom: 0;
|
| 1139 |
+
background: rgba(0, 0, 0, 0.7);
|
| 1140 |
+
backdrop-filter: blur(4px);
|
| 1141 |
+
display: flex;
|
| 1142 |
+
align-items: center;
|
| 1143 |
+
justify-content: center;
|
| 1144 |
+
z-index: 1000;
|
| 1145 |
+
animation: fadeIn 0.2s ease;
|
| 1146 |
+
}
|
| 1147 |
+
|
| 1148 |
+
@keyframes fadeIn {
|
| 1149 |
+
from { opacity: 0; }
|
| 1150 |
+
to { opacity: 1; }
|
| 1151 |
+
}
|
| 1152 |
+
|
| 1153 |
+
.upload-modal {
|
| 1154 |
+
background: var(--surface);
|
| 1155 |
+
border: 1px solid var(--border);
|
| 1156 |
+
border-radius: var(--radius-lg);
|
| 1157 |
+
width: 90%;
|
| 1158 |
+
max-width: 600px;
|
| 1159 |
+
max-height: 85vh;
|
| 1160 |
+
display: flex;
|
| 1161 |
+
flex-direction: column;
|
| 1162 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
| 1163 |
+
animation: slideUp 0.25s ease;
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
@keyframes slideUp {
|
| 1167 |
+
from { transform: translateY(20px); opacity: 0; }
|
| 1168 |
+
to { transform: translateY(0); opacity: 1; }
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
.upload-modal-header {
|
| 1172 |
+
display: flex;
|
| 1173 |
+
align-items: center;
|
| 1174 |
+
justify-content: space-between;
|
| 1175 |
+
padding: 16px 20px;
|
| 1176 |
+
background: var(--surface-2);
|
| 1177 |
+
border-bottom: 1px solid var(--border);
|
| 1178 |
+
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
.upload-modal-header h3 {
|
| 1182 |
+
font-size: 1rem;
|
| 1183 |
+
font-weight: 600;
|
| 1184 |
+
color: var(--text);
|
| 1185 |
+
margin: 0;
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
.upload-modal-body {
|
| 1189 |
+
padding: 20px;
|
| 1190 |
+
overflow-y: auto;
|
| 1191 |
+
flex: 1;
|
| 1192 |
+
}
|
| 1193 |
+
|
| 1194 |
+
.upload-form-row {
|
| 1195 |
+
margin-bottom: 16px;
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
.upload-form-columns {
|
| 1199 |
+
display: grid;
|
| 1200 |
+
grid-template-columns: 1fr 1fr;
|
| 1201 |
+
gap: 16px;
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
.form-group {
|
| 1205 |
+
display: flex;
|
| 1206 |
+
flex-direction: column;
|
| 1207 |
+
gap: 6px;
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
.form-group label {
|
| 1211 |
+
font-size: 0.75rem;
|
| 1212 |
+
font-weight: 600;
|
| 1213 |
+
color: var(--text-2);
|
| 1214 |
+
text-transform: uppercase;
|
| 1215 |
+
letter-spacing: 0.04em;
|
| 1216 |
+
}
|
| 1217 |
+
|
| 1218 |
+
.form-group input,
|
| 1219 |
+
.form-group textarea {
|
| 1220 |
+
background: var(--bg);
|
| 1221 |
+
color: var(--text);
|
| 1222 |
+
border: 1px solid var(--border);
|
| 1223 |
+
border-radius: var(--radius);
|
| 1224 |
+
padding: 10px 12px;
|
| 1225 |
+
font-family: var(--font);
|
| 1226 |
+
font-size: 0.85rem;
|
| 1227 |
+
transition: border-color 0.15s;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
.form-group input:focus,
|
| 1231 |
+
.form-group textarea:focus {
|
| 1232 |
+
outline: none;
|
| 1233 |
+
border-color: var(--accent);
|
| 1234 |
+
box-shadow: 0 0 0 2px rgba(124,110,240,0.15);
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
.form-group input::placeholder,
|
| 1238 |
+
.form-group textarea::placeholder {
|
| 1239 |
+
color: var(--text-3);
|
| 1240 |
+
}
|
| 1241 |
+
|
| 1242 |
+
.drop-zone {
|
| 1243 |
+
display: flex;
|
| 1244 |
+
flex-direction: column;
|
| 1245 |
+
align-items: center;
|
| 1246 |
+
justify-content: center;
|
| 1247 |
+
gap: 6px;
|
| 1248 |
+
padding: 20px 12px;
|
| 1249 |
+
border: 2px dashed var(--border);
|
| 1250 |
+
border-radius: var(--radius);
|
| 1251 |
+
background: var(--bg);
|
| 1252 |
+
cursor: pointer;
|
| 1253 |
+
transition: all 0.2s;
|
| 1254 |
+
min-height: 100px;
|
| 1255 |
+
}
|
| 1256 |
+
|
| 1257 |
+
.drop-zone:hover,
|
| 1258 |
+
.drop-zone.active {
|
| 1259 |
+
border-color: var(--accent);
|
| 1260 |
+
background: rgba(124,110,240,0.05);
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
.drop-zone svg {
|
| 1264 |
+
color: var(--text-3);
|
| 1265 |
+
transition: color 0.2s;
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
.drop-zone:hover svg,
|
| 1269 |
+
.drop-zone.active svg {
|
| 1270 |
+
color: var(--accent);
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
.drop-zone span {
|
| 1274 |
+
font-size: 0.78rem;
|
| 1275 |
+
color: var(--text-3);
|
| 1276 |
+
text-align: center;
|
| 1277 |
+
}
|
| 1278 |
+
|
| 1279 |
+
.drop-zone-secondary {
|
| 1280 |
+
border-style: dotted;
|
| 1281 |
+
background: var(--surface-2);
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
.file-list {
|
| 1285 |
+
display: flex;
|
| 1286 |
+
flex-direction: column;
|
| 1287 |
+
gap: 4px;
|
| 1288 |
+
margin-top: 8px;
|
| 1289 |
+
max-height: 120px;
|
| 1290 |
+
overflow-y: auto;
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
.file-item {
|
| 1294 |
+
display: flex;
|
| 1295 |
+
align-items: center;
|
| 1296 |
+
gap: 8px;
|
| 1297 |
+
padding: 6px 10px;
|
| 1298 |
+
background: var(--surface-2);
|
| 1299 |
+
border-radius: var(--radius);
|
| 1300 |
+
font-size: 0.8rem;
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
.file-name {
|
| 1304 |
+
flex: 1;
|
| 1305 |
+
color: var(--text);
|
| 1306 |
+
font-family: var(--font-mono);
|
| 1307 |
+
font-size: 0.75rem;
|
| 1308 |
+
overflow: hidden;
|
| 1309 |
+
text-overflow: ellipsis;
|
| 1310 |
+
white-space: nowrap;
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
.btn-icon-sm {
|
| 1314 |
+
padding: 2px 6px;
|
| 1315 |
+
font-size: 0.85rem;
|
| 1316 |
+
line-height: 1;
|
| 1317 |
+
border: none;
|
| 1318 |
+
background: transparent;
|
| 1319 |
+
color: var(--text-3);
|
| 1320 |
+
cursor: pointer;
|
| 1321 |
+
border-radius: 4px;
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
.btn-icon-sm:hover {
|
| 1325 |
+
background: var(--red-bg);
|
| 1326 |
+
color: var(--red);
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
.file-list-original .file-item {
|
| 1330 |
+
background: var(--blue-bg);
|
| 1331 |
+
border: 1px solid rgba(88,166,255,0.2);
|
| 1332 |
+
}
|
| 1333 |
+
|
| 1334 |
+
.upload-error {
|
| 1335 |
+
padding: 10px 12px;
|
| 1336 |
+
background: var(--red-bg);
|
| 1337 |
+
border: 1px solid rgba(248,81,73,0.3);
|
| 1338 |
+
border-radius: var(--radius);
|
| 1339 |
+
color: var(--red);
|
| 1340 |
+
font-size: 0.82rem;
|
| 1341 |
+
margin-top: 12px;
|
| 1342 |
+
}
|
| 1343 |
+
|
| 1344 |
+
.upload-modal-footer {
|
| 1345 |
+
display: flex;
|
| 1346 |
+
justify-content: flex-end;
|
| 1347 |
+
gap: 10px;
|
| 1348 |
+
padding: 16px 20px;
|
| 1349 |
+
background: var(--surface-2);
|
| 1350 |
+
border-top: 1px solid var(--border);
|
| 1351 |
+
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
| 1352 |
+
}
|
| 1353 |
+
|
| 1354 |
+
.btn-secondary {
|
| 1355 |
+
background: var(--surface-2);
|
| 1356 |
+
border: 1px solid var(--border);
|
| 1357 |
+
color: var(--text-2);
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
.btn-secondary:hover:not(:disabled) {
|
| 1361 |
+
background: var(--surface-3);
|
| 1362 |
+
color: var(--text);
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
.btn-icon {
|
| 1366 |
+
padding: 4px;
|
| 1367 |
+
background: transparent;
|
| 1368 |
+
border: none;
|
| 1369 |
+
color: var(--text-3);
|
| 1370 |
+
cursor: pointer;
|
| 1371 |
+
border-radius: 4px;
|
| 1372 |
+
transition: all 0.15s;
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
.btn-icon:hover {
|
| 1376 |
+
background: var(--surface-3);
|
| 1377 |
+
color: var(--text);
|
| 1378 |
+
}
|
| 1379 |
+
|
| 1380 |
+
@media (max-width: 600px) {
|
| 1381 |
+
.upload-form-columns {
|
| 1382 |
+
grid-template-columns: 1fr;
|
| 1383 |
+
}
|
| 1384 |
+
|
| 1385 |
+
.upload-modal {
|
| 1386 |
+
width: 95%;
|
| 1387 |
+
max-height: 90vh;
|
| 1388 |
+
}
|
| 1389 |
+
}
|
frontend/src/services/api.js
CHANGED
|
@@ -32,4 +32,28 @@ export async function autoAction() {
|
|
| 32 |
return res.data;
|
| 33 |
}
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
export default api;
|
|
|
|
| 32 |
return res.data;
|
| 33 |
}
|
| 34 |
|
| 35 |
+
export async function uploadCode(title, description, files, originalFiles = []) {
|
| 36 |
+
const formData = new FormData();
|
| 37 |
+
formData.append('title', title);
|
| 38 |
+
formData.append('description', description);
|
| 39 |
+
|
| 40 |
+
files.forEach(file => {
|
| 41 |
+
formData.append('files', file);
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
originalFiles.forEach(file => {
|
| 45 |
+
formData.append('original_files', file);
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
const res = await api.post('/api/upload', formData, {
|
| 49 |
+
headers: { 'Content-Type': 'multipart/form-data' },
|
| 50 |
+
});
|
| 51 |
+
return res.data;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export async function deleteUpload(taskId) {
|
| 55 |
+
const res = await api.delete(`/api/upload/${taskId}`);
|
| 56 |
+
return res.data;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
export default api;
|