Amal Nimmy Lal
commited on
Commit
·
698b2c1
1
Parent(s):
876e77a
feat : updated pages
Browse files- backend/.env.example +0 -0
- backend/Dockerfile +15 -30
- backend/app/agent_worker.py +77 -0
- backend/app/main.py +113 -3
- backend/app/models.py +8 -2
- backend/app/smart_query.py +173 -24
- backend/app/tools/memory.py +2 -1
- backend/app/tools/tasks.py +2 -0
- backend/entrypoint.sh +0 -10
- backend/tests/test_complete_api.py +1 -1
- frontend/Dockerfile +1 -1
- frontend/nginx.conf +3 -3
- frontend/src/api/client.ts +12 -3
- frontend/src/pages/ActivityPage.tsx +236 -86
- frontend/src/types/index.ts +2 -1
backend/.env.example
DELETED
|
File without changes
|
backend/Dockerfile
CHANGED
|
@@ -1,43 +1,28 @@
|
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
|
| 6 |
-
#
|
| 7 |
-
ENV HOME=/home/user \
|
| 8 |
-
PATH=/home/user/.local/bin:$PATH
|
| 9 |
-
|
| 10 |
-
# Set working directory early to avoid permission issues
|
| 11 |
-
WORKDIR $HOME/app
|
| 12 |
-
|
| 13 |
-
# Install system dependencies as root
|
| 14 |
RUN apt-get update && apt-get install -y \
|
| 15 |
gcc \
|
| 16 |
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
|
| 18 |
# Copy requirements first for better caching
|
| 19 |
-
COPY requirements.txt
|
| 20 |
-
|
| 21 |
-
# Install Python dependencies (system-wide)
|
| 22 |
-
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
| 23 |
-
|
| 24 |
-
# Copy the application and set ownership to 'user' at copy time to avoid expensive chowns
|
| 25 |
-
COPY --chown=user:user . $HOME/app
|
| 26 |
-
|
| 27 |
-
# Ensure entrypoint is executable (no-op if missing)
|
| 28 |
-
RUN chmod +x $HOME/app/entrypoint.sh || true
|
| 29 |
|
| 30 |
-
#
|
| 31 |
-
RUN
|
| 32 |
|
| 33 |
-
#
|
| 34 |
-
|
| 35 |
|
| 36 |
-
#
|
| 37 |
-
RUN
|
| 38 |
|
| 39 |
-
# Expose
|
| 40 |
-
EXPOSE
|
| 41 |
|
| 42 |
-
|
| 43 |
-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "
|
|
|
|
| 1 |
+
# Backend Dockerfile
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
|
| 7 |
+
# Install system dependencies
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
RUN apt-get update && apt-get install -y \
|
| 9 |
gcc \
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
# Copy requirements first for better caching
|
| 13 |
+
COPY requirements.txt .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
|
| 18 |
+
# Copy application code
|
| 19 |
+
COPY . .
|
| 20 |
|
| 21 |
+
# Create directory for database
|
| 22 |
+
RUN mkdir -p /app/data
|
| 23 |
|
| 24 |
+
# Expose port
|
| 25 |
+
EXPOSE 8000
|
| 26 |
|
| 27 |
+
# Run the application
|
| 28 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
backend/app/agent_worker.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Background agent worker that autonomously completes tasks."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import random
|
| 5 |
+
from app.database import SessionLocal
|
| 6 |
+
from app.models import Task, TaskStatus, Project, AI_AGENT_USER_ID
|
| 7 |
+
from app.tools.memory import complete_task
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
async def agent_loop():
|
| 11 |
+
"""Background loop that processes tasks for agent-enabled projects."""
|
| 12 |
+
print("[Agent] Worker started, waiting for initial delay...")
|
| 13 |
+
# Initial delay to let the server start up
|
| 14 |
+
await asyncio.sleep(10)
|
| 15 |
+
|
| 16 |
+
while True:
|
| 17 |
+
try:
|
| 18 |
+
await process_one_task()
|
| 19 |
+
except Exception as e:
|
| 20 |
+
print(f"[Agent] Error: {e}")
|
| 21 |
+
|
| 22 |
+
# Wait 25-35 seconds (jitter to avoid rate limit patterns)
|
| 23 |
+
wait_time = random.uniform(25, 35)
|
| 24 |
+
await asyncio.sleep(wait_time)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def process_one_task():
|
| 28 |
+
"""Pick a random todo task from agent-enabled projects and complete it."""
|
| 29 |
+
db = SessionLocal()
|
| 30 |
+
try:
|
| 31 |
+
# Find projects with agent enabled
|
| 32 |
+
projects = db.query(Project).filter(Project.agent_enabled == True).all()
|
| 33 |
+
if not projects:
|
| 34 |
+
return
|
| 35 |
+
|
| 36 |
+
# Pick random project
|
| 37 |
+
project = random.choice(projects)
|
| 38 |
+
|
| 39 |
+
# Find a random todo task
|
| 40 |
+
todo_tasks = db.query(Task).filter(
|
| 41 |
+
Task.project_id == project.id,
|
| 42 |
+
Task.status == TaskStatus.todo
|
| 43 |
+
).all()
|
| 44 |
+
|
| 45 |
+
if not todo_tasks:
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
task = random.choice(todo_tasks)
|
| 49 |
+
print(f"[Agent] Starting task: {task.title}")
|
| 50 |
+
|
| 51 |
+
# Step 1: Move to in_progress
|
| 52 |
+
task.status = TaskStatus.in_progress
|
| 53 |
+
task.working_by = AI_AGENT_USER_ID
|
| 54 |
+
db.commit()
|
| 55 |
+
|
| 56 |
+
# Step 2: "Work" on it (small delay for realism)
|
| 57 |
+
await asyncio.sleep(random.uniform(2, 5))
|
| 58 |
+
|
| 59 |
+
# Step 3: Complete the task
|
| 60 |
+
result = await complete_task(
|
| 61 |
+
task_id=task.id,
|
| 62 |
+
project_id=project.id,
|
| 63 |
+
user_id=AI_AGENT_USER_ID,
|
| 64 |
+
what_i_did=f"Implemented {task.title}. {task.description or 'Completed the task as specified.'}",
|
| 65 |
+
actor_type="agent"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if result.get("success"):
|
| 69 |
+
print(f"[Agent] Completed task: {task.title}")
|
| 70 |
+
else:
|
| 71 |
+
print(f"[Agent] Failed to complete task: {result.get('error')}")
|
| 72 |
+
|
| 73 |
+
except Exception as e:
|
| 74 |
+
print(f"[Agent] Error in process_one_task: {e}")
|
| 75 |
+
db.rollback()
|
| 76 |
+
finally:
|
| 77 |
+
db.close()
|
backend/app/main.py
CHANGED
|
@@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse
|
|
| 6 |
from contextlib import asynccontextmanager
|
| 7 |
from typing import List, Optional
|
| 8 |
import os
|
|
|
|
| 9 |
from dotenv import load_dotenv
|
| 10 |
|
| 11 |
from app import schemas
|
|
@@ -29,14 +30,39 @@ load_dotenv()
|
|
| 29 |
@asynccontextmanager
|
| 30 |
async def lifespan(app: FastAPI):
|
| 31 |
"""Initialize database and vector store on startup."""
|
| 32 |
-
from app.database import init_db
|
| 33 |
from app.vectorstore import init_vectorstore
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
init_db()
|
| 36 |
init_vectorstore()
|
| 37 |
print("[OK] Database and vector store initialized")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
yield
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# Initialize FastAPI app
|
| 42 |
app = FastAPI(
|
|
@@ -227,6 +253,81 @@ async def get_project_members(project_id: str):
|
|
| 227 |
db.close()
|
| 228 |
|
| 229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
# ==================== Task Endpoints ====================
|
| 231 |
@app.get("/api/projects/{project_id}/tasks")
|
| 232 |
async def get_project_tasks(project_id: str, status: Optional[str] = None):
|
|
@@ -346,9 +447,17 @@ async def update_task_status(task_id: str, request: dict):
|
|
| 346 |
raise HTTPException(status_code=404, detail="Task not found")
|
| 347 |
|
| 348 |
new_status = request.get("status")
|
|
|
|
|
|
|
| 349 |
if new_status not in ["todo", "in_progress", "done"]:
|
| 350 |
raise HTTPException(status_code=400, detail="Invalid status. Must be: todo, in_progress, or done")
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
task.status = TaskStatus(new_status)
|
| 353 |
db.commit()
|
| 354 |
db.refresh(task)
|
|
@@ -360,6 +469,7 @@ async def update_task_status(task_id: str, request: dict):
|
|
| 360 |
"description": task.description,
|
| 361 |
"status": task.status.value,
|
| 362 |
"assigned_to": task.assigned_to,
|
|
|
|
| 363 |
"created_at": task.created_at.isoformat() if task.created_at else None
|
| 364 |
}
|
| 365 |
except HTTPException:
|
|
@@ -539,6 +649,6 @@ if __name__ == "__main__":
|
|
| 539 |
uvicorn.run(
|
| 540 |
"app.main:app",
|
| 541 |
host="0.0.0.0",
|
| 542 |
-
port=
|
| 543 |
reload=True
|
| 544 |
)
|
|
|
|
| 6 |
from contextlib import asynccontextmanager
|
| 7 |
from typing import List, Optional
|
| 8 |
import os
|
| 9 |
+
import asyncio
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
|
| 12 |
from app import schemas
|
|
|
|
| 30 |
@asynccontextmanager
|
| 31 |
async def lifespan(app: FastAPI):
|
| 32 |
"""Initialize database and vector store on startup."""
|
| 33 |
+
from app.database import init_db, SessionLocal
|
| 34 |
from app.vectorstore import init_vectorstore
|
| 35 |
+
from app.models import User, AI_AGENT_USER_ID
|
| 36 |
+
from app.agent_worker import agent_loop
|
| 37 |
+
|
| 38 |
init_db()
|
| 39 |
init_vectorstore()
|
| 40 |
print("[OK] Database and vector store initialized")
|
| 41 |
+
|
| 42 |
+
# Ensure AI Agent user exists
|
| 43 |
+
db = SessionLocal()
|
| 44 |
+
try:
|
| 45 |
+
if not db.query(User).filter(User.id == AI_AGENT_USER_ID).first():
|
| 46 |
+
db.add(User(id=AI_AGENT_USER_ID, first_name="AI", last_name="Agent"))
|
| 47 |
+
db.commit()
|
| 48 |
+
print("[OK] AI Agent user created")
|
| 49 |
+
finally:
|
| 50 |
+
db.close()
|
| 51 |
+
|
| 52 |
+
# Start agent worker in background
|
| 53 |
+
agent_task = asyncio.create_task(agent_loop())
|
| 54 |
+
print("[OK] Agent worker started")
|
| 55 |
+
|
| 56 |
yield
|
| 57 |
|
| 58 |
+
# Cleanup on shutdown
|
| 59 |
+
agent_task.cancel()
|
| 60 |
+
try:
|
| 61 |
+
await agent_task
|
| 62 |
+
except asyncio.CancelledError:
|
| 63 |
+
pass
|
| 64 |
+
print("[OK] Agent worker stopped")
|
| 65 |
+
|
| 66 |
|
| 67 |
# Initialize FastAPI app
|
| 68 |
app = FastAPI(
|
|
|
|
| 253 |
db.close()
|
| 254 |
|
| 255 |
|
| 256 |
+
# ==================== Agent Endpoints ====================
|
| 257 |
+
@app.post("/api/projects/{project_id}/agent/enable")
|
| 258 |
+
async def enable_agent(project_id: str):
|
| 259 |
+
"""Enable AI agent for this project. Adds agent to team."""
|
| 260 |
+
from app.database import get_db
|
| 261 |
+
from app.models import Project, ProjectMembership, AI_AGENT_USER_ID
|
| 262 |
+
|
| 263 |
+
db = next(get_db())
|
| 264 |
+
try:
|
| 265 |
+
# Check project exists
|
| 266 |
+
project = db.query(Project).filter(Project.id == project_id).first()
|
| 267 |
+
if not project:
|
| 268 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 269 |
+
|
| 270 |
+
# Enable agent
|
| 271 |
+
project.agent_enabled = True
|
| 272 |
+
|
| 273 |
+
# Add agent to project membership if not already
|
| 274 |
+
existing = db.query(ProjectMembership).filter(
|
| 275 |
+
ProjectMembership.project_id == project_id,
|
| 276 |
+
ProjectMembership.user_id == AI_AGENT_USER_ID
|
| 277 |
+
).first()
|
| 278 |
+
|
| 279 |
+
if not existing:
|
| 280 |
+
membership = ProjectMembership(
|
| 281 |
+
project_id=project_id,
|
| 282 |
+
user_id=AI_AGENT_USER_ID,
|
| 283 |
+
role="agent"
|
| 284 |
+
)
|
| 285 |
+
db.add(membership)
|
| 286 |
+
|
| 287 |
+
db.commit()
|
| 288 |
+
return {"message": "AI Agent enabled", "project_id": project_id}
|
| 289 |
+
except HTTPException:
|
| 290 |
+
raise
|
| 291 |
+
except Exception as e:
|
| 292 |
+
db.rollback()
|
| 293 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 294 |
+
finally:
|
| 295 |
+
db.close()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@app.post("/api/projects/{project_id}/agent/disable")
|
| 299 |
+
async def disable_agent(project_id: str):
|
| 300 |
+
"""Disable AI agent for this project. Removes from team."""
|
| 301 |
+
from app.database import get_db
|
| 302 |
+
from app.models import Project, ProjectMembership, AI_AGENT_USER_ID
|
| 303 |
+
|
| 304 |
+
db = next(get_db())
|
| 305 |
+
try:
|
| 306 |
+
# Check project exists
|
| 307 |
+
project = db.query(Project).filter(Project.id == project_id).first()
|
| 308 |
+
if not project:
|
| 309 |
+
raise HTTPException(status_code=404, detail="Project not found")
|
| 310 |
+
|
| 311 |
+
# Disable agent
|
| 312 |
+
project.agent_enabled = False
|
| 313 |
+
|
| 314 |
+
# Remove agent from project membership
|
| 315 |
+
db.query(ProjectMembership).filter(
|
| 316 |
+
ProjectMembership.project_id == project_id,
|
| 317 |
+
ProjectMembership.user_id == AI_AGENT_USER_ID
|
| 318 |
+
).delete()
|
| 319 |
+
|
| 320 |
+
db.commit()
|
| 321 |
+
return {"message": "AI Agent disabled", "project_id": project_id}
|
| 322 |
+
except HTTPException:
|
| 323 |
+
raise
|
| 324 |
+
except Exception as e:
|
| 325 |
+
db.rollback()
|
| 326 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 327 |
+
finally:
|
| 328 |
+
db.close()
|
| 329 |
+
|
| 330 |
+
|
| 331 |
# ==================== Task Endpoints ====================
|
| 332 |
@app.get("/api/projects/{project_id}/tasks")
|
| 333 |
async def get_project_tasks(project_id: str, status: Optional[str] = None):
|
|
|
|
| 447 |
raise HTTPException(status_code=404, detail="Task not found")
|
| 448 |
|
| 449 |
new_status = request.get("status")
|
| 450 |
+
user_id = request.get("userId") # Who is making this change
|
| 451 |
+
|
| 452 |
if new_status not in ["todo", "in_progress", "done"]:
|
| 453 |
raise HTTPException(status_code=400, detail="Invalid status. Must be: todo, in_progress, or done")
|
| 454 |
|
| 455 |
+
# Update working_by based on status
|
| 456 |
+
if new_status == "in_progress" and user_id:
|
| 457 |
+
task.working_by = user_id
|
| 458 |
+
elif new_status in ["todo", "done"]:
|
| 459 |
+
task.working_by = None # Clear when not in progress
|
| 460 |
+
|
| 461 |
task.status = TaskStatus(new_status)
|
| 462 |
db.commit()
|
| 463 |
db.refresh(task)
|
|
|
|
| 469 |
"description": task.description,
|
| 470 |
"status": task.status.value,
|
| 471 |
"assigned_to": task.assigned_to,
|
| 472 |
+
"working_by": task.working_by,
|
| 473 |
"created_at": task.created_at.isoformat() if task.created_at else None
|
| 474 |
}
|
| 475 |
except HTTPException:
|
|
|
|
| 649 |
uvicorn.run(
|
| 650 |
"app.main:app",
|
| 651 |
host="0.0.0.0",
|
| 652 |
+
port=8000,
|
| 653 |
reload=True
|
| 654 |
)
|
backend/app/models.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""SQLAlchemy models for Project Memory."""
|
| 2 |
|
| 3 |
-
from sqlalchemy import Column, String, DateTime, ForeignKey, Text, Enum, JSON
|
| 4 |
from sqlalchemy.orm import relationship
|
| 5 |
from datetime import datetime
|
| 6 |
import uuid
|
|
@@ -10,6 +10,10 @@ import random
|
|
| 10 |
from app.database import Base
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def generate_uuid() -> str:
|
| 14 |
"""Generate a new UUID string."""
|
| 15 |
return str(uuid.uuid4())
|
|
@@ -80,7 +84,8 @@ class Project(Base):
|
|
| 80 |
description = Column(Text, nullable=True)
|
| 81 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 82 |
created_by = Column(String, ForeignKey("users.id"), nullable=True)
|
| 83 |
-
|
|
|
|
| 84 |
# Relationships
|
| 85 |
creator = relationship("User", back_populates="created_projects")
|
| 86 |
memberships = relationship("ProjectMembership", back_populates="project")
|
|
@@ -113,6 +118,7 @@ class Task(Base):
|
|
| 113 |
description = Column(Text, nullable=True)
|
| 114 |
status = Column(Enum(TaskStatus), default=TaskStatus.todo)
|
| 115 |
assigned_to = Column(String, nullable=True) # userId or "agent"
|
|
|
|
| 116 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 117 |
completed_at = Column(DateTime, nullable=True)
|
| 118 |
|
|
|
|
| 1 |
"""SQLAlchemy models for Project Memory."""
|
| 2 |
|
| 3 |
+
from sqlalchemy import Column, String, DateTime, ForeignKey, Text, Enum, JSON, Boolean
|
| 4 |
from sqlalchemy.orm import relationship
|
| 5 |
from datetime import datetime
|
| 6 |
import uuid
|
|
|
|
| 10 |
from app.database import Base
|
| 11 |
|
| 12 |
|
| 13 |
+
# Special user ID for AI Agent
|
| 14 |
+
AI_AGENT_USER_ID = "ai-agent"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
def generate_uuid() -> str:
|
| 18 |
"""Generate a new UUID string."""
|
| 19 |
return str(uuid.uuid4())
|
|
|
|
| 84 |
description = Column(Text, nullable=True)
|
| 85 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 86 |
created_by = Column(String, ForeignKey("users.id"), nullable=True)
|
| 87 |
+
agent_enabled = Column(Boolean, default=False) # Whether AI agent is enabled for this project
|
| 88 |
+
|
| 89 |
# Relationships
|
| 90 |
creator = relationship("User", back_populates="created_projects")
|
| 91 |
memberships = relationship("ProjectMembership", back_populates="project")
|
|
|
|
| 118 |
description = Column(Text, nullable=True)
|
| 119 |
status = Column(Enum(TaskStatus), default=TaskStatus.todo)
|
| 120 |
assigned_to = Column(String, nullable=True) # userId or "agent"
|
| 121 |
+
working_by = Column(String, nullable=True) # User ID currently working on this task
|
| 122 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 123 |
completed_at = Column(DateTime, nullable=True)
|
| 124 |
|
backend/app/smart_query.py
CHANGED
|
@@ -14,8 +14,21 @@ from typing import Optional
|
|
| 14 |
import google.generativeai as genai
|
| 15 |
import json
|
| 16 |
import os
|
|
|
|
| 17 |
from dotenv import load_dotenv
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# Load environment variables
|
| 20 |
load_dotenv()
|
| 21 |
|
|
@@ -99,7 +112,7 @@ GEMINI_TOOLS = [
|
|
| 99 |
),
|
| 100 |
genai.protos.FunctionDeclaration(
|
| 101 |
name="list_tasks",
|
| 102 |
-
description="List tasks in the project, optionally filtered by status. Use this for queries like 'what tasks are done', 'pending tasks', '
|
| 103 |
parameters=genai.protos.Schema(
|
| 104 |
type=genai.protos.Type.OBJECT,
|
| 105 |
properties={
|
|
@@ -107,9 +120,17 @@ GEMINI_TOOLS = [
|
|
| 107 |
type=genai.protos.Type.STRING,
|
| 108 |
description="Filter by status: 'todo', 'in_progress', 'done', or 'all' for no filter"
|
| 109 |
),
|
| 110 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
type=genai.protos.Type.STRING,
|
| 112 |
-
description="Filter
|
| 113 |
),
|
| 114 |
"limit": genai.protos.Schema(
|
| 115 |
type=genai.protos.Type.INTEGER,
|
|
@@ -141,6 +162,10 @@ async def smart_query(
|
|
| 141 |
Returns:
|
| 142 |
{answer: str, tools_used: list[str], sources: list[dict]}
|
| 143 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# Parse datetime
|
| 145 |
dt = datetime.fromisoformat(current_datetime) if current_datetime else datetime.now()
|
| 146 |
context = QueryContext(current_user_id, dt, project_id)
|
|
@@ -169,7 +194,10 @@ RULES:
|
|
| 169 |
try:
|
| 170 |
# Get best available model from router for smart_query task
|
| 171 |
model_name = model_router.get_model_for_task("smart_query")
|
|
|
|
|
|
|
| 172 |
if not model_name:
|
|
|
|
| 173 |
return {
|
| 174 |
"answer": "All models are rate limited. Please try again in a minute.",
|
| 175 |
"tools_used": [],
|
|
@@ -188,13 +216,15 @@ RULES:
|
|
| 188 |
|
| 189 |
# Start chat and send query
|
| 190 |
chat = model.start_chat()
|
|
|
|
| 191 |
response = chat.send_message(query)
|
| 192 |
|
| 193 |
# Tool calling loop
|
| 194 |
tool_results = []
|
| 195 |
max_iterations = 5
|
|
|
|
| 196 |
|
| 197 |
-
for
|
| 198 |
# Check for function calls in response
|
| 199 |
function_calls = []
|
| 200 |
for part in response.candidates[0].content.parts:
|
|
@@ -202,14 +232,30 @@ RULES:
|
|
| 202 |
function_calls.append(part.function_call)
|
| 203 |
|
| 204 |
if not function_calls:
|
|
|
|
| 205 |
break # No more function calls, we have final answer
|
| 206 |
|
|
|
|
|
|
|
| 207 |
# Execute each function call
|
| 208 |
function_responses = []
|
| 209 |
for fn_call in function_calls:
|
|
|
|
|
|
|
|
|
|
| 210 |
result = await execute_tool(fn_call.name, dict(fn_call.args), context)
|
| 211 |
tool_results.append({"tool": fn_call.name, "args": dict(fn_call.args), "result": result})
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
function_responses.append(
|
| 214 |
genai.protos.Part(
|
| 215 |
function_response=genai.protos.FunctionResponse(
|
|
@@ -228,6 +274,10 @@ RULES:
|
|
| 228 |
if hasattr(part, 'text'):
|
| 229 |
final_answer += part.text
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
return {
|
| 232 |
"answer": final_answer,
|
| 233 |
"tools_used": [tr["tool"] for tr in tool_results],
|
|
@@ -235,6 +285,7 @@ RULES:
|
|
| 235 |
}
|
| 236 |
|
| 237 |
except Exception as e:
|
|
|
|
| 238 |
return {
|
| 239 |
"answer": f"Error processing query: {str(e)}",
|
| 240 |
"tools_used": [],
|
|
@@ -284,35 +335,51 @@ def _get_recent_work_hint(db, user_id: str, project_id: str) -> str:
|
|
| 284 |
|
| 285 |
def _resolve_user_in_project(db, project_id: str, user_name: str) -> dict:
|
| 286 |
"""
|
| 287 |
-
Resolve a user by name within project scope
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
Returns:
|
| 290 |
{"found": True, "user_id": "...", "user_name": "..."} - single match
|
| 291 |
{"found": False, "reason": "not_found"} - no matches
|
| 292 |
{"found": False, "reason": "ambiguous", "options": [...]} - multiple matches
|
| 293 |
"""
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
matches = db.query(User, ProjectMembership).join(
|
| 296 |
ProjectMembership, User.id == ProjectMembership.user_id
|
| 297 |
).filter(
|
| 298 |
ProjectMembership.project_id == project_id,
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
).all()
|
| 301 |
|
| 302 |
-
if not matches:
|
| 303 |
-
# Try first_name or last_name separately as fallback
|
| 304 |
-
matches = db.query(User, ProjectMembership).join(
|
| 305 |
-
ProjectMembership, User.id == ProjectMembership.user_id
|
| 306 |
-
).filter(
|
| 307 |
-
ProjectMembership.project_id == project_id,
|
| 308 |
-
(User.first_name.ilike(f"%{user_name}%") | User.last_name.ilike(f"%{user_name}%"))
|
| 309 |
-
).all()
|
| 310 |
-
|
| 311 |
if not matches:
|
| 312 |
return {
|
| 313 |
"found": False,
|
| 314 |
"reason": "not_found",
|
| 315 |
-
"message": f"No project member
|
| 316 |
}
|
| 317 |
|
| 318 |
if len(matches) == 1:
|
|
@@ -568,13 +635,41 @@ def _tool_list_users(db, project_id: str) -> dict:
|
|
| 568 |
|
| 569 |
|
| 570 |
def _tool_list_tasks(db, project_id: str, args: dict) -> dict:
|
| 571 |
-
"""List tasks in project, optionally filtered by status."""
|
| 572 |
from sqlalchemy import desc, func
|
| 573 |
|
| 574 |
status_filter = args.get("status", "all")
|
| 575 |
-
|
|
|
|
|
|
|
| 576 |
limit = args.get("limit", 20)
|
| 577 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
# Build base query - always filter by project_id
|
| 579 |
base_query = db.query(Task).filter(Task.project_id == project_id)
|
| 580 |
|
|
@@ -586,9 +681,45 @@ def _tool_list_tasks(db, project_id: str, args: dict) -> dict:
|
|
| 586 |
except ValueError:
|
| 587 |
return {"error": f"Invalid status: {status_filter}. Use: todo, in_progress, done, or all"}
|
| 588 |
|
| 589 |
-
#
|
| 590 |
-
if
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
|
| 593 |
# Get TOTAL count before applying limit
|
| 594 |
total_count = base_query.count()
|
|
@@ -602,11 +733,25 @@ def _tool_list_tasks(db, project_id: str, args: dict) -> dict:
|
|
| 602 |
for task in all_tasks:
|
| 603 |
status_counts[task.status.value] += 1
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
return {
|
| 606 |
"project_id": project_id,
|
| 607 |
"filter": {
|
| 608 |
"status": status_filter,
|
| 609 |
-
"
|
|
|
|
|
|
|
| 610 |
},
|
| 611 |
"total_count": total_count, # Actual total matching the filter
|
| 612 |
"returned_count": len(tasks), # How many in this response (may be limited)
|
|
@@ -619,7 +764,11 @@ def _tool_list_tasks(db, project_id: str, args: dict) -> dict:
|
|
| 619 |
"status": task.status.value,
|
| 620 |
"assigned_to": task.assigned_to,
|
| 621 |
"created_at": task.created_at.isoformat() if task.created_at else None,
|
| 622 |
-
"completed_at": task.completed_at.isoformat() if task.completed_at else None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
}
|
| 624 |
for task in tasks
|
| 625 |
]
|
|
|
|
| 14 |
import google.generativeai as genai
|
| 15 |
import json
|
| 16 |
import os
|
| 17 |
+
import logging
|
| 18 |
from dotenv import load_dotenv
|
| 19 |
|
| 20 |
+
# Configure logging for smart_query
|
| 21 |
+
logger = logging.getLogger("smart_query")
|
| 22 |
+
logger.setLevel(logging.DEBUG)
|
| 23 |
+
|
| 24 |
+
# Create console handler if not already present
|
| 25 |
+
if not logger.handlers:
|
| 26 |
+
ch = logging.StreamHandler()
|
| 27 |
+
ch.setLevel(logging.DEBUG)
|
| 28 |
+
formatter = logging.Formatter('[SmartQuery] %(asctime)s - %(levelname)s - %(message)s')
|
| 29 |
+
ch.setFormatter(formatter)
|
| 30 |
+
logger.addHandler(ch)
|
| 31 |
+
|
| 32 |
# Load environment variables
|
| 33 |
load_dotenv()
|
| 34 |
|
|
|
|
| 112 |
),
|
| 113 |
genai.protos.FunctionDeclaration(
|
| 114 |
name="list_tasks",
|
| 115 |
+
description="List tasks in the project, optionally filtered by status, who completed them, or completion date range. Use this for queries like 'what tasks are done', 'tasks completed by X', 'pending tasks', 'tasks completed in last 5 minutes'",
|
| 116 |
parameters=genai.protos.Schema(
|
| 117 |
type=genai.protos.Type.OBJECT,
|
| 118 |
properties={
|
|
|
|
| 120 |
type=genai.protos.Type.STRING,
|
| 121 |
description="Filter by status: 'todo', 'in_progress', 'done', or 'all' for no filter"
|
| 122 |
),
|
| 123 |
+
"completed_by": genai.protos.Schema(
|
| 124 |
+
type=genai.protos.Type.STRING,
|
| 125 |
+
description="Filter by who completed the task - user name or user ID (e.g., 'sunny', 'John', 'ai-agent')"
|
| 126 |
+
),
|
| 127 |
+
"completed_after": genai.protos.Schema(
|
| 128 |
+
type=genai.protos.Type.STRING,
|
| 129 |
+
description="Filter tasks completed after this datetime (ISO format)"
|
| 130 |
+
),
|
| 131 |
+
"completed_before": genai.protos.Schema(
|
| 132 |
type=genai.protos.Type.STRING,
|
| 133 |
+
description="Filter tasks completed before this datetime (ISO format)"
|
| 134 |
),
|
| 135 |
"limit": genai.protos.Schema(
|
| 136 |
type=genai.protos.Type.INTEGER,
|
|
|
|
| 162 |
Returns:
|
| 163 |
{answer: str, tools_used: list[str], sources: list[dict]}
|
| 164 |
"""
|
| 165 |
+
logger.info(f"=== NEW QUERY ===")
|
| 166 |
+
logger.info(f"Query: {query}")
|
| 167 |
+
logger.info(f"Project: {project_id}, User: {current_user_id}")
|
| 168 |
+
|
| 169 |
# Parse datetime
|
| 170 |
dt = datetime.fromisoformat(current_datetime) if current_datetime else datetime.now()
|
| 171 |
context = QueryContext(current_user_id, dt, project_id)
|
|
|
|
| 194 |
try:
|
| 195 |
# Get best available model from router for smart_query task
|
| 196 |
model_name = model_router.get_model_for_task("smart_query")
|
| 197 |
+
logger.info(f"Selected model: {model_name}")
|
| 198 |
+
|
| 199 |
if not model_name:
|
| 200 |
+
logger.warning("All models rate limited!")
|
| 201 |
return {
|
| 202 |
"answer": "All models are rate limited. Please try again in a minute.",
|
| 203 |
"tools_used": [],
|
|
|
|
| 216 |
|
| 217 |
# Start chat and send query
|
| 218 |
chat = model.start_chat()
|
| 219 |
+
logger.debug(f"Sending query to model...")
|
| 220 |
response = chat.send_message(query)
|
| 221 |
|
| 222 |
# Tool calling loop
|
| 223 |
tool_results = []
|
| 224 |
max_iterations = 5
|
| 225 |
+
iteration = 0
|
| 226 |
|
| 227 |
+
for iteration in range(max_iterations):
|
| 228 |
# Check for function calls in response
|
| 229 |
function_calls = []
|
| 230 |
for part in response.candidates[0].content.parts:
|
|
|
|
| 232 |
function_calls.append(part.function_call)
|
| 233 |
|
| 234 |
if not function_calls:
|
| 235 |
+
logger.debug(f"No more function calls after iteration {iteration}")
|
| 236 |
break # No more function calls, we have final answer
|
| 237 |
|
| 238 |
+
logger.info(f"--- Iteration {iteration + 1}: {len(function_calls)} tool call(s) ---")
|
| 239 |
+
|
| 240 |
# Execute each function call
|
| 241 |
function_responses = []
|
| 242 |
for fn_call in function_calls:
|
| 243 |
+
logger.info(f" TOOL: {fn_call.name}")
|
| 244 |
+
logger.info(f" ARGS: {json.dumps(dict(fn_call.args), default=str)}")
|
| 245 |
+
|
| 246 |
result = await execute_tool(fn_call.name, dict(fn_call.args), context)
|
| 247 |
tool_results.append({"tool": fn_call.name, "args": dict(fn_call.args), "result": result})
|
| 248 |
|
| 249 |
+
# Log result summary
|
| 250 |
+
if "error" in result:
|
| 251 |
+
logger.error(f" ERROR: {result['error']}")
|
| 252 |
+
elif "count" in result:
|
| 253 |
+
logger.info(f" RESULT: {result.get('count', 'N/A')} items returned")
|
| 254 |
+
elif "found" in result:
|
| 255 |
+
logger.info(f" RESULT: found={result['found']}")
|
| 256 |
+
else:
|
| 257 |
+
logger.info(f" RESULT: {list(result.keys())}")
|
| 258 |
+
|
| 259 |
function_responses.append(
|
| 260 |
genai.protos.Part(
|
| 261 |
function_response=genai.protos.FunctionResponse(
|
|
|
|
| 274 |
if hasattr(part, 'text'):
|
| 275 |
final_answer += part.text
|
| 276 |
|
| 277 |
+
logger.info(f"=== QUERY COMPLETE ===")
|
| 278 |
+
logger.info(f"Tools used: {[tr['tool'] for tr in tool_results]}")
|
| 279 |
+
logger.info(f"Answer preview: {final_answer[:200]}...")
|
| 280 |
+
|
| 281 |
return {
|
| 282 |
"answer": final_answer,
|
| 283 |
"tools_used": [tr["tool"] for tr in tool_results],
|
|
|
|
| 285 |
}
|
| 286 |
|
| 287 |
except Exception as e:
|
| 288 |
+
logger.error(f"Error processing query: {str(e)}", exc_info=True)
|
| 289 |
return {
|
| 290 |
"answer": f"Error processing query: {str(e)}",
|
| 291 |
"tools_used": [],
|
|
|
|
| 335 |
|
| 336 |
def _resolve_user_in_project(db, project_id: str, user_name: str) -> dict:
|
| 337 |
"""
|
| 338 |
+
Resolve a user by ID or name within project scope.
|
| 339 |
+
|
| 340 |
+
First tries exact match by user ID (for cases like "ai-agent"),
|
| 341 |
+
then falls back to searching by first/last name.
|
| 342 |
|
| 343 |
Returns:
|
| 344 |
{"found": True, "user_id": "...", "user_name": "..."} - single match
|
| 345 |
{"found": False, "reason": "not_found"} - no matches
|
| 346 |
{"found": False, "reason": "ambiguous", "options": [...]} - multiple matches
|
| 347 |
"""
|
| 348 |
+
from sqlalchemy import func, or_
|
| 349 |
+
|
| 350 |
+
# First try exact match by user ID (for cases like "ai-agent")
|
| 351 |
+
exact_match = db.query(User, ProjectMembership).join(
|
| 352 |
+
ProjectMembership, User.id == ProjectMembership.user_id
|
| 353 |
+
).filter(
|
| 354 |
+
ProjectMembership.project_id == project_id,
|
| 355 |
+
User.id == user_name
|
| 356 |
+
).first()
|
| 357 |
+
|
| 358 |
+
if exact_match:
|
| 359 |
+
user, membership = exact_match
|
| 360 |
+
return {
|
| 361 |
+
"found": True,
|
| 362 |
+
"user_id": str(user.id),
|
| 363 |
+
"user_name": f"{user.first_name} {user.last_name}"
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
# Fall back to searching by first_name, last_name, or concatenated full name
|
| 367 |
matches = db.query(User, ProjectMembership).join(
|
| 368 |
ProjectMembership, User.id == ProjectMembership.user_id
|
| 369 |
).filter(
|
| 370 |
ProjectMembership.project_id == project_id,
|
| 371 |
+
or_(
|
| 372 |
+
User.first_name.ilike(f"%{user_name}%"),
|
| 373 |
+
User.last_name.ilike(f"%{user_name}%"),
|
| 374 |
+
func.concat(User.first_name, ' ', User.last_name).ilike(f"%{user_name}%")
|
| 375 |
+
)
|
| 376 |
).all()
|
| 377 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
if not matches:
|
| 379 |
return {
|
| 380 |
"found": False,
|
| 381 |
"reason": "not_found",
|
| 382 |
+
"message": f"No project member with ID or name matching '{user_name}' found"
|
| 383 |
}
|
| 384 |
|
| 385 |
if len(matches) == 1:
|
|
|
|
| 635 |
|
| 636 |
|
| 637 |
def _tool_list_tasks(db, project_id: str, args: dict) -> dict:
|
| 638 |
+
"""List tasks in project, optionally filtered by status, who completed them, or date range."""
|
| 639 |
from sqlalchemy import desc, func
|
| 640 |
|
| 641 |
status_filter = args.get("status", "all")
|
| 642 |
+
completed_by_name = args.get("completed_by")
|
| 643 |
+
completed_after = args.get("completed_after")
|
| 644 |
+
completed_before = args.get("completed_before")
|
| 645 |
limit = args.get("limit", 20)
|
| 646 |
|
| 647 |
+
# Resolve completed_by user name/ID to user_id
|
| 648 |
+
completed_by_user_id = None
|
| 649 |
+
resolved_user_name = None
|
| 650 |
+
if completed_by_name:
|
| 651 |
+
resolution = _resolve_user_in_project(db, project_id, completed_by_name)
|
| 652 |
+
if not resolution["found"]:
|
| 653 |
+
return resolution # Return not_found or ambiguous response
|
| 654 |
+
completed_by_user_id = resolution["user_id"]
|
| 655 |
+
resolved_user_name = resolution["user_name"]
|
| 656 |
+
|
| 657 |
+
# Parse date filters
|
| 658 |
+
date_after = None
|
| 659 |
+
date_before = None
|
| 660 |
+
if completed_after:
|
| 661 |
+
try:
|
| 662 |
+
date_str = completed_after.replace('Z', '+00:00').replace('+00:00', '')
|
| 663 |
+
date_after = datetime.fromisoformat(date_str)
|
| 664 |
+
except ValueError:
|
| 665 |
+
date_after = datetime.strptime(date_str[:10], '%Y-%m-%d')
|
| 666 |
+
if completed_before:
|
| 667 |
+
try:
|
| 668 |
+
date_str = completed_before.replace('Z', '+00:00').replace('+00:00', '')
|
| 669 |
+
date_before = datetime.fromisoformat(date_str)
|
| 670 |
+
except ValueError:
|
| 671 |
+
date_before = datetime.strptime(date_str[:10], '%Y-%m-%d') + timedelta(days=1)
|
| 672 |
+
|
| 673 |
# Build base query - always filter by project_id
|
| 674 |
base_query = db.query(Task).filter(Task.project_id == project_id)
|
| 675 |
|
|
|
|
| 681 |
except ValueError:
|
| 682 |
return {"error": f"Invalid status: {status_filter}. Use: todo, in_progress, done, or all"}
|
| 683 |
|
| 684 |
+
# If filtering by completed_by or date range, find task_ids from LogEntry first
|
| 685 |
+
if completed_by_user_id or date_after or date_before:
|
| 686 |
+
# Build LogEntry query
|
| 687 |
+
log_query = db.query(LogEntry.task_id).filter(LogEntry.project_id == project_id)
|
| 688 |
+
|
| 689 |
+
if completed_by_user_id:
|
| 690 |
+
log_query = log_query.filter(LogEntry.user_id == completed_by_user_id)
|
| 691 |
+
if date_after:
|
| 692 |
+
log_query = log_query.filter(LogEntry.created_at >= date_after)
|
| 693 |
+
if date_before:
|
| 694 |
+
log_query = log_query.filter(LogEntry.created_at <= date_before)
|
| 695 |
+
|
| 696 |
+
# Get task_ids matching the filters
|
| 697 |
+
completed_task_ids = [log.task_id for log in log_query.all()]
|
| 698 |
+
|
| 699 |
+
if not completed_task_ids:
|
| 700 |
+
filter_desc = []
|
| 701 |
+
if resolved_user_name:
|
| 702 |
+
filter_desc.append(f"by {resolved_user_name}")
|
| 703 |
+
if date_after:
|
| 704 |
+
filter_desc.append(f"after {date_after.isoformat()}")
|
| 705 |
+
if date_before:
|
| 706 |
+
filter_desc.append(f"before {date_before.isoformat()}")
|
| 707 |
+
|
| 708 |
+
return {
|
| 709 |
+
"project_id": project_id,
|
| 710 |
+
"filter": {
|
| 711 |
+
"status": status_filter,
|
| 712 |
+
"completed_by": resolved_user_name,
|
| 713 |
+
"completed_after": completed_after,
|
| 714 |
+
"completed_before": completed_before
|
| 715 |
+
},
|
| 716 |
+
"total_count": 0,
|
| 717 |
+
"returned_count": 0,
|
| 718 |
+
"status_summary": {"todo": 0, "in_progress": 0, "done": 0},
|
| 719 |
+
"tasks": [],
|
| 720 |
+
"message": f"No tasks completed {' '.join(filter_desc)}"
|
| 721 |
+
}
|
| 722 |
+
base_query = base_query.filter(Task.id.in_(completed_task_ids))
|
| 723 |
|
| 724 |
# Get TOTAL count before applying limit
|
| 725 |
total_count = base_query.count()
|
|
|
|
| 733 |
for task in all_tasks:
|
| 734 |
status_counts[task.status.value] += 1
|
| 735 |
|
| 736 |
+
# Get completion info (who completed each task) from LogEntry
|
| 737 |
+
task_ids = [str(task.id) for task in tasks]
|
| 738 |
+
completion_logs = db.query(LogEntry).filter(LogEntry.task_id.in_(task_ids)).all()
|
| 739 |
+
completion_map = {} # task_id -> {user_id, user_name, what_was_done}
|
| 740 |
+
for log in completion_logs:
|
| 741 |
+
user = db.query(User).filter(User.id == log.user_id).first() if log.user_id else None
|
| 742 |
+
completion_map[str(log.task_id)] = {
|
| 743 |
+
"completed_by_id": str(log.user_id) if log.user_id else None,
|
| 744 |
+
"completed_by_name": user.name if user else None,
|
| 745 |
+
"what_was_done": log.raw_input[:100] if log.raw_input else None
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
return {
|
| 749 |
"project_id": project_id,
|
| 750 |
"filter": {
|
| 751 |
"status": status_filter,
|
| 752 |
+
"completed_by": resolved_user_name,
|
| 753 |
+
"completed_after": completed_after,
|
| 754 |
+
"completed_before": completed_before
|
| 755 |
},
|
| 756 |
"total_count": total_count, # Actual total matching the filter
|
| 757 |
"returned_count": len(tasks), # How many in this response (may be limited)
|
|
|
|
| 764 |
"status": task.status.value,
|
| 765 |
"assigned_to": task.assigned_to,
|
| 766 |
"created_at": task.created_at.isoformat() if task.created_at else None,
|
| 767 |
+
"completed_at": task.completed_at.isoformat() if task.completed_at else None,
|
| 768 |
+
# Include completion details if available
|
| 769 |
+
"completed_by": completion_map.get(str(task.id), {}).get("completed_by_name"),
|
| 770 |
+
"completed_by_id": completion_map.get(str(task.id), {}).get("completed_by_id"),
|
| 771 |
+
"what_was_done": completion_map.get(str(task.id), {}).get("what_was_done")
|
| 772 |
}
|
| 773 |
for task in tasks
|
| 774 |
]
|
backend/app/tools/memory.py
CHANGED
|
@@ -58,6 +58,7 @@ async def complete_task(
|
|
| 58 |
user_id: str,
|
| 59 |
what_i_did: str,
|
| 60 |
code_snippet: Optional[str] = None,
|
|
|
|
| 61 |
db: Optional[Session] = None
|
| 62 |
) -> dict:
|
| 63 |
"""
|
|
@@ -93,7 +94,7 @@ async def complete_task(
|
|
| 93 |
project_id=project_id,
|
| 94 |
task_id=task_id,
|
| 95 |
user_id=user_id,
|
| 96 |
-
actor_type=ActorType
|
| 97 |
action_type=ActionType.task_completed,
|
| 98 |
raw_input=what_i_did,
|
| 99 |
code_snippet=code_snippet,
|
|
|
|
| 58 |
user_id: str,
|
| 59 |
what_i_did: str,
|
| 60 |
code_snippet: Optional[str] = None,
|
| 61 |
+
actor_type: str = "human",
|
| 62 |
db: Optional[Session] = None
|
| 63 |
) -> dict:
|
| 64 |
"""
|
|
|
|
| 94 |
project_id=project_id,
|
| 95 |
task_id=task_id,
|
| 96 |
user_id=user_id,
|
| 97 |
+
actor_type=ActorType(actor_type),
|
| 98 |
action_type=ActionType.task_completed,
|
| 99 |
raw_input=what_i_did,
|
| 100 |
code_snippet=code_snippet,
|
backend/app/tools/tasks.py
CHANGED
|
@@ -44,6 +44,7 @@ def create_task(project_id: str, title: str, description: str = None, assigned_t
|
|
| 44 |
"description": task.description,
|
| 45 |
"status": task.status.value,
|
| 46 |
"assigned_to": task.assigned_to,
|
|
|
|
| 47 |
"created_at": task.created_at.isoformat()
|
| 48 |
}
|
| 49 |
finally:
|
|
@@ -86,6 +87,7 @@ def list_tasks(project_id: str, status: str = None) -> dict:
|
|
| 86 |
"description": task.description,
|
| 87 |
"status": task.status.value,
|
| 88 |
"assigned_to": task.assigned_to,
|
|
|
|
| 89 |
"created_at": task.created_at.isoformat(),
|
| 90 |
"completed_at": task.completed_at.isoformat() if task.completed_at else None
|
| 91 |
}
|
|
|
|
| 44 |
"description": task.description,
|
| 45 |
"status": task.status.value,
|
| 46 |
"assigned_to": task.assigned_to,
|
| 47 |
+
"working_by": task.working_by,
|
| 48 |
"created_at": task.created_at.isoformat()
|
| 49 |
}
|
| 50 |
finally:
|
|
|
|
| 87 |
"description": task.description,
|
| 88 |
"status": task.status.value,
|
| 89 |
"assigned_to": task.assigned_to,
|
| 90 |
+
"working_by": task.working_by,
|
| 91 |
"created_at": task.created_at.isoformat(),
|
| 92 |
"completed_at": task.completed_at.isoformat() if task.completed_at else None
|
| 93 |
}
|
backend/entrypoint.sh
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
#!/bin/sh
|
| 2 |
-
set -e
|
| 3 |
-
|
| 4 |
-
# Ensure /data exists and has safe permissions. On HF Spaces /data is mounted at runtime when
|
| 5 |
-
# persistent storage is enabled. Creating it here is harmless for local runs.
|
| 6 |
-
mkdir -p /data
|
| 7 |
-
chmod 700 /data || true
|
| 8 |
-
|
| 9 |
-
# Execute the command (or CMD) passed to the container
|
| 10 |
-
exec "$@"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/tests/test_complete_api.py
CHANGED
|
@@ -4,7 +4,7 @@ import requests
|
|
| 4 |
import json
|
| 5 |
import sys
|
| 6 |
|
| 7 |
-
BASE_URL = "http://localhost:
|
| 8 |
|
| 9 |
print("="*60)
|
| 10 |
print("PROJECT MEMORY - COMPLETE API TEST")
|
|
|
|
| 4 |
import json
|
| 5 |
import sys
|
| 6 |
|
| 7 |
+
BASE_URL = "http://localhost:8000"
|
| 8 |
|
| 9 |
print("="*60)
|
| 10 |
print("PROJECT MEMORY - COMPLETE API TEST")
|
frontend/Dockerfile
CHANGED
|
@@ -26,7 +26,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
|
| 26 |
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 27 |
|
| 28 |
# Expose port
|
| 29 |
-
EXPOSE
|
| 30 |
|
| 31 |
# Start nginx
|
| 32 |
CMD ["nginx", "-g", "daemon off;"]
|
|
|
|
| 26 |
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
| 27 |
|
| 28 |
# Expose port
|
| 29 |
+
EXPOSE 80
|
| 30 |
|
| 31 |
# Start nginx
|
| 32 |
CMD ["nginx", "-g", "daemon off;"]
|
frontend/nginx.conf
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
server {
|
| 2 |
-
listen
|
| 3 |
server_name localhost;
|
| 4 |
|
| 5 |
root /usr/share/nginx/html;
|
|
@@ -12,7 +12,7 @@ server {
|
|
| 12 |
|
| 13 |
# API proxy
|
| 14 |
location /api {
|
| 15 |
-
proxy_pass http://backend:
|
| 16 |
proxy_http_version 1.1;
|
| 17 |
proxy_set_header Upgrade $http_upgrade;
|
| 18 |
proxy_set_header Connection 'upgrade';
|
|
@@ -25,7 +25,7 @@ server {
|
|
| 25 |
|
| 26 |
# WebSocket support (if needed)
|
| 27 |
location /ws {
|
| 28 |
-
proxy_pass http://backend:
|
| 29 |
proxy_http_version 1.1;
|
| 30 |
proxy_set_header Upgrade $http_upgrade;
|
| 31 |
proxy_set_header Connection "upgrade";
|
|
|
|
| 1 |
server {
|
| 2 |
+
listen 80;
|
| 3 |
server_name localhost;
|
| 4 |
|
| 5 |
root /usr/share/nginx/html;
|
|
|
|
| 12 |
|
| 13 |
# API proxy
|
| 14 |
location /api {
|
| 15 |
+
proxy_pass http://backend:8000;
|
| 16 |
proxy_http_version 1.1;
|
| 17 |
proxy_set_header Upgrade $http_upgrade;
|
| 18 |
proxy_set_header Connection 'upgrade';
|
|
|
|
| 25 |
|
| 26 |
# WebSocket support (if needed)
|
| 27 |
location /ws {
|
| 28 |
+
proxy_pass http://backend:8000;
|
| 29 |
proxy_http_version 1.1;
|
| 30 |
proxy_set_header Upgrade $http_upgrade;
|
| 31 |
proxy_set_header Connection "upgrade";
|
frontend/src/api/client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import type { User, UserCreate, Project, ProjectCreate, ProjectsResponse, ProjectAvailability, Task, TaskCreate, TasksResponse, MembersResponse, ActivityResponse } from '../types';
|
| 2 |
|
| 3 |
-
const API_BASE = '/api';
|
| 4 |
|
| 5 |
class ApiClient {
|
| 6 |
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
@@ -86,10 +86,10 @@ class ApiClient {
|
|
| 86 |
});
|
| 87 |
}
|
| 88 |
|
| 89 |
-
async updateTaskStatus(taskId: string, status: 'todo' | 'in_progress' | 'done'): Promise<Task> {
|
| 90 |
return this.request<Task>(`/tasks/${taskId}/status`, {
|
| 91 |
method: 'PATCH',
|
| 92 |
-
body: JSON.stringify({ status }),
|
| 93 |
});
|
| 94 |
}
|
| 95 |
|
|
@@ -98,6 +98,15 @@ class ApiClient {
|
|
| 98 |
return this.request<MembersResponse>(`/projects/${projectId}/members`);
|
| 99 |
}
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
// Search & Activity
|
| 102 |
async searchProject(projectId: string, query: string, filters?: { userId?: string; dateFrom?: string; dateTo?: string }) {
|
| 103 |
return this.request(`/projects/${projectId}/search`, {
|
|
|
|
| 1 |
import type { User, UserCreate, Project, ProjectCreate, ProjectsResponse, ProjectAvailability, Task, TaskCreate, TasksResponse, MembersResponse, ActivityResponse } from '../types';
|
| 2 |
|
| 3 |
+
const API_BASE = 'http://localhost:8000/api';
|
| 4 |
|
| 5 |
class ApiClient {
|
| 6 |
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
|
|
| 86 |
});
|
| 87 |
}
|
| 88 |
|
| 89 |
+
async updateTaskStatus(taskId: string, status: 'todo' | 'in_progress' | 'done', userId?: string): Promise<Task> {
|
| 90 |
return this.request<Task>(`/tasks/${taskId}/status`, {
|
| 91 |
method: 'PATCH',
|
| 92 |
+
body: JSON.stringify({ status, userId }),
|
| 93 |
});
|
| 94 |
}
|
| 95 |
|
|
|
|
| 98 |
return this.request<MembersResponse>(`/projects/${projectId}/members`);
|
| 99 |
}
|
| 100 |
|
| 101 |
+
// Agent Control
|
| 102 |
+
async enableAgent(projectId: string): Promise<{ message: string; project_id: string }> {
|
| 103 |
+
return this.request(`/projects/${projectId}/agent/enable`, { method: 'POST' });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
async disableAgent(projectId: string): Promise<{ message: string; project_id: string }> {
|
| 107 |
+
return this.request(`/projects/${projectId}/agent/disable`, { method: 'POST' });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
// Search & Activity
|
| 111 |
async searchProject(projectId: string, query: string, filters?: { userId?: string; dateFrom?: string; dateTo?: string }) {
|
| 112 |
return this.request(`/projects/${projectId}/search`, {
|
frontend/src/pages/ActivityPage.tsx
CHANGED
|
@@ -38,6 +38,12 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 38 |
const [showSuggestions, setShowSuggestions] = useState(false);
|
| 39 |
const searchInputRef = useRef<HTMLInputElement>(null);
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
const handleLogout = () => {
|
| 42 |
clearProject();
|
| 43 |
logout();
|
|
@@ -93,13 +99,40 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 93 |
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 94 |
}, []);
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
// Handle starting work on a task
|
| 97 |
const handleStartWorking = async (task: Task) => {
|
|
|
|
| 98 |
try {
|
| 99 |
-
// Update task status to in_progress if it's todo
|
| 100 |
if (task.status === 'todo') {
|
| 101 |
-
await api.updateTaskStatus(task.id, 'in_progress');
|
| 102 |
-
task = { ...task, status: 'in_progress' };
|
| 103 |
}
|
| 104 |
// Navigate to task solver page
|
| 105 |
onStartTask(task);
|
|
@@ -144,6 +177,45 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 144 |
)
|
| 145 |
: QUERY_SUGGESTIONS;
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if (!user || !currentProject) return null;
|
| 148 |
|
| 149 |
// Group tasks by status
|
|
@@ -201,6 +273,68 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 201 |
</div>
|
| 202 |
)}
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
{isLoading ? (
|
| 205 |
<div className="flex items-center justify-center h-64">
|
| 206 |
<div className="text-purple-300">Loading...</div>
|
|
@@ -208,69 +342,7 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 208 |
) : (
|
| 209 |
<div className="grid lg:grid-cols-4 gap-6">
|
| 210 |
{/* Main Content - Kanban Board (3 cols) */}
|
| 211 |
-
<div className="lg:col-span-3
|
| 212 |
-
{/* Smart Query Search Bar */}
|
| 213 |
-
<div className="bg-white/5 backdrop-blur-lg rounded-xl p-4 border border-white/10">
|
| 214 |
-
<form onSubmit={handleSearch} className="flex gap-2">
|
| 215 |
-
<div className="flex-1 relative" ref={searchInputRef}>
|
| 216 |
-
<input
|
| 217 |
-
type="text"
|
| 218 |
-
value={searchQuery}
|
| 219 |
-
onChange={(e) => setSearchQuery(e.target.value)}
|
| 220 |
-
onFocus={() => setShowSuggestions(true)}
|
| 221 |
-
placeholder="Ask anything about your project..."
|
| 222 |
-
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
| 223 |
-
disabled={isSearching}
|
| 224 |
-
/>
|
| 225 |
-
|
| 226 |
-
{/* Suggestions Dropdown */}
|
| 227 |
-
{showSuggestions && !searchResult && filteredSuggestions.length > 0 && (
|
| 228 |
-
<div className="absolute top-full left-0 right-0 mt-2 bg-slate-800 border border-white/10 rounded-lg shadow-xl z-20 max-h-64 overflow-y-auto">
|
| 229 |
-
<div className="p-2">
|
| 230 |
-
<p className="text-purple-300/50 text-xs px-2 py-1 uppercase tracking-wide">Try asking...</p>
|
| 231 |
-
{filteredSuggestions.map((suggestion, idx) => (
|
| 232 |
-
<button
|
| 233 |
-
key={idx}
|
| 234 |
-
type="button"
|
| 235 |
-
onClick={() => selectSuggestion(suggestion.text)}
|
| 236 |
-
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-white/10 rounded-lg transition-colors flex items-center justify-between group"
|
| 237 |
-
>
|
| 238 |
-
<span>{suggestion.text}</span>
|
| 239 |
-
<span className="text-purple-400/50 text-xs group-hover:text-purple-400">{suggestion.category}</span>
|
| 240 |
-
</button>
|
| 241 |
-
))}
|
| 242 |
-
</div>
|
| 243 |
-
</div>
|
| 244 |
-
)}
|
| 245 |
-
</div>
|
| 246 |
-
<button
|
| 247 |
-
type="submit"
|
| 248 |
-
disabled={isSearching || !searchQuery.trim()}
|
| 249 |
-
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-800 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-all"
|
| 250 |
-
>
|
| 251 |
-
{isSearching ? 'Asking...' : 'Ask'}
|
| 252 |
-
</button>
|
| 253 |
-
</form>
|
| 254 |
-
|
| 255 |
-
{/* Search Result */}
|
| 256 |
-
{searchResult && (
|
| 257 |
-
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-purple-500/30">
|
| 258 |
-
<div className="flex items-start justify-between gap-4">
|
| 259 |
-
<div className="flex-1">
|
| 260 |
-
<p className="text-purple-300 text-xs font-medium mb-2">AI Response:</p>
|
| 261 |
-
<p className="text-white text-sm whitespace-pre-wrap">{searchResult.answer}</p>
|
| 262 |
-
</div>
|
| 263 |
-
<button
|
| 264 |
-
onClick={clearSearch}
|
| 265 |
-
className="text-purple-300/50 hover:text-white text-sm"
|
| 266 |
-
>
|
| 267 |
-
Clear
|
| 268 |
-
</button>
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
-
)}
|
| 272 |
-
</div>
|
| 273 |
-
|
| 274 |
{/* Kanban Board */}
|
| 275 |
<div className="grid md:grid-cols-3 gap-4">
|
| 276 |
{/* Todo Column */}
|
|
@@ -302,6 +374,26 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 302 |
{todoTasks.length === 0 && (
|
| 303 |
<p className="text-purple-300/40 text-sm text-center py-4">No tasks</p>
|
| 304 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
</div>
|
| 306 |
</div>
|
| 307 |
|
|
@@ -314,23 +406,43 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 314 |
</h3>
|
| 315 |
</div>
|
| 316 |
<div className="p-3 space-y-2 max-h-[60vh] overflow-y-auto">
|
| 317 |
-
{inProgressTasks.map((task) =>
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
<
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
)}
|
| 326 |
-
<button
|
| 327 |
-
onClick={() => handleStartWorking(task)}
|
| 328 |
-
className="mt-2 w-full px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 text-xs font-medium rounded transition-all"
|
| 329 |
>
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
{inProgressTasks.length === 0 && (
|
| 335 |
<p className="text-purple-300/40 text-sm text-center py-4">No tasks</p>
|
| 336 |
)}
|
|
@@ -375,17 +487,55 @@ export function ActivityPage({ onStartTask }: ActivityPageProps) {
|
|
| 375 |
<div className="p-3 space-y-2 max-h-48 overflow-y-auto">
|
| 376 |
{members.map((member) => (
|
| 377 |
<div key={member.id} className="flex items-center gap-3">
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
<div className="flex-1 min-w-0">
|
| 382 |
<p className="text-white text-sm truncate">
|
| 383 |
{member.firstName} {member.lastName}
|
| 384 |
</p>
|
| 385 |
-
<p className=
|
|
|
|
|
|
|
| 386 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
</div>
|
| 388 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
| 390 |
</div>
|
| 391 |
|
|
|
|
| 38 |
const [showSuggestions, setShowSuggestions] = useState(false);
|
| 39 |
const searchInputRef = useRef<HTMLInputElement>(null);
|
| 40 |
|
| 41 |
+
// Task generation state
|
| 42 |
+
const [isGeneratingTasks, setIsGeneratingTasks] = useState(false);
|
| 43 |
+
|
| 44 |
+
// Agent control state
|
| 45 |
+
const [isAgentLoading, setIsAgentLoading] = useState(false);
|
| 46 |
+
|
| 47 |
const handleLogout = () => {
|
| 48 |
clearProject();
|
| 49 |
logout();
|
|
|
|
| 99 |
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 100 |
}, []);
|
| 101 |
|
| 102 |
+
// Handle generating more tasks
|
| 103 |
+
const handleGenerateMoreTasks = async () => {
|
| 104 |
+
if (!currentProject) return;
|
| 105 |
+
setIsGeneratingTasks(true);
|
| 106 |
+
setError('');
|
| 107 |
+
|
| 108 |
+
try {
|
| 109 |
+
// Generate 10 tasks using AI
|
| 110 |
+
const { tasks: generatedTasks } = await api.generateTasks(currentProject.id, 10);
|
| 111 |
+
|
| 112 |
+
// Create all tasks in parallel
|
| 113 |
+
await Promise.all(
|
| 114 |
+
generatedTasks.map((t) =>
|
| 115 |
+
api.createTask(currentProject.id, { title: t.title, description: t.description })
|
| 116 |
+
)
|
| 117 |
+
);
|
| 118 |
+
|
| 119 |
+
// Refresh the task list
|
| 120 |
+
await fetchData(false);
|
| 121 |
+
} catch (err) {
|
| 122 |
+
setError(err instanceof Error ? err.message : 'Failed to generate tasks');
|
| 123 |
+
} finally {
|
| 124 |
+
setIsGeneratingTasks(false);
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
// Handle starting work on a task
|
| 129 |
const handleStartWorking = async (task: Task) => {
|
| 130 |
+
if (!user) return;
|
| 131 |
try {
|
| 132 |
+
// Update task status to in_progress if it's todo (pass user.id to track worker)
|
| 133 |
if (task.status === 'todo') {
|
| 134 |
+
await api.updateTaskStatus(task.id, 'in_progress', user.id);
|
| 135 |
+
task = { ...task, status: 'in_progress', working_by: user.id };
|
| 136 |
}
|
| 137 |
// Navigate to task solver page
|
| 138 |
onStartTask(task);
|
|
|
|
| 177 |
)
|
| 178 |
: QUERY_SUGGESTIONS;
|
| 179 |
|
| 180 |
+
// Helper to get worker name from members list
|
| 181 |
+
const getWorkerName = (userId: string) => {
|
| 182 |
+
const member = members.find(m => m.id === userId);
|
| 183 |
+
return member ? member.firstName : userId;
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
+
// Handle adding AI agent to team
|
| 187 |
+
const handleAddAgent = async () => {
|
| 188 |
+
if (!currentProject) return;
|
| 189 |
+
setIsAgentLoading(true);
|
| 190 |
+
setError('');
|
| 191 |
+
try {
|
| 192 |
+
await api.enableAgent(currentProject.id);
|
| 193 |
+
await fetchData(false);
|
| 194 |
+
} catch (err) {
|
| 195 |
+
setError(err instanceof Error ? err.message : 'Failed to add AI agent');
|
| 196 |
+
} finally {
|
| 197 |
+
setIsAgentLoading(false);
|
| 198 |
+
}
|
| 199 |
+
};
|
| 200 |
+
|
| 201 |
+
// Handle removing AI agent from team
|
| 202 |
+
const handleRemoveAgent = async () => {
|
| 203 |
+
if (!currentProject) return;
|
| 204 |
+
setIsAgentLoading(true);
|
| 205 |
+
setError('');
|
| 206 |
+
try {
|
| 207 |
+
await api.disableAgent(currentProject.id);
|
| 208 |
+
await fetchData(false);
|
| 209 |
+
} catch (err) {
|
| 210 |
+
setError(err instanceof Error ? err.message : 'Failed to remove AI agent');
|
| 211 |
+
} finally {
|
| 212 |
+
setIsAgentLoading(false);
|
| 213 |
+
}
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
// Check if agent is in the team
|
| 217 |
+
const hasAgent = members.some(m => m.role === 'agent');
|
| 218 |
+
|
| 219 |
if (!user || !currentProject) return null;
|
| 220 |
|
| 221 |
// Group tasks by status
|
|
|
|
| 273 |
</div>
|
| 274 |
)}
|
| 275 |
|
| 276 |
+
{/* Smart Query Search Bar - Outside grid for proper z-index */}
|
| 277 |
+
<div className="bg-white/5 backdrop-blur-lg rounded-xl p-4 border border-white/10 mb-6 relative z-10">
|
| 278 |
+
<form onSubmit={handleSearch} className="flex gap-2">
|
| 279 |
+
<div className="flex-1 relative" ref={searchInputRef}>
|
| 280 |
+
<input
|
| 281 |
+
type="text"
|
| 282 |
+
value={searchQuery}
|
| 283 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 284 |
+
onFocus={() => setShowSuggestions(true)}
|
| 285 |
+
placeholder="Ask anything about your project..."
|
| 286 |
+
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
| 287 |
+
disabled={isSearching}
|
| 288 |
+
/>
|
| 289 |
+
|
| 290 |
+
{/* Suggestions Dropdown */}
|
| 291 |
+
{showSuggestions && !searchResult && filteredSuggestions.length > 0 && (
|
| 292 |
+
<div className="absolute top-full left-0 right-0 mt-2 bg-slate-800 border border-white/10 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
| 293 |
+
<div className="p-2">
|
| 294 |
+
<p className="text-purple-300/50 text-xs px-2 py-1 uppercase tracking-wide">Try asking...</p>
|
| 295 |
+
{filteredSuggestions.map((suggestion, idx) => (
|
| 296 |
+
<button
|
| 297 |
+
key={idx}
|
| 298 |
+
type="button"
|
| 299 |
+
onClick={() => selectSuggestion(suggestion.text)}
|
| 300 |
+
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-white/10 rounded-lg transition-colors flex items-center justify-between group"
|
| 301 |
+
>
|
| 302 |
+
<span>{suggestion.text}</span>
|
| 303 |
+
<span className="text-purple-400/50 text-xs group-hover:text-purple-400">{suggestion.category}</span>
|
| 304 |
+
</button>
|
| 305 |
+
))}
|
| 306 |
+
</div>
|
| 307 |
+
</div>
|
| 308 |
+
)}
|
| 309 |
+
</div>
|
| 310 |
+
<button
|
| 311 |
+
type="submit"
|
| 312 |
+
disabled={isSearching || !searchQuery.trim()}
|
| 313 |
+
className="px-6 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-800 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-all"
|
| 314 |
+
>
|
| 315 |
+
{isSearching ? 'Asking...' : 'Ask'}
|
| 316 |
+
</button>
|
| 317 |
+
</form>
|
| 318 |
+
|
| 319 |
+
{/* Search Result */}
|
| 320 |
+
{searchResult && (
|
| 321 |
+
<div className="mt-4 p-4 bg-white/5 rounded-lg border border-purple-500/30">
|
| 322 |
+
<div className="flex items-start justify-between gap-4">
|
| 323 |
+
<div className="flex-1">
|
| 324 |
+
<p className="text-purple-300 text-xs font-medium mb-2">AI Response:</p>
|
| 325 |
+
<p className="text-white text-sm whitespace-pre-wrap">{searchResult.answer}</p>
|
| 326 |
+
</div>
|
| 327 |
+
<button
|
| 328 |
+
onClick={clearSearch}
|
| 329 |
+
className="text-purple-300/50 hover:text-white text-sm"
|
| 330 |
+
>
|
| 331 |
+
Clear
|
| 332 |
+
</button>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
)}
|
| 336 |
+
</div>
|
| 337 |
+
|
| 338 |
{isLoading ? (
|
| 339 |
<div className="flex items-center justify-center h-64">
|
| 340 |
<div className="text-purple-300">Loading...</div>
|
|
|
|
| 342 |
) : (
|
| 343 |
<div className="grid lg:grid-cols-4 gap-6">
|
| 344 |
{/* Main Content - Kanban Board (3 cols) */}
|
| 345 |
+
<div className="lg:col-span-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
{/* Kanban Board */}
|
| 347 |
<div className="grid md:grid-cols-3 gap-4">
|
| 348 |
{/* Todo Column */}
|
|
|
|
| 374 |
{todoTasks.length === 0 && (
|
| 375 |
<p className="text-purple-300/40 text-sm text-center py-4">No tasks</p>
|
| 376 |
)}
|
| 377 |
+
{/* Generate More Tasks Button - shows when < 30 todos */}
|
| 378 |
+
{todoTasks.length < 30 && (
|
| 379 |
+
<button
|
| 380 |
+
onClick={handleGenerateMoreTasks}
|
| 381 |
+
disabled={isGeneratingTasks}
|
| 382 |
+
className="mt-3 w-full px-3 py-2 bg-purple-500/20 hover:bg-purple-500/30 disabled:bg-purple-500/10 text-purple-300 disabled:text-purple-400/50 text-xs font-medium rounded border border-dashed border-purple-500/30 transition-all flex items-center justify-center gap-2"
|
| 383 |
+
>
|
| 384 |
+
{isGeneratingTasks ? (
|
| 385 |
+
<>
|
| 386 |
+
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 387 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 388 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 389 |
+
</svg>
|
| 390 |
+
Generating...
|
| 391 |
+
</>
|
| 392 |
+
) : (
|
| 393 |
+
<>+ Generate 10 More Tasks</>
|
| 394 |
+
)}
|
| 395 |
+
</button>
|
| 396 |
+
)}
|
| 397 |
</div>
|
| 398 |
</div>
|
| 399 |
|
|
|
|
| 406 |
</h3>
|
| 407 |
</div>
|
| 408 |
<div className="p-3 space-y-2 max-h-[60vh] overflow-y-auto">
|
| 409 |
+
{inProgressTasks.map((task) => {
|
| 410 |
+
const isOtherUserWorking = task.working_by && task.working_by !== user.id;
|
| 411 |
+
const isCurrentUserWorking = task.working_by === user.id;
|
| 412 |
+
|
| 413 |
+
return (
|
| 414 |
+
<div
|
| 415 |
+
key={task.id}
|
| 416 |
+
className="bg-white/5 rounded-lg p-3 border border-white/5"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
>
|
| 418 |
+
<h4 className="text-white text-sm font-medium truncate">{task.title}</h4>
|
| 419 |
+
{task.description && (
|
| 420 |
+
<p className="text-purple-300/60 text-xs mt-1 line-clamp-2">{task.description}</p>
|
| 421 |
+
)}
|
| 422 |
+
{/* Show who is working on this task */}
|
| 423 |
+
{task.working_by && (
|
| 424 |
+
<p className="text-blue-300 text-xs mt-1">
|
| 425 |
+
{isCurrentUserWorking ? 'You are' : `${getWorkerName(task.working_by)} is`} working
|
| 426 |
+
</p>
|
| 427 |
+
)}
|
| 428 |
+
{isOtherUserWorking ? (
|
| 429 |
+
<button
|
| 430 |
+
disabled
|
| 431 |
+
className="mt-2 w-full px-3 py-1.5 bg-gray-500/20 text-gray-400 text-xs font-medium rounded cursor-not-allowed"
|
| 432 |
+
>
|
| 433 |
+
{getWorkerName(task.working_by)} is working
|
| 434 |
+
</button>
|
| 435 |
+
) : (
|
| 436 |
+
<button
|
| 437 |
+
onClick={() => handleStartWorking(task)}
|
| 438 |
+
className="mt-2 w-full px-3 py-1.5 bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 text-xs font-medium rounded transition-all"
|
| 439 |
+
>
|
| 440 |
+
Continue Working
|
| 441 |
+
</button>
|
| 442 |
+
)}
|
| 443 |
+
</div>
|
| 444 |
+
);
|
| 445 |
+
})}
|
| 446 |
{inProgressTasks.length === 0 && (
|
| 447 |
<p className="text-purple-300/40 text-sm text-center py-4">No tasks</p>
|
| 448 |
)}
|
|
|
|
| 487 |
<div className="p-3 space-y-2 max-h-48 overflow-y-auto">
|
| 488 |
{members.map((member) => (
|
| 489 |
<div key={member.id} className="flex items-center gap-3">
|
| 490 |
+
{member.role === 'agent' ? (
|
| 491 |
+
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center text-sm">
|
| 492 |
+
🤖
|
| 493 |
+
</div>
|
| 494 |
+
) : (
|
| 495 |
+
<div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-xs font-medium">
|
| 496 |
+
{member.firstName[0]}{member.lastName[0]}
|
| 497 |
+
</div>
|
| 498 |
+
)}
|
| 499 |
<div className="flex-1 min-w-0">
|
| 500 |
<p className="text-white text-sm truncate">
|
| 501 |
{member.firstName} {member.lastName}
|
| 502 |
</p>
|
| 503 |
+
<p className={`text-xs ${member.role === 'agent' ? 'text-cyan-300/50' : 'text-purple-300/50'}`}>
|
| 504 |
+
{member.role}
|
| 505 |
+
</p>
|
| 506 |
</div>
|
| 507 |
+
{member.role === 'agent' && currentProject.role === 'owner' && (
|
| 508 |
+
<button
|
| 509 |
+
onClick={handleRemoveAgent}
|
| 510 |
+
disabled={isAgentLoading}
|
| 511 |
+
className="text-red-400/70 hover:text-red-400 text-xs disabled:opacity-50"
|
| 512 |
+
>
|
| 513 |
+
Remove
|
| 514 |
+
</button>
|
| 515 |
+
)}
|
| 516 |
</div>
|
| 517 |
))}
|
| 518 |
+
|
| 519 |
+
{/* Add AI Agent Button - only if no agent and user is owner */}
|
| 520 |
+
{!hasAgent && currentProject.role === 'owner' && (
|
| 521 |
+
<button
|
| 522 |
+
onClick={handleAddAgent}
|
| 523 |
+
disabled={isAgentLoading}
|
| 524 |
+
className="mt-2 w-full px-3 py-2 bg-cyan-500/20 hover:bg-cyan-500/30 disabled:bg-cyan-500/10 text-cyan-300 disabled:text-cyan-400/50 text-xs font-medium rounded border border-dashed border-cyan-500/30 transition-all flex items-center justify-center gap-2"
|
| 525 |
+
>
|
| 526 |
+
{isAgentLoading ? (
|
| 527 |
+
<>
|
| 528 |
+
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 529 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 530 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 531 |
+
</svg>
|
| 532 |
+
Adding...
|
| 533 |
+
</>
|
| 534 |
+
) : (
|
| 535 |
+
<>🤖 Add AI Agent</>
|
| 536 |
+
)}
|
| 537 |
+
</button>
|
| 538 |
+
)}
|
| 539 |
</div>
|
| 540 |
</div>
|
| 541 |
|
frontend/src/types/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface Task {
|
|
| 37 |
description: string;
|
| 38 |
status: 'todo' | 'in_progress' | 'done';
|
| 39 |
assigned_to?: string;
|
|
|
|
| 40 |
created_at: string;
|
| 41 |
}
|
| 42 |
|
|
@@ -52,7 +53,7 @@ export interface ProjectMember {
|
|
| 52 |
firstName: string;
|
| 53 |
lastName: string;
|
| 54 |
avatar_url?: string;
|
| 55 |
-
role: 'owner' | 'member';
|
| 56 |
joined_at: string;
|
| 57 |
}
|
| 58 |
|
|
|
|
| 37 |
description: string;
|
| 38 |
status: 'todo' | 'in_progress' | 'done';
|
| 39 |
assigned_to?: string;
|
| 40 |
+
working_by?: string; // User ID currently working on this task
|
| 41 |
created_at: string;
|
| 42 |
}
|
| 43 |
|
|
|
|
| 53 |
firstName: string;
|
| 54 |
lastName: string;
|
| 55 |
avatar_url?: string;
|
| 56 |
+
role: 'owner' | 'member' | 'agent';
|
| 57 |
joined_at: string;
|
| 58 |
}
|
| 59 |
|