cesjavi commited on
Commit
9cc23a0
·
1 Parent(s): c9e2ce8

Add Tavily web search and project editing improvements

Browse files
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
- A tool that allows agents to browse the web and extract content.
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(f"BrowserTool: Navigating to {url}")
15
- async with async_playwright() as p:
16
- browser = await p.chromium.launch(headless=True)
17
  page = await browser.new_page()
18
  try:
19
  await page.goto(url, wait_until="networkidle", timeout=30000)
20
- # Simple extraction: get all text from body
21
  content = await page.inner_text("body")
22
- # Truncate if too long for LLM context
23
- return content[:10000]
24
- except Exception as e:
25
- logger.error(f"BrowserTool error: {str(e)}")
26
- return f"Error accessing {url}: {str(e)}"
27
  finally:
28
  await browser.close()
29
 
30
- async def google_search(self, query: str) -> str:
31
  """
32
- Performs a Google search and returns results.
33
  """
34
- search_url = f"https://www.google.com/search?q={query}"
35
- return await self.search_and_extract(search_url)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.google_search,
20
- "description": "Searches the web for a given query and returns the results."
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 createAgent = async (event: React.FormEvent) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  event.preventDefault();
60
  if (!user) {
61
- setError('You must be signed in to create an agent.');
62
  return;
63
  }
64
 
@@ -66,22 +88,24 @@ const AgentsView: React.FC = () => {
66
  setError(null);
67
  setMessage(null);
68
 
69
- const { error: insertError } = await supabase.from('agents').insert({
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 (insertError) {
79
- setError(insertError.message);
80
  } else {
81
- setName('');
82
- setRole('');
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
- <PlusCircle size={22} color="var(--accent)" />
114
- <h3>Create Agent</h3>
 
 
 
 
 
 
 
115
  </div>
116
- <form onSubmit={createAgent} style={{ display: 'grid', gap: 'var(--space-md)' }}>
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
- <div key={agent.id} className="task-row">
 
 
 
 
 
 
 
 
 
 
 
 
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
- </div>
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 { error: insertError } = await supabase.from('tasks').insert({
158
- project_id: projectId,
159
  title,
160
  description,
161
  assigned_agent_id: agentId || null,
162
- status: 'todo',
163
- priority: 0
164
- });
165
-
166
- if (insertError) {
167
- setError(insertError.message);
 
 
 
 
 
 
 
168
  } else {
169
- setTitle('');
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
- <PlusCircle size={22} color="var(--accent)" />
532
- <h3>Add Task</h3>
 
 
 
 
 
 
 
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}`}>