Merge branch 'arc53:main' into main

This commit is contained in:
Manish Madan
2025-06-19 02:20:08 +05:30
committed by GitHub
18 changed files with 647 additions and 150 deletions

View File

@@ -6,16 +6,25 @@ import secrets
import shutil
import uuid
from functools import wraps
from typing import Optional, Tuple
from bson.binary import Binary, UuidRepresentation
from bson.dbref import DBRef
from bson.objectid import ObjectId
from flask import Blueprint, current_app, jsonify, make_response, redirect, request
from flask import (
Blueprint,
current_app,
jsonify,
make_response,
redirect,
request,
Response,
)
from flask_restx import fields, inputs, Namespace, Resource
from pymongo import ReturnDocument
from werkzeug.utils import secure_filename
from application.agents.tools.tool_manager import ToolManager
from pymongo import ReturnDocument
from application.api.user.tasks import (
ingest,
@@ -28,7 +37,12 @@ from application.core.settings import settings
from application.extensions import api
from application.storage.storage_creator import StorageCreator
from application.tts.google_tts import GoogleTTS
from application.utils import check_required_fields, safe_filename, validate_function_name
from application.utils import (
check_required_fields,
generate_image_url,
safe_filename,
validate_function_name,
)
from application.vectorstore.vector_creator import VectorCreator
storage = StorageCreator.get_storage()
@@ -143,6 +157,29 @@ def get_vector_store(source_id):
return store
def handle_image_upload(
request, existing_url: str, user: str, storage, base_path: str = "attachments/"
) -> Tuple[str, Optional[Response]]:
image_url = existing_url
if "image" in request.files:
file = request.files["image"]
if file.filename != "":
filename = secure_filename(file.filename)
upload_path = f"{settings.UPLOAD_FOLDER.rstrip('/')}/{user}/{base_path.rstrip('/')}/{uuid.uuid4()}_{filename}"
try:
storage.save_file(file, upload_path)
image_url = upload_path
except Exception as e:
current_app.logger.error(f"Error uploading image: {e}")
return None, make_response(
jsonify({"success": False, "message": "Image upload failed"}),
400,
)
return image_url, None
@user_ns.route("/api/delete_conversation")
class DeleteConversation(Resource):
@api.doc(
@@ -253,7 +290,7 @@ class GetSingleConversation(Resource):
)
if not conversation:
return make_response(jsonify({"status": "not found"}), 404)
# Process queries to include attachment names
queries = conversation["queries"]
for query in queries:
@@ -265,13 +302,18 @@ class GetSingleConversation(Resource):
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append({
"id": str(attachment["_id"]),
"fileName": attachment.get("filename", "Unknown file")
})
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}", exc_info=True
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
except Exception as err:
@@ -499,7 +541,7 @@ class UploadFile(Resource):
)
user = decoded_token.get("sub")
job_name = request.form["name"]
# Create safe versions for filesystem operations
safe_user = safe_filename(user)
dir_name = safe_filename(job_name)
@@ -1069,27 +1111,28 @@ class UpdatePrompt(Resource):
@user_ns.route("/api/get_agent")
class GetAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Get a single agent by ID")
@api.doc(params={"id": "Agent ID"}, description="Get agent by ID")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
if not (agent_id := request.args.get("id")):
return {"success": False, "message": "ID required"}, 400
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": user}
{"_id": ObjectId(agent_id), "user": decoded_token["sub"]}
)
if not agent:
return make_response(jsonify({"status": "Not found"}), 404)
return {"status": "Not found"}, 404
data = {
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef)
@@ -1116,19 +1159,20 @@ class GetAgent(Resource):
"shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""),
}
except Exception as err:
current_app.logger.error(f"Error retrieving agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(data), 200)
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
return {"success": False}, 400
@user_ns.route("/api/get_agents")
class GetAgents(Resource):
@api.doc(description="Retrieve agents for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
try:
user_doc = ensure_user_doc(user)
@@ -1140,6 +1184,9 @@ class GetAgents(Resource):
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef)
@@ -1184,8 +1231,8 @@ class CreateAgent(Resource):
"description": fields.String(
required=True, description="Description of the agent"
),
"image": fields.String(
required=False, description="Image URL or identifier"
"image": fields.Raw(
required=False, description="Image file upload", type="file"
),
"source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"),
@@ -1204,12 +1251,20 @@ class CreateAgent(Resource):
@api.expect(create_agent_model)
@api.doc(description="Create a new agent")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
data = request.get_json()
if request.content_type == "application/json":
data = request.get_json()
else:
print(request.form)
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
print(f"Received data: {data}")
if data.get("status") not in ["draft", "published"]:
return make_response(
jsonify({"success": False, "message": "Invalid status"}), 400
@@ -1230,13 +1285,20 @@ class CreateAgent(Resource):
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
image_url, error = handle_image_upload(request, "", user, storage)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
try:
key = str(uuid.uuid4())
new_agent = {
"user": user,
"name": data.get("name"),
"description": data.get("description", ""),
"image": data.get("image", ""),
"image": image_url,
"source": (
DBRef("sources", ObjectId(data.get("source")))
if ObjectId.is_valid(data.get("source"))
@@ -1294,11 +1356,18 @@ class UpdateAgent(Resource):
@api.expect(update_agent_model)
@api.doc(description="Update an existing agent")
def put(self, agent_id):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
data = request.get_json()
if request.content_type == "application/json":
data = request.get_json()
else:
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
if not ObjectId.is_valid(agent_id):
return make_response(
@@ -1323,6 +1392,15 @@ class UpdateAgent(Resource):
),
404,
)
image_url, error = handle_image_upload(
request, existing_agent.get("image", ""), user, storage
)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
update_fields = {}
allowed_fields = [
"name",
@@ -1394,6 +1472,8 @@ class UpdateAgent(Resource):
)
else:
update_fields[field] = data[field]
if image_url:
update_fields["image"] = image_url
if not update_fields:
return make_response(
jsonify({"success": False, "message": "No update data provided"}), 400
@@ -1542,6 +1622,9 @@ class PinnedAgents(Resource):
"id": str(agent["_id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent
@@ -1702,6 +1785,11 @@ class SharedAgent(Resource):
"id": agent_id,
"user": shared_agent.get("user", ""),
"name": shared_agent.get("name", ""),
"image": (
generate_image_url(shared_agent["image"])
if shared_agent.get("image")
else ""
),
"description": shared_agent.get("description", ""),
"tools": shared_agent.get("tools", []),
"tool_details": resolve_tool_details(shared_agent.get("tools", [])),
@@ -1777,6 +1865,9 @@ class SharedAgents(Resource):
"id": str(agent["_id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""),
@@ -2241,7 +2332,7 @@ class GetPubliclySharedConversations(Resource):
conversation_queries = conversation["queries"][
: (shared["first_n_queries"])
]
for query in conversation_queries:
if "attachments" in query and query["attachments"]:
attachment_details = []
@@ -2251,13 +2342,18 @@ class GetPubliclySharedConversations(Resource):
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append({
"id": str(attachment["_id"]),
"fileName": attachment.get("filename", "Unknown file")
})
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}", exc_info=True
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
else:
@@ -3493,3 +3589,30 @@ class StoreAttachment(Resource):
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": str(err)}), 400)
@user_ns.route("/api/images/<path:image_path>")
class ServeImage(Resource):
@api.doc(description="Serve an image from storage")
def get(self, image_path):
try:
file_obj = storage.get_file(image_path)
extension = image_path.split(".")[-1].lower()
content_type = f"image/{extension}"
if extension == "jpg":
content_type = "image/jpeg"
response = make_response(file_obj.read())
response.headers.set("Content-Type", content_type)
response.headers.set("Cache-Control", "max-age=86400")
return response
except FileNotFoundError:
return make_response(
jsonify({"success": False, "message": "Image not found"}), 404
)
except Exception as e:
current_app.logger.error(f"Error serving image: {e}")
return make_response(
jsonify({"success": False, "message": "Error retrieving image"}), 500
)

View File

@@ -103,6 +103,7 @@ class Settings(BaseSettings):
FLASK_DEBUG_MODE: bool = False
STORAGE_TYPE: str = "local" # local or s3
URL_STRATEGY: str = "backend" # backend or s3
JWT_SECRET_KEY: str = ""

View File

@@ -1,13 +1,14 @@
"""S3 storage implementation."""
import io
from typing import BinaryIO, List, Callable
import os
from typing import BinaryIO, Callable, List
import boto3
from botocore.exceptions import ClientError
from application.core.settings import settings
from application.storage.base import BaseStorage
from application.core.settings import settings
from botocore.exceptions import ClientError
class S3Storage(BaseStorage):
@@ -20,18 +21,21 @@ class S3Storage(BaseStorage):
Args:
bucket_name: S3 bucket name (optional, defaults to settings)
"""
self.bucket_name = bucket_name or getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket")
self.bucket_name = bucket_name or getattr(
settings, "S3_BUCKET_NAME", "docsgpt-test-bucket"
)
# Get credentials from settings
aws_access_key_id = getattr(settings, "SAGEMAKER_ACCESS_KEY", None)
aws_secret_access_key = getattr(settings, "SAGEMAKER_SECRET_KEY", None)
region_name = getattr(settings, "SAGEMAKER_REGION", None)
self.s3 = boto3.client(
's3',
"s3",
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
region_name=region_name
region_name=region_name,
)
def save_file(self, file_data: BinaryIO, path: str) -> dict:
@@ -41,17 +45,16 @@ class S3Storage(BaseStorage):
region = getattr(settings, "SAGEMAKER_REGION", None)
return {
'storage_type': 's3',
'bucket_name': self.bucket_name,
'uri': f's3://{self.bucket_name}/{path}',
'region': region
"storage_type": "s3",
"bucket_name": self.bucket_name,
"uri": f"s3://{self.bucket_name}/{path}",
"region": region,
}
def get_file(self, path: str) -> BinaryIO:
"""Get a file from S3 storage."""
if not self.file_exists(path):
raise FileNotFoundError(f"File not found: {path}")
file_obj = io.BytesIO()
self.s3.download_fileobj(self.bucket_name, path, file_obj)
file_obj.seek(0)
@@ -76,18 +79,17 @@ class S3Storage(BaseStorage):
def list_files(self, directory: str) -> List[str]:
"""List all files in a directory in S3 storage."""
# Ensure directory ends with a slash if it's not empty
if directory and not directory.endswith('/'):
directory += '/'
if directory and not directory.endswith("/"):
directory += "/"
result = []
paginator = self.s3.get_paginator('list_objects_v2')
paginator = self.s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory)
for page in pages:
if 'Contents' in page:
for obj in page['Contents']:
result.append(obj['Key'])
if "Contents" in page:
for obj in page["Contents"]:
result.append(obj["Key"])
return result
def process_file(self, path: str, processor_func: Callable, **kwargs):
@@ -98,22 +100,24 @@ class S3Storage(BaseStorage):
path: Path to the file
processor_func: Function that processes the file
**kwargs: Additional arguments to pass to the processor function
Returns:
The result of the processor function
"""
import tempfile
import logging
import tempfile
if not self.file_exists(path):
raise FileNotFoundError(f"File not found in S3: {path}")
with tempfile.NamedTemporaryFile(suffix=os.path.splitext(path)[1], delete=True) as temp_file:
with tempfile.NamedTemporaryFile(
suffix=os.path.splitext(path)[1], delete=True
) as temp_file:
try:
# Download the file from S3 to the temporary file
self.s3.download_fileobj(self.bucket_name, path, temp_file)
temp_file.flush()
return processor_func(local_path=temp_file.name, **kwargs)
except Exception as e:
logging.error(f"Error processing S3 file {path}: {e}", exc_info=True)

View File

@@ -6,6 +6,7 @@ import uuid
import tiktoken
from flask import jsonify, make_response
from werkzeug.utils import secure_filename
from application.core.settings import settings
_encoding = None
@@ -22,24 +23,24 @@ def safe_filename(filename):
"""
Creates a safe filename that preserves the original extension.
Uses secure_filename, but ensures a proper filename is returned even with non-Latin characters.
Args:
filename (str): The original filename
Returns:
str: A safe filename that can be used for storage
"""
if not filename:
return str(uuid.uuid4())
_, extension = os.path.splitext(filename)
safe_name = secure_filename(filename)
# If secure_filename returns just the extension or an empty string
if not safe_name or safe_name == extension.lstrip('.'):
if not safe_name or safe_name == extension.lstrip("."):
return f"{str(uuid.uuid4())}{extension}"
return safe_name
@@ -137,3 +138,14 @@ def validate_function_name(function_name):
if not re.match(r"^[a-zA-Z0-9_-]+$", function_name):
return False
return True
def generate_image_url(image_path):
strategy = getattr(settings, "URL_STRATEGY", "backend")
if strategy == "s3":
bucket_name = getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket")
region_name = getattr(settings, "SAGEMAKER_REGION", "eu-central-1")
return f"https://{bucket_name}.s3.{region_name}.amazonaws.com/{image_path}"
else:
base_url = getattr(settings, "API_URL", "http://localhost:7091")
return f"{base_url}/api/images/{image_path}"

View File

@@ -28,7 +28,8 @@
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/mermaid": "^9.1.0",
@@ -10469,6 +10470,16 @@
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"dev": true
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz",
"integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",

View File

@@ -39,7 +39,8 @@
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0"
"remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/mermaid": "^9.1.0",

View File

@@ -420,9 +420,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="flex items-center gap-2">
<div className="flex w-6 justify-center">
<img
src={agent.image ?? Robot}
src={
agent.image && agent.image.trim() !== ''
? agent.image
: Robot
}
alt="agent-logo"
className="h-6 w-6"
className="h-6 w-6 rounded-full object-contain"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">

View File

@@ -83,9 +83,9 @@ export default function AgentCard({
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image ?? Robot}
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full"
className="h-7 w-7 rounded-full object-contain"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
@@ -6,6 +6,7 @@ import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import SourceIcon from '../assets/source.svg';
import Dropdown from '../components/Dropdown';
import { FileUpload } from '../components/FileUpload';
import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup';
import AgentDetailsModal from '../modals/AgentDetailsModal';
import ConfirmationModal from '../modals/ConfirmationModal';
@@ -48,6 +49,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
agent_type: '',
status: '',
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[]
>([]);
@@ -106,6 +108,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
);
};
const handleUpload = useCallback((files: File[]) => {
if (files && files.length > 0) {
const file = files[0];
setImageFile(file);
}
}, []);
const handleCancel = () => {
if (selectedAgent) dispatch(setSelectedAgent(null));
navigate('/agents');
@@ -118,42 +127,80 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
};
const handleSaveDraft = async () => {
const response =
effectiveMode === 'new'
? await userService.createAgent({ ...agent, status: 'draft' }, token)
: await userService.updateAgent(
agent.id || '',
{ ...agent, status: 'draft' },
token,
);
if (!response.ok) throw new Error('Failed to create agent draft');
const data = await response.json();
if (effectiveMode === 'new') {
setEffectiveMode('draft');
setAgent((prev) => ({ ...prev, id: data.id }));
const formData = new FormData();
formData.append('name', agent.name);
formData.append('description', agent.description);
formData.append('source', agent.source);
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
formData.append('prompt_id', agent.prompt_id);
formData.append('agent_type', agent.agent_type);
formData.append('status', 'draft');
if (imageFile) formData.append('image', imageFile);
if (agent.tools && agent.tools.length > 0)
formData.append('tools', JSON.stringify(agent.tools));
else formData.append('tools', '[]');
try {
const response =
effectiveMode === 'new'
? await userService.createAgent(formData, token)
: await userService.updateAgent(agent.id || '', formData, token);
if (!response.ok) throw new Error('Failed to create agent draft');
const data = await response.json();
if (effectiveMode === 'new') {
setEffectiveMode('draft');
setAgent((prev) => ({
...prev,
id: data.id,
image: data.image || prev.image,
}));
}
} catch (error) {
console.error('Error saving draft:', error);
throw new Error('Failed to save draft');
}
};
const handlePublish = async () => {
const response =
effectiveMode === 'new'
? await userService.createAgent(
{ ...agent, status: 'published' },
token,
)
: await userService.updateAgent(
agent.id || '',
{ ...agent, status: 'published' },
token,
);
if (!response.ok) throw new Error('Failed to publish agent');
const data = await response.json();
if (data.id) setAgent((prev) => ({ ...prev, id: data.id }));
if (data.key) setAgent((prev) => ({ ...prev, key: data.key }));
if (effectiveMode === 'new' || effectiveMode === 'draft') {
setEffectiveMode('edit');
setAgent((prev) => ({ ...prev, status: 'published' }));
setAgentDetails('ACTIVE');
const formData = new FormData();
formData.append('name', agent.name);
formData.append('description', agent.description);
formData.append('source', agent.source);
formData.append('chunks', agent.chunks);
formData.append('retriever', agent.retriever);
formData.append('prompt_id', agent.prompt_id);
formData.append('agent_type', agent.agent_type);
formData.append('status', 'published');
if (imageFile) formData.append('image', imageFile);
if (agent.tools && agent.tools.length > 0)
formData.append('tools', JSON.stringify(agent.tools));
else formData.append('tools', '[]');
try {
const response =
effectiveMode === 'new'
? await userService.createAgent(formData, token)
: await userService.updateAgent(agent.id || '', formData, token);
if (!response.ok) throw new Error('Failed to publish agent');
const data = await response.json();
if (data.id) setAgent((prev) => ({ ...prev, id: data.id }));
if (data.key) setAgent((prev) => ({ ...prev, key: data.key }));
if (effectiveMode === 'new' || effectiveMode === 'draft') {
setEffectiveMode('edit');
setAgent((prev) => ({
...prev,
status: 'published',
image: data.image || prev.image,
}));
setAgentDetails('ACTIVE');
}
} catch (error) {
console.error('Error publishing agent:', error);
throw new Error('Failed to publish agent');
}
};
@@ -325,6 +372,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, description: e.target.value })
}
/>
<div className="mt-3">
<FileUpload
showPreview
className="dark:bg-[#222327]"
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{ text: 'Click to upload', colorClass: 'text-[#7D54D1]' },
{
text: ' or drag and drop',
colorClass: 'text-[#525252]',
},
]}
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Source</h2>

View File

@@ -153,9 +153,13 @@ export default function SharedAgent() {
<div className="relative h-full w-full">
<div className="absolute left-4 top-5 hidden items-center gap-3 sm:flex">
<img
src={sharedAgent.image ?? Robot}
src={
sharedAgent.image && sharedAgent.image.trim() !== ''
? sharedAgent.image
: Robot
}
alt="agent-logo"
className="h-6 w-6"
className="h-6 w-6 rounded-full object-contain"
/>
<h2 className="text-lg font-semibold text-[#212121] dark:text-[#E0E0E0]">
{sharedAgent.name}

View File

@@ -6,7 +6,10 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) {
<div className="flex w-full max-w-[720px] flex-col rounded-3xl border border-dark-gray p-6 shadow-sm dark:border-grey sm:w-fit sm:min-w-[480px]">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<img src={Robot} className="h-full w-full object-contain" />
<img
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
className="h-full w-full rounded-full object-contain"
/>
</div>
<div className="flex max-h-[92px] w-[80%] flex-col gap-px">
<h2 className="text-base font-semibold text-[#212121] dark:text-[#E0E0E0] sm:text-lg">

View File

@@ -324,17 +324,21 @@ function AgentCard({
iconWidth: 14,
iconHeight: 14,
},
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary',
iconWidth: 18,
iconHeight: 18,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
{
icon: Trash,
label: 'Delete',
@@ -426,16 +430,16 @@ function AgentCard({
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="top-right"
position="bottom-right"
offset={{ x: 0, y: 0 }}
/>
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image ?? Robot}
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full"
className="h-7 w-7 rounded-full object-contain"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>

View File

@@ -1,16 +1,21 @@
export const baseURL =
import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
const defaultHeaders = {
'Content-Type': 'application/json',
};
const getHeaders = (token: string | null, customHeaders = {}): HeadersInit => {
return {
...defaultHeaders,
const getHeaders = (
token: string | null,
customHeaders = {},
isFormData = false,
): HeadersInit => {
const headers: HeadersInit = {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...customHeaders,
};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
};
const apiClient = {
@@ -44,6 +49,21 @@ const apiClient = {
return response;
}),
postFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'POST',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
put: (
url: string,
data: any,
@@ -60,6 +80,21 @@ const apiClient = {
return response;
}),
putFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'PUT',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
delete: (
url: string,
token: string | null,

View File

@@ -22,13 +22,13 @@ const userService = {
getAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENTS, token),
createAgent: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.CREATE_AGENT, data, token),
apiClient.postFormData(endpoints.USER.CREATE_AGENT, data, token),
updateAgent: (
agent_id: string,
data: any,
token: string | null,
): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_AGENT(agent_id), data, token),
apiClient.putFormData(endpoints.USER.UPDATE_AGENT(agent_id), data, token),
deleteAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.DELETE_AGENT(id), token),
getPinnedAgents: (token: string | null): Promise<any> =>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -0,0 +1,3 @@
<svg width="40" height="39" viewBox="0 0 40 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9477 3.02295H33.8827C35.898 3.02295 37.5388 4.6819 37.5388 6.71923V22.9819C37.5388 25.0193 35.898 26.6782 33.8827 26.6782H11.9477C9.9328 26.6782 8.29192 25.0193 8.29192 22.9819V6.71923C8.29192 4.6819 9.9328 3.02295 11.9477 3.02295ZM33.8827 5.97992H11.9477C11.5442 5.97992 11.2167 6.31098 11.2167 6.71916V20.6741L15.2527 16.595C16.2515 15.5839 17.8791 15.5839 18.8792 16.595L20.6486 18.3795L26.0799 11.7888C26.5653 11.2003 27.276 10.8603 28.0335 10.856C28.7953 10.8735 29.5046 11.1841 29.9946 11.765L34.614 17.2147V6.71923C34.614 6.31104 34.2866 5.97992 33.8827 5.97992ZM6.40446 25.1242C7.16068 27.3803 9.243 28.8957 11.584 28.8957H32.8128L31.4954 33.1312C31.1223 34.5715 29.7916 35.5487 28.3352 35.5487C28.051 35.5485 27.768 35.5117 27.4929 35.4393L4.88059 29.3169C3.13614 28.8305 2.09642 27.0048 2.55267 25.2438L6.10025 13.2714V23.3516C6.10025 23.8543 6.17497 24.3567 6.35333 24.9542L6.40446 25.1242ZM18.53 10.4151C18.53 12.0459 17.2186 13.3721 15.6055 13.3721C13.9926 13.3721 12.6808 12.0458 12.6808 10.4151C12.6808 8.78445 13.9925 7.45815 15.6055 7.45815C17.2186 7.45815 18.53 8.78438 18.53 10.4151Z" fill="#A3A3A3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,19 +1,19 @@
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.394 1.001H7.982C7.32776 1.00074 6.67989 1.12939 6.07539 1.3796C5.47089 1.62982 4.92162 1.99669 4.45896 2.45926C3.9963 2.92182 3.62932 3.47102 3.37898 4.07547C3.12865 4.67992 2.99987 5.32776 3 5.982V11.394C2.99974 12.0483 3.12842 12.6963 3.3787 13.3008C3.62897 13.9054 3.99593 14.4547 4.45861 14.9174C4.92128 15.3801 5.4706 15.747 6.07516 15.9973C6.67972 16.2476 7.32768 16.3763 7.982 16.376H13.394C14.0483 16.3763 14.6963 16.2476 15.3008 15.9973C15.9054 15.747 16.4547 15.3801 16.9174 14.9174C17.3801 14.4547 17.747 13.9054 17.9973 13.3008C18.2476 12.6963 18.3763 12.0483 18.376 11.394V5.982C18.3763 5.32768 18.2476 4.67972 17.9973 4.07516C17.747 3.4706 17.3801 2.92128 16.9174 2.45861C16.4547 1.99593 15.9054 1.62897 15.3008 1.3787C14.6963 1.12842 14.0483 0.999738 13.394 1V1.001Z" stroke="url(#paint0_linear_8958_15228)" stroke-width="1.5"/>
<path d="M18.606 12.5881H20.225C20.4968 12.5881 20.7576 12.4801 20.9498 12.2879C21.142 12.0956 21.25 11.8349 21.25 11.5631V6.43809C21.25 6.16624 21.142 5.90553 20.9498 5.7133C20.7576 5.52108 20.4968 5.41309 20.225 5.41309H18.605M3.395 12.5881H1.775C1.6404 12.5881 1.50711 12.5616 1.38275 12.5101C1.25839 12.4586 1.1454 12.3831 1.05022 12.2879C0.955035 12.1927 0.879535 12.0797 0.828023 11.9553C0.776512 11.831 0.75 11.6977 0.75 11.5631V6.43809C0.75 6.16624 0.857991 5.90553 1.05022 5.7133C1.24244 5.52108 1.50315 5.41309 1.775 5.41309H3.395" stroke="url(#paint1_linear_8958_15228)" stroke-width="1.5"/>
<path d="M1.76562 5.41323V1.31323M20.2256 5.41323L20.2156 1.31323M7.91562 5.76323V8.46123M14.0656 5.76323V8.46123M8.94062 12.5882H13.0406" stroke="url(#paint2_linear_8958_15228)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.394 4.001H8.982C8.32776 4.00074 7.67989 4.12939 7.07539 4.3796C6.47089 4.62982 5.92162 4.99669 5.45896 5.45926C4.9963 5.92182 4.62932 6.47102 4.37898 7.07547C4.12865 7.67992 3.99987 8.32776 4 8.982V14.394C3.99974 15.0483 4.12842 15.6963 4.3787 16.3008C4.62897 16.9054 4.99593 17.4547 5.45861 17.9174C5.92128 18.3801 6.4706 18.747 7.07516 18.9973C7.67972 19.2476 8.32768 19.3763 8.982 19.376H14.394C15.0483 19.3763 15.6963 19.2476 16.3008 18.9973C16.9054 18.747 17.4547 18.3801 17.9174 17.9174C18.3801 17.4547 18.747 16.9054 18.9973 16.3008C19.2476 15.6963 19.3763 15.0483 19.376 14.394V8.982C19.3763 8.32768 19.2476 7.67972 18.9973 7.07516C18.747 6.4706 18.3801 5.92128 17.9174 5.45861C17.4547 4.99593 16.9054 4.62897 16.3008 4.3787C15.6963 4.12842 15.0483 3.99974 14.394 4V4.001Z" stroke="url(#paint0_linear_9044_3689)" stroke-width="1.5"/>
<path d="M19.606 15.5881H21.225C21.4968 15.5881 21.7576 15.4801 21.9498 15.2879C22.142 15.0956 22.25 14.8349 22.25 14.5631V9.43809C22.25 9.16624 22.142 8.90553 21.9498 8.7133C21.7576 8.52108 21.4968 8.41309 21.225 8.41309H19.605M4.395 15.5881H2.775C2.6404 15.5881 2.50711 15.5616 2.38275 15.5101C2.25839 15.4586 2.1454 15.3831 2.05022 15.2879C1.95504 15.1927 1.87953 15.0797 1.82802 14.9553C1.77651 14.831 1.75 14.6977 1.75 14.5631V9.43809C1.75 9.16624 1.85799 8.90553 2.05022 8.7133C2.24244 8.52108 2.50315 8.41309 2.775 8.41309H4.395" stroke="url(#paint1_linear_9044_3689)" stroke-width="1.5"/>
<path d="M2.76562 8.41323V4.31323M21.2256 8.41323L21.2156 4.31323M8.91562 8.76323V11.4612M15.0656 8.76323V11.4612M9.94062 15.5882H14.0406" stroke="url(#paint2_linear_9044_3689)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="paint0_linear_8958_15228" x1="10.688" y1="1" x2="10.688" y2="16.376" gradientUnits="userSpaceOnUse">
<linearGradient id="paint0_linear_9044_3689" x1="11.688" y1="4" x2="11.688" y2="19.376" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
<linearGradient id="paint1_linear_8958_15228" x1="11" y1="5.41309" x2="11" y2="12.5881" gradientUnits="userSpaceOnUse">
<linearGradient id="paint1_linear_9044_3689" x1="12" y1="8.41309" x2="12" y2="15.5881" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
<linearGradient id="paint2_linear_8958_15228" x1="10.9956" y1="1.31323" x2="10.9956" y2="12.5882" gradientUnits="userSpaceOnUse">
<linearGradient id="paint2_linear_9044_3689" x1="11.9956" y1="4.31323" x2="11.9956" y2="15.5882" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,229 @@
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { twMerge } from 'tailwind-merge';
import Cross from '../assets/cross.svg';
import ImagesIcon from '../assets/images.svg';
interface FileUploadProps {
onUpload: (files: File[]) => void;
onRemove?: (file: File) => void;
multiple?: boolean;
maxFiles?: number;
maxSize?: number; // in bytes
accept?: Record<string, string[]>; // e.g. { 'image/*': ['.png', '.jpg'] }
showPreview?: boolean;
previewSize?: number;
children?: React.ReactNode;
className?: string;
activeClassName?: string;
acceptClassName?: string;
rejectClassName?: string;
uploadText?: string | { text: string; colorClass?: string }[];
dragActiveText?: string;
fileTypeText?: string;
sizeLimitText?: string;
disabled?: boolean;
validator?: (file: File) => { isValid: boolean; error?: string };
}
export const FileUpload = ({
onUpload,
onRemove,
multiple = false,
maxFiles = 1,
maxSize = 5 * 1024 * 1024,
accept = { 'image/*': ['.jpeg', '.png', '.jpg'] },
showPreview = false,
previewSize = 80,
children,
className = 'border-2 border-dashed rounded-3xl p-6 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]',
activeClassName = 'border-blue-500 bg-blue-50',
acceptClassName = 'border-green-500 dark:border-green-500 bg-green-50 dark:bg-green-50/10',
rejectClassName = 'border-red-500 bg-red-50 dark:bg-red-500/10 dark:border-red-500',
uploadText = 'Click to upload or drag and drop',
dragActiveText = 'Drop the files here',
fileTypeText = 'PNG, JPG, JPEG up to',
sizeLimitText = 'MB',
disabled = false,
validator,
}: FileUploadProps) => {
const [errors, setErrors] = useState<string[]>([]);
const [preview, setPreview] = useState<string | null>(null);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const validateFile = (file: File) => {
const defaultValidation = {
isValid: true,
error: '',
};
if (validator) {
const customValidation = validator(file);
if (!customValidation.isValid) {
return customValidation;
}
}
if (file.size > maxSize) {
return {
isValid: false,
error: `File exceeds ${maxSize / 1024 / 1024}MB limit`,
};
}
return defaultValidation;
};
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: any[]) => {
setErrors([]);
if (fileRejections.length > 0) {
const newErrors = fileRejections
.map(({ errors }) => errors.map((e: any) => e.message))
.flat();
setErrors(newErrors);
return;
}
const validationResults = acceptedFiles.map(validateFile);
const invalidFiles = validationResults.filter((r) => !r.isValid);
if (invalidFiles.length > 0) {
setErrors(invalidFiles.map((f) => f.error!));
return;
}
const filesToUpload = multiple ? acceptedFiles : [acceptedFiles[0]];
onUpload(filesToUpload);
const file = multiple ? acceptedFiles[0] : acceptedFiles[0];
setCurrentFile(file);
if (showPreview && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
},
[onUpload, multiple, maxSize, validator],
);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
multiple,
maxFiles,
maxSize,
accept,
disabled,
});
const currentClassName = twMerge(
'border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]',
className,
isDragActive && activeClassName,
isDragAccept && acceptClassName,
isDragReject && rejectClassName,
disabled && 'opacity-50 cursor-not-allowed',
);
const handleRemove = () => {
setPreview(null);
setCurrentFile(null);
if (onRemove && currentFile) onRemove(currentFile);
};
const renderPreview = () => (
<div
className="relative"
style={{ width: previewSize, height: previewSize }}
>
<img
src={preview ?? undefined}
alt="preview"
className="h-full w-full rounded-md object-cover"
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
className="absolute -right-2 -top-2 rounded-full bg-[#7D54D1] p-1 transition-colors hover:bg-[#714cbc]"
>
<img src={Cross} alt="remove" className="h-3 w-3" />
</button>
</div>
);
const renderUploadText = () => {
if (Array.isArray(uploadText)) {
return (
<p className="text-sm font-semibold">
{uploadText.map((segment, i) => (
<span key={i} className={segment.colorClass || ''}>
{segment.text}
</span>
))}
</p>
);
}
return <p className="text-sm font-semibold">{uploadText}</p>;
};
const defaultContent = (
<div className="flex flex-col items-center gap-2">
{showPreview && preview ? (
renderPreview()
) : (
<div
style={{ width: previewSize, height: previewSize }}
className="flex items-center justify-center"
>
<img src={ImagesIcon} className="h-10 w-10" />
</div>
)}
<div className="text-center">
<div className="text-sm font-medium">
{isDragActive ? (
<p className="text-sm font-semibold">{dragActiveText}</p>
) : (
renderUploadText()
)}
</div>
<p className="mt-1 text-xs text-[#A3A3A3]">
{fileTypeText} {maxSize / 1024 / 1024}
{sizeLimitText}
</p>
</div>
</div>
);
return (
<div className="relative">
<div {...getRootProps({ className: currentClassName })}>
<input {...getInputProps()} />
{children || defaultContent}
{errors.length > 0 && (
<div className="absolute left-0 right-0 mt-[2px] px-4 text-xs text-red-600">
{errors.map((error, i) => (
<p key={i} className="truncate">
{error}
</p>
))}
</div>
)}
</div>
</div>
);
};