Amal Nimmy Lal commited on
Commit
698b2c1
·
1 Parent(s): 876e77a

feat : updated pages

Browse files
backend/.env.example DELETED
File without changes
backend/Dockerfile CHANGED
@@ -1,43 +1,28 @@
 
1
  FROM python:3.11-slim
2
 
3
- # Create a non-root user with UID 1000 (matches HF Spaces runtime)
4
- RUN useradd -m -u 1000 user
5
 
6
- # Set HOME and include user's local bin in PATH
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 /tmp/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
- # Create /data directory (runtime-mounted on HF Spaces) with safe permissions
31
- RUN mkdir -p /data && chmod 700 /data
32
 
33
- # Switch to non-root user for subsequent steps and runtime
34
- USER user
35
 
36
- # Upgrade pip in user's environment
37
- RUN pip install --no-cache-dir --upgrade pip
38
 
39
- # Expose the port and set entrypoint to prepare /data at runtime
40
- EXPOSE 7860
41
 
42
- ENTRYPOINT ["/home/user/app/entrypoint.sh"]
43
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
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=7860,
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', 'all 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
- "assigned_to": genai.protos.Schema(
 
 
 
 
 
 
 
 
111
  type=genai.protos.Type.STRING,
112
- description="Filter by assignee user ID or 'agent'"
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 _ in range(max_iterations):
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 only using first/last name fields.
 
 
 
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
- # Query users who are members of this project and match first or last name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  matches = db.query(User, ProjectMembership).join(
296
  ProjectMembership, User.id == ProjectMembership.user_id
297
  ).filter(
298
  ProjectMembership.project_id == project_id,
299
- User.name.ilike(f"%{user_name}%")
 
 
 
 
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 named '{user_name}' found"
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
- assigned_to = args.get("assigned_to")
 
 
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
- # Apply assignee filter
590
- if assigned_to:
591
- base_query = base_query.filter(Task.assigned_to == assigned_to)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "assigned_to": assigned_to
 
 
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.human,
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:7860"
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 7860
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 7860;
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:7860;
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:7860;
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 space-y-6">
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
- <div
319
- key={task.id}
320
- className="bg-white/5 rounded-lg p-3 border border-white/5"
321
- >
322
- <h4 className="text-white text-sm font-medium truncate">{task.title}</h4>
323
- {task.description && (
324
- <p className="text-purple-300/60 text-xs mt-1 line-clamp-2">{task.description}</p>
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
- Continue Working
331
- </button>
332
- </div>
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
- <div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white text-xs font-medium">
379
- {member.firstName[0]}{member.lastName[0]}
380
- </div>
 
 
 
 
 
 
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="text-purple-300/50 text-xs">{member.role}</p>
 
 
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