Add Tavily web search and project editing improvements
Browse files- README.md +2 -0
- backend/.env.example +1 -0
- backend/services/config.py +1 -0
- backend/tools/browser.py +92 -16
- backend/tools/registry.py +14 -4
- frontend/src/components/AgentsView.tsx +61 -18
- frontend/src/components/NewProject.tsx +242 -3
- frontend/src/components/ProjectDetail.tsx +133 -18
README.md
CHANGED
|
@@ -73,6 +73,7 @@ SUPABASE_URL=your_project_url
|
|
| 73 |
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 74 |
OPENAI_API_KEY=optional_key
|
| 75 |
GROQ_API_KEY=optional_key
|
|
|
|
| 76 |
# See SPEC.md for all available providers
|
| 77 |
```
|
| 78 |
|
|
@@ -126,6 +127,7 @@ GROQ_API_KEY=optional_key
|
|
| 126 |
OPENAI_API_KEY=optional_key
|
| 127 |
GEMINI_API_KEY=optional_key
|
| 128 |
AMD_API_KEY=optional_key
|
|
|
|
| 129 |
TASK_QUEUE_EMBEDDED_WORKER=true
|
| 130 |
```
|
| 131 |
|
|
|
|
| 73 |
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
| 74 |
OPENAI_API_KEY=optional_key
|
| 75 |
GROQ_API_KEY=optional_key
|
| 76 |
+
TAVILY_API_KEY=optional_key
|
| 77 |
# See SPEC.md for all available providers
|
| 78 |
```
|
| 79 |
|
|
|
|
| 127 |
OPENAI_API_KEY=optional_key
|
| 128 |
GEMINI_API_KEY=optional_key
|
| 129 |
AMD_API_KEY=optional_key
|
| 130 |
+
TAVILY_API_KEY=optional_key
|
| 131 |
TASK_QUEUE_EMBEDDED_WORKER=true
|
| 132 |
```
|
| 133 |
|
backend/.env.example
CHANGED
|
@@ -7,6 +7,7 @@ OPENAI_API_KEY=your-openai-key
|
|
| 7 |
GROQ_API_KEY=your-groq-key
|
| 8 |
GEMINI_API_KEY=your-gemini-key
|
| 9 |
ANTHROPIC_API_KEY=your-anthropic-key
|
|
|
|
| 10 |
|
| 11 |
# App Settings
|
| 12 |
PORT=8000
|
|
|
|
| 7 |
GROQ_API_KEY=your-groq-key
|
| 8 |
GEMINI_API_KEY=your-gemini-key
|
| 9 |
ANTHROPIC_API_KEY=your-anthropic-key
|
| 10 |
+
TAVILY_API_KEY=your-tavily-key
|
| 11 |
|
| 12 |
# App Settings
|
| 13 |
PORT=8000
|
backend/services/config.py
CHANGED
|
@@ -14,6 +14,7 @@ class Settings(BaseSettings):
|
|
| 14 |
GEMINI_API_KEY: Optional[str] = None
|
| 15 |
ANTHROPIC_API_KEY: Optional[str] = None
|
| 16 |
AMD_API_KEY: Optional[str] = None
|
|
|
|
| 17 |
|
| 18 |
# App Config
|
| 19 |
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
|
|
|
| 14 |
GEMINI_API_KEY: Optional[str] = None
|
| 15 |
ANTHROPIC_API_KEY: Optional[str] = None
|
| 16 |
AMD_API_KEY: Optional[str] = None
|
| 17 |
+
TAVILY_API_KEY: Optional[str] = None
|
| 18 |
|
| 19 |
# App Config
|
| 20 |
TASK_QUEUE_EMBEDDED_WORKER: bool = True
|
backend/tools/browser.py
CHANGED
|
@@ -1,35 +1,111 @@
|
|
| 1 |
-
from playwright.async_api import async_playwright
|
| 2 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
logger = logging.getLogger("uvicorn")
|
| 5 |
|
|
|
|
| 6 |
class BrowserTool:
|
| 7 |
"""
|
| 8 |
-
|
| 9 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
async def search_and_extract(self, url: str) -> str:
|
| 11 |
"""
|
| 12 |
-
Navigates to a URL and returns the text content.
|
| 13 |
"""
|
| 14 |
-
logger.info(
|
| 15 |
-
async with async_playwright() as
|
| 16 |
-
browser = await
|
| 17 |
page = await browser.new_page()
|
| 18 |
try:
|
| 19 |
await page.goto(url, wait_until="networkidle", timeout=30000)
|
| 20 |
-
|
| 21 |
content = await page.inner_text("body")
|
| 22 |
-
|
| 23 |
-
return
|
| 24 |
-
except Exception as
|
| 25 |
-
logger.error(
|
| 26 |
-
return f"Error accessing {url}: {
|
| 27 |
finally:
|
| 28 |
await browser.close()
|
| 29 |
|
| 30 |
-
async def
|
| 31 |
"""
|
| 32 |
-
|
| 33 |
"""
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
from playwright.async_api import async_playwright
|
| 6 |
+
|
| 7 |
+
from services.config import settings
|
| 8 |
|
| 9 |
logger = logging.getLogger("uvicorn")
|
| 10 |
|
| 11 |
+
|
| 12 |
class BrowserTool:
|
| 13 |
"""
|
| 14 |
+
Tools for live web search and direct URL extraction.
|
| 15 |
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self) -> None:
|
| 18 |
+
self.tavily_api_key = settings.TAVILY_API_KEY
|
| 19 |
+
|
| 20 |
async def search_and_extract(self, url: str) -> str:
|
| 21 |
"""
|
| 22 |
+
Navigates to a URL and returns the page text content.
|
| 23 |
"""
|
| 24 |
+
logger.info("BrowserTool: Navigating to %s", url)
|
| 25 |
+
async with async_playwright() as playwright:
|
| 26 |
+
browser = await playwright.chromium.launch(headless=True)
|
| 27 |
page = await browser.new_page()
|
| 28 |
try:
|
| 29 |
await page.goto(url, wait_until="networkidle", timeout=30000)
|
| 30 |
+
title = await page.title()
|
| 31 |
content = await page.inner_text("body")
|
| 32 |
+
combined = f"Title: {title}\nURL: {url}\n\n{content}".strip()
|
| 33 |
+
return combined[:12000]
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
logger.error("BrowserTool extract error for %s: %s", url, exc)
|
| 36 |
+
return f"Error accessing {url}: {exc}"
|
| 37 |
finally:
|
| 38 |
await browser.close()
|
| 39 |
|
| 40 |
+
async def web_search(self, query: str, topic: str = "general", max_results: int = 5) -> str:
|
| 41 |
"""
|
| 42 |
+
Searches the public web with Tavily and returns LLM-friendly results.
|
| 43 |
"""
|
| 44 |
+
if not self.tavily_api_key:
|
| 45 |
+
return (
|
| 46 |
+
"Web search is unavailable: TAVILY_API_KEY is not configured. "
|
| 47 |
+
"Add it to the backend environment to enable internet search."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
payload = {
|
| 51 |
+
"query": query,
|
| 52 |
+
"topic": topic if topic in {"general", "news", "finance"} else "general",
|
| 53 |
+
"search_depth": "advanced",
|
| 54 |
+
"max_results": max(1, min(max_results, 10)),
|
| 55 |
+
"include_answer": "advanced",
|
| 56 |
+
"include_raw_content": False,
|
| 57 |
+
"include_images": False,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
headers = {
|
| 61 |
+
"Authorization": f"Bearer {self.tavily_api_key}",
|
| 62 |
+
"Content-Type": "application/json",
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
async with httpx.AsyncClient(timeout=45.0) as client:
|
| 67 |
+
response = await client.post(
|
| 68 |
+
"https://api.tavily.com/search",
|
| 69 |
+
headers=headers,
|
| 70 |
+
json=payload,
|
| 71 |
+
)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
except httpx.HTTPStatusError as exc:
|
| 74 |
+
detail = exc.response.text[:500] if exc.response is not None else str(exc)
|
| 75 |
+
logger.error("Tavily HTTP error: %s", detail)
|
| 76 |
+
return f"Tavily search failed with status {exc.response.status_code}: {detail}"
|
| 77 |
+
except Exception as exc:
|
| 78 |
+
logger.error("Tavily request error: %s", exc)
|
| 79 |
+
return f"Tavily search failed: {exc}"
|
| 80 |
+
|
| 81 |
+
data = response.json()
|
| 82 |
+
return self._format_tavily_results(query, data)
|
| 83 |
+
|
| 84 |
+
def _format_tavily_results(self, query: str, data: dict[str, Any]) -> str:
|
| 85 |
+
answer = data.get("answer")
|
| 86 |
+
results = data.get("results") or []
|
| 87 |
+
|
| 88 |
+
lines = [f"Search query: {query}"]
|
| 89 |
+
if answer:
|
| 90 |
+
lines.extend(["", "Answer:", str(answer).strip()])
|
| 91 |
+
|
| 92 |
+
if not results:
|
| 93 |
+
lines.extend(["", "No search results returned."])
|
| 94 |
+
return "\n".join(lines)
|
| 95 |
+
|
| 96 |
+
lines.extend(["", "Sources:"])
|
| 97 |
+
for index, result in enumerate(results, start=1):
|
| 98 |
+
title = result.get("title") or "Untitled"
|
| 99 |
+
url = result.get("url") or ""
|
| 100 |
+
snippet = (result.get("content") or "").strip()
|
| 101 |
+
score = result.get("score")
|
| 102 |
+
|
| 103 |
+
lines.append(f"{index}. {title}")
|
| 104 |
+
if url:
|
| 105 |
+
lines.append(f" URL: {url}")
|
| 106 |
+
if score is not None:
|
| 107 |
+
lines.append(f" Score: {score}")
|
| 108 |
+
if snippet:
|
| 109 |
+
lines.append(f" Snippet: {snippet[:900]}")
|
| 110 |
+
|
| 111 |
+
return "\n".join(lines)[:12000]
|
backend/tools/registry.py
CHANGED
|
@@ -16,8 +16,8 @@ class ToolRegistry:
|
|
| 16 |
self.visuals = VisualsTool()
|
| 17 |
self.tools = {
|
| 18 |
"web_search": {
|
| 19 |
-
"func": self.browser.
|
| 20 |
-
"description": "Searches the web
|
| 21 |
},
|
| 22 |
"extract_url": {
|
| 23 |
"func": self.browser.search_and_extract,
|
|
@@ -70,11 +70,21 @@ class ToolRegistry:
|
|
| 70 |
"type": "function",
|
| 71 |
"function": {
|
| 72 |
"name": "web_search",
|
| 73 |
-
"description": "Search the web for information",
|
| 74 |
"parameters": {
|
| 75 |
"type": "object",
|
| 76 |
"properties": {
|
| 77 |
-
"query": {"type": "string", "description": "The search query"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
},
|
| 79 |
"required": ["query"]
|
| 80 |
}
|
|
|
|
| 16 |
self.visuals = VisualsTool()
|
| 17 |
self.tools = {
|
| 18 |
"web_search": {
|
| 19 |
+
"func": self.browser.web_search,
|
| 20 |
+
"description": "Searches the public web using Tavily and returns summarized results with source URLs."
|
| 21 |
},
|
| 22 |
"extract_url": {
|
| 23 |
"func": self.browser.search_and_extract,
|
|
|
|
| 70 |
"type": "function",
|
| 71 |
"function": {
|
| 72 |
"name": "web_search",
|
| 73 |
+
"description": "Search the public web for information using Tavily. Use this when the task requires current external information.",
|
| 74 |
"parameters": {
|
| 75 |
"type": "object",
|
| 76 |
"properties": {
|
| 77 |
+
"query": {"type": "string", "description": "The search query"},
|
| 78 |
+
"topic": {
|
| 79 |
+
"type": "string",
|
| 80 |
+
"enum": ["general", "news", "finance"],
|
| 81 |
+
"description": "The search category. Use news for recent events and finance for market/company financial queries."
|
| 82 |
+
},
|
| 83 |
+
"max_results": {
|
| 84 |
+
"type": "integer",
|
| 85 |
+
"description": "Maximum number of results to return. Keep this small to control context size.",
|
| 86 |
+
"default": 5
|
| 87 |
+
}
|
| 88 |
},
|
| 89 |
"required": ["query"]
|
| 90 |
}
|
frontend/src/components/AgentsView.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useEffect, useMemo, useState } from 'react';
|
| 2 |
-
import { Bot, CheckCircle2, PlusCircle, RefreshCw } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
|
@@ -20,6 +20,7 @@ const AgentsView: React.FC = () => {
|
|
| 20 |
const { user } = useAuth();
|
| 21 |
const defaultProvider = useMemo(() => getDefaultProvider(), []);
|
| 22 |
const [agents, setAgents] = useState<Agent[]>([]);
|
|
|
|
| 23 |
const [name, setName] = useState('');
|
| 24 |
const [role, setRole] = useState('');
|
| 25 |
const [provider, setProvider] = useState<SupportedProvider>(defaultProvider);
|
|
@@ -31,6 +32,7 @@ const AgentsView: React.FC = () => {
|
|
| 31 |
const [error, setError] = useState<string | null>(null);
|
| 32 |
|
| 33 |
const providerModels = providerOptions.find((option) => option.id === provider)?.models ?? [];
|
|
|
|
| 34 |
|
| 35 |
const loadAgents = async () => {
|
| 36 |
setLoading(true);
|
|
@@ -55,10 +57,30 @@ const AgentsView: React.FC = () => {
|
|
| 55 |
setModel(getDefaultModel(value));
|
| 56 |
};
|
| 57 |
|
| 58 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
event.preventDefault();
|
| 60 |
if (!user) {
|
| 61 |
-
setError(
|
| 62 |
return;
|
| 63 |
}
|
| 64 |
|
|
@@ -66,22 +88,24 @@ const AgentsView: React.FC = () => {
|
|
| 66 |
setError(null);
|
| 67 |
setMessage(null);
|
| 68 |
|
| 69 |
-
const
|
| 70 |
user_id: user.id,
|
| 71 |
name,
|
| 72 |
role,
|
| 73 |
api_provider: provider,
|
| 74 |
model,
|
| 75 |
system_prompt: systemPrompt || `You are ${name}, acting as ${role || 'an AI agent'}.`
|
| 76 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
-
if (
|
| 79 |
-
setError(
|
| 80 |
} else {
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
setSystemPrompt('');
|
| 84 |
-
setMessage('Agent created successfully.');
|
| 85 |
await loadAgents();
|
| 86 |
}
|
| 87 |
|
|
@@ -109,11 +133,18 @@ const AgentsView: React.FC = () => {
|
|
| 109 |
|
| 110 |
<div className="project-detail-grid">
|
| 111 |
<section className="glass-panel project-form">
|
| 112 |
-
<div className="settings-section-title">
|
| 113 |
-
<
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
</div>
|
| 116 |
-
<form onSubmit={
|
| 117 |
<label>
|
| 118 |
<span>Name</span>
|
| 119 |
<input value={name} onChange={(event) => setName(event.target.value)} required placeholder="Research Analyst" />
|
|
@@ -146,7 +177,7 @@ const AgentsView: React.FC = () => {
|
|
| 146 |
</label>
|
| 147 |
<button className="btn btn-primary" type="submit" disabled={saving}>
|
| 148 |
<Bot size={18} />
|
| 149 |
-
{saving ? 'Creating...' : 'Create Agent'}
|
| 150 |
</button>
|
| 151 |
</form>
|
| 152 |
</section>
|
|
@@ -159,13 +190,25 @@ const AgentsView: React.FC = () => {
|
|
| 159 |
{agents.length === 0 && <p style={{ color: 'var(--text-dim)' }}>No agents created yet.</p>}
|
| 160 |
<div className="task-list">
|
| 161 |
{agents.map((agent) => (
|
| 162 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
<div>
|
| 164 |
<strong>{agent.name}</strong>
|
| 165 |
<p>{agent.role || 'No role provided.'}</p>
|
| 166 |
</div>
|
| 167 |
<span>{agent.api_provider} / {agent.model}</span>
|
| 168 |
-
</
|
| 169 |
))}
|
| 170 |
</div>
|
| 171 |
</section>
|
|
|
|
| 1 |
import React, { useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import { Bot, CheckCircle2, PlusCircle, RefreshCw, X } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
|
|
|
| 20 |
const { user } = useAuth();
|
| 21 |
const defaultProvider = useMemo(() => getDefaultProvider(), []);
|
| 22 |
const [agents, setAgents] = useState<Agent[]>([]);
|
| 23 |
+
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
| 24 |
const [name, setName] = useState('');
|
| 25 |
const [role, setRole] = useState('');
|
| 26 |
const [provider, setProvider] = useState<SupportedProvider>(defaultProvider);
|
|
|
|
| 32 |
const [error, setError] = useState<string | null>(null);
|
| 33 |
|
| 34 |
const providerModels = providerOptions.find((option) => option.id === provider)?.models ?? [];
|
| 35 |
+
const isEditing = selectedAgentId !== null;
|
| 36 |
|
| 37 |
const loadAgents = async () => {
|
| 38 |
setLoading(true);
|
|
|
|
| 57 |
setModel(getDefaultModel(value));
|
| 58 |
};
|
| 59 |
|
| 60 |
+
const resetForm = () => {
|
| 61 |
+
setSelectedAgentId(null);
|
| 62 |
+
setName('');
|
| 63 |
+
setRole('');
|
| 64 |
+
setProvider(defaultProvider);
|
| 65 |
+
setModel(getDefaultModel(defaultProvider));
|
| 66 |
+
setSystemPrompt('');
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
const selectAgent = (agent: Agent) => {
|
| 70 |
+
setSelectedAgentId(agent.id);
|
| 71 |
+
setName(agent.name);
|
| 72 |
+
setRole(agent.role ?? '');
|
| 73 |
+
setProvider(agent.api_provider);
|
| 74 |
+
setModel(agent.model);
|
| 75 |
+
setSystemPrompt(agent.system_prompt ?? '');
|
| 76 |
+
setMessage(null);
|
| 77 |
+
setError(null);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const saveAgent = async (event: React.FormEvent) => {
|
| 81 |
event.preventDefault();
|
| 82 |
if (!user) {
|
| 83 |
+
setError(`You must be signed in to ${isEditing ? 'update' : 'create'} an agent.`);
|
| 84 |
return;
|
| 85 |
}
|
| 86 |
|
|
|
|
| 88 |
setError(null);
|
| 89 |
setMessage(null);
|
| 90 |
|
| 91 |
+
const payload = {
|
| 92 |
user_id: user.id,
|
| 93 |
name,
|
| 94 |
role,
|
| 95 |
api_provider: provider,
|
| 96 |
model,
|
| 97 |
system_prompt: systemPrompt || `You are ${name}, acting as ${role || 'an AI agent'}.`
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const response = isEditing
|
| 101 |
+
? await supabase.from('agents').update(payload).eq('id', selectedAgentId)
|
| 102 |
+
: await supabase.from('agents').insert(payload);
|
| 103 |
|
| 104 |
+
if (response.error) {
|
| 105 |
+
setError(response.error.message);
|
| 106 |
} else {
|
| 107 |
+
resetForm();
|
| 108 |
+
setMessage(isEditing ? 'Agent updated successfully.' : 'Agent created successfully.');
|
|
|
|
|
|
|
| 109 |
await loadAgents();
|
| 110 |
}
|
| 111 |
|
|
|
|
| 133 |
|
| 134 |
<div className="project-detail-grid">
|
| 135 |
<section className="glass-panel project-form">
|
| 136 |
+
<div className="settings-section-title" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
| 137 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
| 138 |
+
<PlusCircle size={22} color="var(--accent)" />
|
| 139 |
+
<h3>{isEditing ? 'Edit Agent' : 'Create Agent'}</h3>
|
| 140 |
+
</div>
|
| 141 |
+
{isEditing && (
|
| 142 |
+
<button className="btn btn-icon" type="button" onClick={resetForm} title="Close editor">
|
| 143 |
+
<X size={18} />
|
| 144 |
+
</button>
|
| 145 |
+
)}
|
| 146 |
</div>
|
| 147 |
+
<form onSubmit={saveAgent} style={{ display: 'grid', gap: 'var(--space-md)' }}>
|
| 148 |
<label>
|
| 149 |
<span>Name</span>
|
| 150 |
<input value={name} onChange={(event) => setName(event.target.value)} required placeholder="Research Analyst" />
|
|
|
|
| 177 |
</label>
|
| 178 |
<button className="btn btn-primary" type="submit" disabled={saving}>
|
| 179 |
<Bot size={18} />
|
| 180 |
+
{saving ? (isEditing ? 'Saving...' : 'Creating...') : (isEditing ? 'Save Agent' : 'Create Agent')}
|
| 181 |
</button>
|
| 182 |
</form>
|
| 183 |
</section>
|
|
|
|
| 190 |
{agents.length === 0 && <p style={{ color: 'var(--text-dim)' }}>No agents created yet.</p>}
|
| 191 |
<div className="task-list">
|
| 192 |
{agents.map((agent) => (
|
| 193 |
+
<button
|
| 194 |
+
key={agent.id}
|
| 195 |
+
type="button"
|
| 196 |
+
className="task-row"
|
| 197 |
+
onClick={() => selectAgent(agent)}
|
| 198 |
+
style={{
|
| 199 |
+
width: '100%',
|
| 200 |
+
background: selectedAgentId === agent.id ? 'rgba(255,255,255,0.06)' : undefined,
|
| 201 |
+
borderColor: selectedAgentId === agent.id ? 'var(--accent)' : undefined,
|
| 202 |
+
textAlign: 'left',
|
| 203 |
+
cursor: 'pointer'
|
| 204 |
+
}}
|
| 205 |
+
>
|
| 206 |
<div>
|
| 207 |
<strong>{agent.name}</strong>
|
| 208 |
<p>{agent.role || 'No role provided.'}</p>
|
| 209 |
</div>
|
| 210 |
<span>{agent.api_provider} / {agent.model}</span>
|
| 211 |
+
</button>
|
| 212 |
))}
|
| 213 |
</div>
|
| 214 |
</section>
|
frontend/src/components/NewProject.tsx
CHANGED
|
@@ -1,18 +1,177 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import { CheckCircle2, FileText, PlusCircle } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
const NewProject: React.FC<{ onCreated?: () => void }> = ({ onCreated }) => {
|
| 8 |
const { user } = useAuth();
|
|
|
|
| 9 |
const [name, setName] = useState('');
|
| 10 |
const [description, setDescription] = useState('');
|
| 11 |
const [context, setContext] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const [isPublic, setIsPublic] = useState(false);
|
| 13 |
const [saving, setSaving] = useState(false);
|
| 14 |
const [message, setMessage] = useState<string | null>(null);
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const handleSubmit = async (event: React.FormEvent) => {
|
| 17 |
event.preventDefault();
|
| 18 |
if (!user) {
|
|
@@ -23,10 +182,12 @@ const NewProject: React.FC<{ onCreated?: () => void }> = ({ onCreated }) => {
|
|
| 23 |
setSaving(true);
|
| 24 |
setMessage(null);
|
| 25 |
|
|
|
|
|
|
|
| 26 |
const { error } = await supabase.from('projects').insert({
|
| 27 |
name,
|
| 28 |
description,
|
| 29 |
-
context,
|
| 30 |
owner_id: user.id,
|
| 31 |
is_public: isPublic,
|
| 32 |
status: 'active'
|
|
@@ -38,6 +199,11 @@ const NewProject: React.FC<{ onCreated?: () => void }> = ({ onCreated }) => {
|
|
| 38 |
setName('');
|
| 39 |
setDescription('');
|
| 40 |
setContext('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
setIsPublic(false);
|
| 42 |
setMessage('Project created successfully.');
|
| 43 |
window.setTimeout(() => onCreated?.(), 500);
|
|
@@ -72,6 +238,79 @@ const NewProject: React.FC<{ onCreated?: () => void }> = ({ onCreated }) => {
|
|
| 72 |
<textarea value={context} onChange={(event) => setContext(event.target.value)} placeholder="Business constraints, preferred tone, source links, acceptance criteria..." rows={6} />
|
| 73 |
</label>
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
<label className="toggle-row">
|
| 76 |
<input type="checkbox" checked={isPublic} onChange={(event) => setIsPublic(event.target.checked)} />
|
| 77 |
<span>Make project visible to authenticated users</span>
|
|
|
|
| 1 |
+
import React, { useRef, useState } from 'react';
|
| 2 |
+
import { CheckCircle2, FileText, Link2, Paperclip, PlusCircle, StickyNote, Trash2 } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
| 6 |
|
| 7 |
+
type ProjectSource =
|
| 8 |
+
| {
|
| 9 |
+
id: string;
|
| 10 |
+
kind: 'link';
|
| 11 |
+
label: string;
|
| 12 |
+
url: string;
|
| 13 |
+
}
|
| 14 |
+
| {
|
| 15 |
+
id: string;
|
| 16 |
+
kind: 'note';
|
| 17 |
+
label: string;
|
| 18 |
+
content: string;
|
| 19 |
+
}
|
| 20 |
+
| {
|
| 21 |
+
id: string;
|
| 22 |
+
kind: 'file';
|
| 23 |
+
label: string;
|
| 24 |
+
fileName: string;
|
| 25 |
+
mimeType: string;
|
| 26 |
+
size: number;
|
| 27 |
+
content?: string;
|
| 28 |
+
extracted: boolean;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const supportedTextMimeTypes = new Set([
|
| 32 |
+
'text/plain',
|
| 33 |
+
'text/markdown',
|
| 34 |
+
'text/csv',
|
| 35 |
+
'application/json',
|
| 36 |
+
]);
|
| 37 |
+
|
| 38 |
+
const formatFileSize = (size: number) => {
|
| 39 |
+
if (size < 1024) return `${size} B`;
|
| 40 |
+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
|
| 41 |
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const buildContextPayload = (baseContext: string, sources: ProjectSource[]) => {
|
| 45 |
+
const sections: string[] = [];
|
| 46 |
+
|
| 47 |
+
const trimmedContext = baseContext.trim();
|
| 48 |
+
if (trimmedContext) {
|
| 49 |
+
sections.push(trimmedContext);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (sources.length) {
|
| 53 |
+
const sourceLines = sources.flatMap((source, index) => {
|
| 54 |
+
if (source.kind === 'link') {
|
| 55 |
+
return [`${index + 1}. [${source.label}](${source.url})`];
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (source.kind === 'note') {
|
| 59 |
+
return [
|
| 60 |
+
`${index + 1}. ${source.label}`,
|
| 61 |
+
source.content,
|
| 62 |
+
];
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
const metadata = `${source.fileName} (${source.mimeType || 'unknown'}, ${formatFileSize(source.size)})`;
|
| 66 |
+
if (source.extracted && source.content) {
|
| 67 |
+
return [
|
| 68 |
+
`${index + 1}. ${source.label} - ${metadata}`,
|
| 69 |
+
source.content,
|
| 70 |
+
];
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return [`${index + 1}. ${source.label} - ${metadata}`];
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
sections.push(`Project Sources:\n${sourceLines.join('\n\n')}`);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return sections.join('\n\n').trim();
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
const NewProject: React.FC<{ onCreated?: () => void }> = ({ onCreated }) => {
|
| 83 |
const { user } = useAuth();
|
| 84 |
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
| 85 |
const [name, setName] = useState('');
|
| 86 |
const [description, setDescription] = useState('');
|
| 87 |
const [context, setContext] = useState('');
|
| 88 |
+
const [sourceLabel, setSourceLabel] = useState('');
|
| 89 |
+
const [sourceUrl, setSourceUrl] = useState('');
|
| 90 |
+
const [noteLabel, setNoteLabel] = useState('');
|
| 91 |
+
const [noteContent, setNoteContent] = useState('');
|
| 92 |
+
const [sources, setSources] = useState<ProjectSource[]>([]);
|
| 93 |
const [isPublic, setIsPublic] = useState(false);
|
| 94 |
const [saving, setSaving] = useState(false);
|
| 95 |
const [message, setMessage] = useState<string | null>(null);
|
| 96 |
|
| 97 |
+
const appendSource = (source: ProjectSource) => {
|
| 98 |
+
setSources((current) => [...current, source]);
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const handleAddLink = () => {
|
| 102 |
+
if (!sourceUrl.trim()) {
|
| 103 |
+
setMessage('Add a valid link before saving it.');
|
| 104 |
+
return;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
appendSource({
|
| 108 |
+
id: crypto.randomUUID(),
|
| 109 |
+
kind: 'link',
|
| 110 |
+
label: sourceLabel.trim() || sourceUrl.trim(),
|
| 111 |
+
url: sourceUrl.trim(),
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
setSourceLabel('');
|
| 115 |
+
setSourceUrl('');
|
| 116 |
+
setMessage(null);
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
const handleAddNote = () => {
|
| 120 |
+
if (!noteContent.trim()) {
|
| 121 |
+
setMessage('Write some note content before saving it.');
|
| 122 |
+
return;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
appendSource({
|
| 126 |
+
id: crypto.randomUUID(),
|
| 127 |
+
kind: 'note',
|
| 128 |
+
label: noteLabel.trim() || 'Inline note',
|
| 129 |
+
content: noteContent.trim(),
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
setNoteLabel('');
|
| 133 |
+
setNoteContent('');
|
| 134 |
+
setMessage(null);
|
| 135 |
+
};
|
| 136 |
+
|
| 137 |
+
const handleFileSelection = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 138 |
+
const files = Array.from(event.target.files ?? []);
|
| 139 |
+
const nextSources: ProjectSource[] = [];
|
| 140 |
+
|
| 141 |
+
for (const file of files) {
|
| 142 |
+
const canExtractText =
|
| 143 |
+
supportedTextMimeTypes.has(file.type) ||
|
| 144 |
+
/\.(md|txt|csv|json)$/i.test(file.name);
|
| 145 |
+
|
| 146 |
+
let content: string | undefined;
|
| 147 |
+
if (canExtractText) {
|
| 148 |
+
content = (await file.text()).slice(0, 12000);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
nextSources.push({
|
| 152 |
+
id: crypto.randomUUID(),
|
| 153 |
+
kind: 'file',
|
| 154 |
+
label: file.name,
|
| 155 |
+
fileName: file.name,
|
| 156 |
+
mimeType: file.type,
|
| 157 |
+
size: file.size,
|
| 158 |
+
content,
|
| 159 |
+
extracted: Boolean(content),
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (nextSources.length) {
|
| 164 |
+
setSources((current) => [...current, ...nextSources]);
|
| 165 |
+
setMessage(null);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
event.target.value = '';
|
| 169 |
+
};
|
| 170 |
+
|
| 171 |
+
const removeSource = (id: string) => {
|
| 172 |
+
setSources((current) => current.filter((source) => source.id !== id));
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
const handleSubmit = async (event: React.FormEvent) => {
|
| 176 |
event.preventDefault();
|
| 177 |
if (!user) {
|
|
|
|
| 182 |
setSaving(true);
|
| 183 |
setMessage(null);
|
| 184 |
|
| 185 |
+
const contextPayload = buildContextPayload(context, sources);
|
| 186 |
+
|
| 187 |
const { error } = await supabase.from('projects').insert({
|
| 188 |
name,
|
| 189 |
description,
|
| 190 |
+
context: contextPayload,
|
| 191 |
owner_id: user.id,
|
| 192 |
is_public: isPublic,
|
| 193 |
status: 'active'
|
|
|
|
| 199 |
setName('');
|
| 200 |
setDescription('');
|
| 201 |
setContext('');
|
| 202 |
+
setSourceLabel('');
|
| 203 |
+
setSourceUrl('');
|
| 204 |
+
setNoteLabel('');
|
| 205 |
+
setNoteContent('');
|
| 206 |
+
setSources([]);
|
| 207 |
setIsPublic(false);
|
| 208 |
setMessage('Project created successfully.');
|
| 209 |
window.setTimeout(() => onCreated?.(), 500);
|
|
|
|
| 238 |
<textarea value={context} onChange={(event) => setContext(event.target.value)} placeholder="Business constraints, preferred tone, source links, acceptance criteria..." rows={6} />
|
| 239 |
</label>
|
| 240 |
|
| 241 |
+
<div className="default-agent-panel" style={{ gap: 'var(--space-lg)' }}>
|
| 242 |
+
<div className="settings-section-title">
|
| 243 |
+
<Paperclip size={20} color="var(--accent)" />
|
| 244 |
+
<h3>Project Sources</h3>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div className="responsive-two-col">
|
| 248 |
+
<label>
|
| 249 |
+
<span>Link Label</span>
|
| 250 |
+
<input value={sourceLabel} onChange={(event) => setSourceLabel(event.target.value)} placeholder="Market report" />
|
| 251 |
+
</label>
|
| 252 |
+
<label>
|
| 253 |
+
<span>Link URL</span>
|
| 254 |
+
<input value={sourceUrl} onChange={(event) => setSourceUrl(event.target.value)} placeholder="https://..." />
|
| 255 |
+
</label>
|
| 256 |
+
</div>
|
| 257 |
+
<button className="btn btn-glass" type="button" onClick={handleAddLink}>
|
| 258 |
+
<Link2 size={16} />
|
| 259 |
+
Add Link
|
| 260 |
+
</button>
|
| 261 |
+
|
| 262 |
+
<label>
|
| 263 |
+
<span>Quick Note</span>
|
| 264 |
+
<input value={noteLabel} onChange={(event) => setNoteLabel(event.target.value)} placeholder="Stakeholder note" />
|
| 265 |
+
</label>
|
| 266 |
+
<label>
|
| 267 |
+
<span>Note Content</span>
|
| 268 |
+
<textarea value={noteContent} onChange={(event) => setNoteContent(event.target.value)} rows={3} placeholder="Paste text, markdown, requirements, or snippets..." />
|
| 269 |
+
</label>
|
| 270 |
+
<button className="btn btn-glass" type="button" onClick={handleAddNote}>
|
| 271 |
+
<StickyNote size={16} />
|
| 272 |
+
Add Text
|
| 273 |
+
</button>
|
| 274 |
+
|
| 275 |
+
<input
|
| 276 |
+
ref={fileInputRef}
|
| 277 |
+
type="file"
|
| 278 |
+
multiple
|
| 279 |
+
accept=".pdf,.md,.txt,.doc,.docx,.xls,.xlsx,.csv,.json,.rtf"
|
| 280 |
+
onChange={handleFileSelection}
|
| 281 |
+
style={{ display: 'none' }}
|
| 282 |
+
/>
|
| 283 |
+
<button className="btn btn-glass" type="button" onClick={() => fileInputRef.current?.click()}>
|
| 284 |
+
<Paperclip size={16} />
|
| 285 |
+
Add Files
|
| 286 |
+
</button>
|
| 287 |
+
|
| 288 |
+
<p style={{ color: 'var(--text-dim)', fontSize: '0.85rem', marginTop: '-0.25rem' }}>
|
| 289 |
+
Text and markdown files are embedded into project context. PDF, Word, and Excel files are stored as named references in the context.
|
| 290 |
+
</p>
|
| 291 |
+
|
| 292 |
+
{sources.length > 0 && (
|
| 293 |
+
<div className="task-list">
|
| 294 |
+
{sources.map((source) => (
|
| 295 |
+
<div key={source.id} className="task-row">
|
| 296 |
+
<div style={{ flex: 1 }}>
|
| 297 |
+
<strong>{source.label}</strong>
|
| 298 |
+
<p>
|
| 299 |
+
{source.kind === 'link' && source.url}
|
| 300 |
+
{source.kind === 'note' && source.content}
|
| 301 |
+
{source.kind === 'file' && `${source.fileName} · ${formatFileSize(source.size)}${source.extracted ? ' · text imported' : ' · reference only'}`}
|
| 302 |
+
</p>
|
| 303 |
+
</div>
|
| 304 |
+
<button className="btn btn-glass btn-sm" type="button" onClick={() => removeSource(source.id)}>
|
| 305 |
+
<Trash2 size={14} />
|
| 306 |
+
Remove
|
| 307 |
+
</button>
|
| 308 |
+
</div>
|
| 309 |
+
))}
|
| 310 |
+
</div>
|
| 311 |
+
)}
|
| 312 |
+
</div>
|
| 313 |
+
|
| 314 |
<label className="toggle-row">
|
| 315 |
<input type="checkbox" checked={isPublic} onChange={(event) => setIsPublic(event.target.checked)} />
|
| 316 |
<span>Make project visible to authenticated users</span>
|
frontend/src/components/ProjectDetail.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import React, { useCallback, useEffect, useState } from 'react';
|
| 2 |
-
import { ArrowLeft, Bot, CheckCircle2, Download, FileText, ListTodo, PlayCircle, PlusCircle, RefreshCw } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
|
@@ -17,6 +17,7 @@ interface Project {
|
|
| 17 |
interface Agent {
|
| 18 |
id: string;
|
| 19 |
name: string;
|
|
|
|
| 20 |
model: string;
|
| 21 |
}
|
| 22 |
|
|
@@ -73,6 +74,7 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 73 |
const [title, setTitle] = useState('');
|
| 74 |
const [description, setDescription] = useState('');
|
| 75 |
const [agentId, setAgentId] = useState('');
|
|
|
|
| 76 |
const [saving, setSaving] = useState(false);
|
| 77 |
const [orchestrating, setOrchestrating] = useState(false);
|
| 78 |
const [approvingAll, setApprovingAll] = useState(false);
|
|
@@ -134,7 +136,7 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 134 |
const [{ data: projectData, error: projectError }, { data: taskData, error: taskError }, { data: agentData }] = await Promise.all([
|
| 135 |
supabase.from('projects').select('id,name,description,context,status').eq('id', projectId).single(),
|
| 136 |
supabase.from('tasks').select('id,title,description,status,priority,assigned_agent_id,output_data').eq('project_id', projectId).order('created_at', { ascending: false }),
|
| 137 |
-
supabase.from('agents').select('id,name,model').order('created_at', { ascending: false })
|
| 138 |
]);
|
| 139 |
|
| 140 |
if (projectError) setError(projectError.message);
|
|
@@ -149,33 +151,100 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 149 |
loadProject();
|
| 150 |
}, [loadProject]);
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
const createTask = async (event: React.FormEvent) => {
|
| 153 |
event.preventDefault();
|
| 154 |
setSaving(true);
|
| 155 |
setError(null);
|
|
|
|
| 156 |
|
| 157 |
-
const
|
| 158 |
-
project_id: projectId,
|
| 159 |
title,
|
| 160 |
description,
|
| 161 |
assigned_agent_id: agentId || null,
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
} else {
|
| 169 |
-
|
| 170 |
-
setDescription('');
|
| 171 |
-
setAgentId('');
|
| 172 |
await loadProject();
|
| 173 |
-
setMessage('Task added.');
|
| 174 |
}
|
| 175 |
|
| 176 |
setSaving(false);
|
| 177 |
};
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
const createDefaultAgents = async () => {
|
| 180 |
if (!user) {
|
| 181 |
setError('You must be signed in to create default agents.');
|
|
@@ -527,9 +596,16 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 527 |
</button>
|
| 528 |
</div>
|
| 529 |
|
| 530 |
-
<div className="settings-section-title">
|
| 531 |
-
<
|
| 532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
</div>
|
| 534 |
<form onSubmit={createTask} style={{ display: 'grid', gap: 'var(--space-md)' }}>
|
| 535 |
<label>
|
|
@@ -551,7 +627,7 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 551 |
</label>
|
| 552 |
<button className="btn btn-primary" type="submit" disabled={saving}>
|
| 553 |
<CheckCircle2 size={18} />
|
| 554 |
-
{saving ? 'Adding...' : 'Add Task'}
|
| 555 |
</button>
|
| 556 |
</form>
|
| 557 |
</section>
|
|
@@ -591,6 +667,45 @@ const ProjectDetail: React.FC<ProjectDetailProps> = ({ projectId, onBack }) => {
|
|
| 591 |
<div style={{ flex: 1 }}>
|
| 592 |
<strong>{task.title}</strong>
|
| 593 |
<p>{task.description || 'No description provided.'}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
</div>
|
| 595 |
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
| 596 |
<span className={`status-badge status-${task.status}`}>
|
|
|
|
| 1 |
import React, { useCallback, useEffect, useState } from 'react';
|
| 2 |
+
import { ArrowLeft, Bot, CheckCircle2, Download, FilePenLine, FileText, ListTodo, PlayCircle, PlusCircle, RefreshCw, Trash2, X } from 'lucide-react';
|
| 3 |
import { motion } from 'framer-motion';
|
| 4 |
import { supabase } from '../services/supabase';
|
| 5 |
import { useAuth } from '../context/useAuth';
|
|
|
|
| 17 |
interface Agent {
|
| 18 |
id: string;
|
| 19 |
name: string;
|
| 20 |
+
role?: string | null;
|
| 21 |
model: string;
|
| 22 |
}
|
| 23 |
|
|
|
|
| 74 |
const [title, setTitle] = useState('');
|
| 75 |
const [description, setDescription] = useState('');
|
| 76 |
const [agentId, setAgentId] = useState('');
|
| 77 |
+
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
| 78 |
const [saving, setSaving] = useState(false);
|
| 79 |
const [orchestrating, setOrchestrating] = useState(false);
|
| 80 |
const [approvingAll, setApprovingAll] = useState(false);
|
|
|
|
| 136 |
const [{ data: projectData, error: projectError }, { data: taskData, error: taskError }, { data: agentData }] = await Promise.all([
|
| 137 |
supabase.from('projects').select('id,name,description,context,status').eq('id', projectId).single(),
|
| 138 |
supabase.from('tasks').select('id,title,description,status,priority,assigned_agent_id,output_data').eq('project_id', projectId).order('created_at', { ascending: false }),
|
| 139 |
+
supabase.from('agents').select('id,name,role,model').order('created_at', { ascending: false })
|
| 140 |
]);
|
| 141 |
|
| 142 |
if (projectError) setError(projectError.message);
|
|
|
|
| 151 |
loadProject();
|
| 152 |
}, [loadProject]);
|
| 153 |
|
| 154 |
+
const resetTaskForm = () => {
|
| 155 |
+
setEditingTaskId(null);
|
| 156 |
+
setTitle('');
|
| 157 |
+
setDescription('');
|
| 158 |
+
setAgentId('');
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
const startEditingTask = (task: Task) => {
|
| 162 |
+
setEditingTaskId(task.id);
|
| 163 |
+
setTitle(task.title);
|
| 164 |
+
setDescription(task.description ?? '');
|
| 165 |
+
setAgentId(task.assigned_agent_id ?? '');
|
| 166 |
+
setError(null);
|
| 167 |
+
setMessage(null);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
const createTask = async (event: React.FormEvent) => {
|
| 171 |
event.preventDefault();
|
| 172 |
setSaving(true);
|
| 173 |
setError(null);
|
| 174 |
+
setMessage(null);
|
| 175 |
|
| 176 |
+
const payload = {
|
|
|
|
| 177 |
title,
|
| 178 |
description,
|
| 179 |
assigned_agent_id: agentId || null,
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
const response = editingTaskId
|
| 183 |
+
? await supabase.from('tasks').update(payload).eq('id', editingTaskId)
|
| 184 |
+
: await supabase.from('tasks').insert({
|
| 185 |
+
project_id: projectId,
|
| 186 |
+
...payload,
|
| 187 |
+
status: 'todo',
|
| 188 |
+
priority: 0
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
+
if (response.error) {
|
| 192 |
+
setError(response.error.message);
|
| 193 |
} else {
|
| 194 |
+
resetTaskForm();
|
|
|
|
|
|
|
| 195 |
await loadProject();
|
| 196 |
+
setMessage(editingTaskId ? 'Task updated.' : 'Task added.');
|
| 197 |
}
|
| 198 |
|
| 199 |
setSaving(false);
|
| 200 |
};
|
| 201 |
|
| 202 |
+
const handleDeleteTask = async (task: Task) => {
|
| 203 |
+
const confirmed = window.confirm(`Delete task "${task.title}"? This cannot be undone.`);
|
| 204 |
+
if (!confirmed) return;
|
| 205 |
+
|
| 206 |
+
setError(null);
|
| 207 |
+
setMessage(null);
|
| 208 |
+
|
| 209 |
+
const { error: deleteError } = await supabase.from('tasks').delete().eq('id', task.id);
|
| 210 |
+
if (deleteError) {
|
| 211 |
+
setError(deleteError.message);
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
if (editingTaskId === task.id) {
|
| 216 |
+
resetTaskForm();
|
| 217 |
+
}
|
| 218 |
+
if (selectedTask?.id === task.id) {
|
| 219 |
+
setSelectedTask(null);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
await loadProject();
|
| 223 |
+
setMessage('Task deleted.');
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
const assignTaskAgent = async (taskId: string, assignedAgentId: string) => {
|
| 227 |
+
setError(null);
|
| 228 |
+
setMessage(null);
|
| 229 |
+
|
| 230 |
+
const { error: updateError } = await supabase
|
| 231 |
+
.from('tasks')
|
| 232 |
+
.update({ assigned_agent_id: assignedAgentId || null })
|
| 233 |
+
.eq('id', taskId);
|
| 234 |
+
|
| 235 |
+
if (updateError) {
|
| 236 |
+
setError(updateError.message);
|
| 237 |
+
return;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if (editingTaskId === taskId) {
|
| 241 |
+
setAgentId(assignedAgentId);
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
await loadProject();
|
| 245 |
+
setMessage('Task assignment updated.');
|
| 246 |
+
};
|
| 247 |
+
|
| 248 |
const createDefaultAgents = async () => {
|
| 249 |
if (!user) {
|
| 250 |
setError('You must be signed in to create default agents.');
|
|
|
|
| 596 |
</button>
|
| 597 |
</div>
|
| 598 |
|
| 599 |
+
<div className="settings-section-title" style={{ justifyContent: 'space-between' }}>
|
| 600 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
| 601 |
+
<PlusCircle size={22} color="var(--accent)" />
|
| 602 |
+
<h3>{editingTaskId ? 'Edit Task' : 'Add Task'}</h3>
|
| 603 |
+
</div>
|
| 604 |
+
{editingTaskId && (
|
| 605 |
+
<button className="btn btn-icon" type="button" onClick={resetTaskForm} title="Cancel edit">
|
| 606 |
+
<X size={18} />
|
| 607 |
+
</button>
|
| 608 |
+
)}
|
| 609 |
</div>
|
| 610 |
<form onSubmit={createTask} style={{ display: 'grid', gap: 'var(--space-md)' }}>
|
| 611 |
<label>
|
|
|
|
| 627 |
</label>
|
| 628 |
<button className="btn btn-primary" type="submit" disabled={saving}>
|
| 629 |
<CheckCircle2 size={18} />
|
| 630 |
+
{saving ? (editingTaskId ? 'Saving...' : 'Adding...') : (editingTaskId ? 'Save Task' : 'Add Task')}
|
| 631 |
</button>
|
| 632 |
</form>
|
| 633 |
</section>
|
|
|
|
| 667 |
<div style={{ flex: 1 }}>
|
| 668 |
<strong>{task.title}</strong>
|
| 669 |
<p>{task.description || 'No description provided.'}</p>
|
| 670 |
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-sm)', marginTop: 'var(--space-sm)', alignItems: 'center' }}>
|
| 671 |
+
<select
|
| 672 |
+
value={task.assigned_agent_id ?? ''}
|
| 673 |
+
onClick={(e) => e.stopPropagation()}
|
| 674 |
+
onChange={(e) => {
|
| 675 |
+
e.stopPropagation();
|
| 676 |
+
assignTaskAgent(task.id, e.target.value);
|
| 677 |
+
}}
|
| 678 |
+
style={{ maxWidth: '320px' }}
|
| 679 |
+
>
|
| 680 |
+
<option value="">Unassigned</option>
|
| 681 |
+
{agents.map((agent) => (
|
| 682 |
+
<option key={agent.id} value={agent.id}>{agent.name} ({agent.model})</option>
|
| 683 |
+
))}
|
| 684 |
+
</select>
|
| 685 |
+
<button
|
| 686 |
+
className="btn btn-glass btn-sm"
|
| 687 |
+
type="button"
|
| 688 |
+
onClick={(e) => {
|
| 689 |
+
e.stopPropagation();
|
| 690 |
+
startEditingTask(task);
|
| 691 |
+
}}
|
| 692 |
+
>
|
| 693 |
+
<FilePenLine size={14} />
|
| 694 |
+
Edit
|
| 695 |
+
</button>
|
| 696 |
+
<button
|
| 697 |
+
className="btn btn-glass btn-sm"
|
| 698 |
+
type="button"
|
| 699 |
+
onClick={(e) => {
|
| 700 |
+
e.stopPropagation();
|
| 701 |
+
handleDeleteTask(task);
|
| 702 |
+
}}
|
| 703 |
+
style={{ color: 'var(--danger)', borderColor: 'rgba(231, 76, 60, 0.25)' }}
|
| 704 |
+
>
|
| 705 |
+
<Trash2 size={14} />
|
| 706 |
+
Delete
|
| 707 |
+
</button>
|
| 708 |
+
</div>
|
| 709 |
</div>
|
| 710 |
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
| 711 |
<span className={`status-badge status-${task.status}`}>
|