update latest changes

This commit is contained in:
asminkarki012
2025-04-21 21:05:12 +05:45
60 changed files with 4560 additions and 1060 deletions

View File

@@ -49,11 +49,11 @@
- [x] Manually updating chunks in the app UI (Feb 2025)
- [x] Devcontainer for easy development (Feb 2025)
- [x] ReACT agent (March 2025)
- [ ] Anthropic Tool compatibility
- [ ] New input box in the conversation menu
- [ ] Add triggerable actions / tools (webhook)
- [ ] Chatbots menu re-design to handle tools, agent types, and more (April 2025)
- [ ] New input box in the conversation menu (April 2025)
- [ ] Anthropic Tool compatibility (April 2025)
- [ ] Add triggerable actions / tools (webhook) (April 2025)
- [ ] Add OAuth 2.0 authentication for tools and sources
- [ ] Chatbots menu re-design to handle tools, agent types, and more
- [ ] Agent scheduling
You can find our full roadmap [here](https://github.com/orgs/arc53/projects/2). Please don't hesitate to contribute or create issues, it helps us improve DocsGPT!
@@ -95,13 +95,15 @@ A more detailed [Quickstart](https://docs.docsgpt.cloud/quickstart) is available
./setup.sh
```
This interactive script will guide you through setting up DocsGPT. It offers four options: using the public API, running locally, connecting to a local inference engine, or using a cloud API provider. The script will automatically configure your `.env` file and handle necessary downloads and installations based on your chosen option.
**For Windows:**
2. **Follow the Docker Deployment Guide:**
2. **Run the PowerShell setup script:**
Please refer to the [Docker Deployment documentation](https://docs.docsgpt.cloud/Deploying/Docker-Deploying) for detailed step-by-step instructions on setting up DocsGPT using Docker.
```powershell
PowerShell -ExecutionPolicy Bypass -File .\setup.ps1
```
Either script will guide you through setting up DocsGPT. Four options available: using the public API, running locally, connecting to a local inference engine, or using a cloud API provider. Scripts will automatically configure your `.env` file and handle necessary downloads and installations based on your chosen option.
**Navigate to http://localhost:5173/**
@@ -110,7 +112,7 @@ To stop DocsGPT, open a terminal in the `DocsGPT` directory and run:
```bash
docker compose -f deployment/docker-compose.yaml down
```
(or use the specific `docker compose down` command shown after running `setup.sh`).
(or use the specific `docker compose down` command shown after running the setup script).
> [!Note]
> For development environment setup instructions, please refer to the [Development Environment Guide](https://docs.docsgpt.cloud/Deploying/Development-Environment).

View File

@@ -10,6 +10,7 @@ from application.core.mongo_db import MongoDB
from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext
from application.retriever.base import BaseRetriever
from bson.objectid import ObjectId
class BaseAgent(ABC):
@@ -23,7 +24,7 @@ class BaseAgent(ABC):
prompt: str = "",
chat_history: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]]=None,
attachments: Optional[List[Dict]] = None,
):
self.endpoint = endpoint
self.llm_name = llm_name
@@ -58,6 +59,27 @@ class BaseAgent(ABC):
) -> Generator[Dict, None, None]:
pass
def _get_tools(self, api_key: str = None) -> Dict[str, Dict]:
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
agents_collection = db["agents"]
tools_collection = db["user_tools"]
agent_data = agents_collection.find_one({"key": api_key or self.user_api_key})
tool_ids = agent_data.get("tools", []) if agent_data else []
tools = (
tools_collection.find(
{"_id": {"$in": [ObjectId(tool_id) for tool_id in tool_ids]}}
)
if tool_ids
else []
)
tools = list(tools)
tools_by_id = {str(tool["_id"]): tool for tool in tools} if tools else {}
return tools_by_id
def _get_user_tools(self, user="local"):
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
@@ -243,9 +265,11 @@ class BaseAgent(ABC):
tools_dict: Dict,
messages: List[Dict],
log_context: Optional[LogContext] = None,
attachments: Optional[List[Dict]] = None
attachments: Optional[List[Dict]] = None,
):
resp = self.llm_handler.handle_response(self, resp, tools_dict, messages, attachments)
resp = self.llm_handler.handle_response(
self, resp, tools_dict, messages, attachments
)
if log_context:
data = build_stack_data(self.llm_handler)
log_context.stacks.append({"component": "llm_handler", "data": data})

View File

@@ -5,14 +5,18 @@ from application.logging import LogContext
from application.retriever.base import BaseRetriever
import logging
logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent):
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]:
retrieved_data = self._retriever_search(retriever, query, log_context)
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._prepare_tools(tools_dict)
@@ -33,7 +37,7 @@ class ClassicAgent(BaseAgent):
yield {"answer": resp.message.content}
return
resp = self._llm_handler(resp, tools_dict, messages, log_context,attachments)
resp = self._llm_handler(resp, tools_dict, messages, log_context, attachments)
if isinstance(resp, str):
yield {"answer": resp}
@@ -44,10 +48,13 @@ class ClassicAgent(BaseAgent):
):
yield {"answer": resp.message.content}
else:
completion = self.llm.gen_stream(
model=self.gpt_model, messages=messages, tools=self.tools
)
for line in completion:
# completion = self.llm.gen_stream(
# model=self.gpt_model, messages=messages, tools=self.tools
# )
# log type of resp
logger.info(f"Response type: {type(resp)}")
logger.info(f"Response: {resp}")
for line in resp:
if isinstance(line, str):
yield {"answer": line}

View File

@@ -33,15 +33,53 @@ class LLMHandler(ABC):
logger.info(f"Preparing messages with {len(attachments)} attachments")
# Check if the LLM has its own custom attachment handling implementation
if hasattr(agent.llm, "prepare_messages_with_attachments") and agent.llm.__class__.__name__ != "BaseLLM":
logger.info(f"Using {agent.llm.__class__.__name__}'s own prepare_messages_with_attachments method")
return agent.llm.prepare_messages_with_attachments(messages, attachments)
supported_types = agent.llm.get_supported_attachment_types()
# Otherwise, append attachment content to the system prompt
supported_attachments = []
unsupported_attachments = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
import mimetypes
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
else:
unsupported_attachments.append(attachment)
continue
if mime_type in supported_types:
supported_attachments.append(attachment)
else:
unsupported_attachments.append(attachment)
# Process supported attachments with the LLM's custom method
prepared_messages = messages
if supported_attachments:
logger.info(f"Processing {len(supported_attachments)} supported attachments with {agent.llm.__class__.__name__}'s method")
prepared_messages = agent.llm.prepare_messages_with_attachments(messages, supported_attachments)
# Process unsupported attachments with the default method
if unsupported_attachments:
logger.info(f"Processing {len(unsupported_attachments)} unsupported attachments with default method")
prepared_messages = self._append_attachment_content_to_system(prepared_messages, unsupported_attachments)
return prepared_messages
def _append_attachment_content_to_system(self, messages, attachments):
"""
Default method to append attachment content to the system prompt.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content.
Returns:
list: Messages with attachment context added to the system prompt.
"""
prepared_messages = messages.copy()
# Build attachment content string
attachment_texts = []
for attachment in attachments:
logger.info(f"Adding attachment {attachment.get('id')} to context")
@@ -122,12 +160,13 @@ class OpenAILLMHandler(LLMHandler):
return resp
else:
text_buffer = ""
while True:
tool_calls = {}
for chunk in resp:
if isinstance(chunk, str) and len(chunk) > 0:
return
yield chunk
continue
elif hasattr(chunk, "delta"):
chunk_delta = chunk.delta
@@ -206,12 +245,17 @@ class OpenAILLMHandler(LLMHandler):
}
)
tool_calls = {}
if hasattr(chunk_delta, "content") and chunk_delta.content:
# Add to buffer or yield immediately based on your preference
text_buffer += chunk_delta.content
yield text_buffer
text_buffer = ""
if (
hasattr(chunk, "finish_reason")
and chunk.finish_reason == "stop"
):
return
return resp
elif isinstance(chunk, str) and len(chunk) == 0:
continue
@@ -298,6 +342,9 @@ class GoogleLLMHandler(LLMHandler):
"content": [function_response_part.to_json_dict()],
}
)
else:
tool_call_found = False
yield result
if not tool_call_found:
return response

View File

@@ -30,6 +30,9 @@ class ReActAgent(BaseAgent):
) -> Generator[Dict, None, None]:
retrieved_data = self._retriever_search(retriever, query, log_context)
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._prepare_tools(tools_dict)

View File

@@ -27,7 +27,7 @@ db = mongo["docsgpt"]
conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
api_key_collection = db["api_keys"]
agents_collection = db["agents"]
user_logs_collection = db["user_logs"]
attachments_collection = db["attachments"]
@@ -86,19 +86,42 @@ def run_async_chain(chain, question, chat_history):
return result
def get_data_from_api_key(api_key):
data = api_key_collection.find_one({"key": api_key})
# # Raise custom exception if the API key is not found
if data is None:
raise Exception("Invalid API Key, please generate new key", 401)
def get_agent_key(agent_id, user_id):
if not agent_id:
return None
if "source" in data and isinstance(data["source"], DBRef):
source_doc = db.dereference(data["source"])
try:
agent = agents_collection.find_one({"_id": ObjectId(agent_id)})
if agent is None:
raise Exception("Agent not found", 404)
if agent.get("user") == user_id:
agents_collection.update_one(
{"_id": ObjectId(agent_id)},
{"$set": {"lastUsedAt": datetime.datetime.now(datetime.timezone.utc)}},
)
return str(agent["key"])
raise Exception("Unauthorized access to the agent", 403)
except Exception as e:
logger.error(f"Error in get_agent_key: {str(e)}")
raise
def get_data_from_api_key(api_key):
data = agents_collection.find_one({"key": api_key})
if not data:
raise Exception("Invalid API Key, please generate a new key", 401)
source = data.get("source")
if isinstance(source, DBRef):
source_doc = db.dereference(source)
data["source"] = str(source_doc["_id"])
if "retriever" in source_doc:
data["retriever"] = source_doc["retriever"]
data["retriever"] = source_doc.get("retriever", data.get("retriever"))
else:
data["source"] = {}
return data
@@ -128,7 +151,8 @@ def save_conversation(
llm,
decoded_token,
index=None,
api_key=None
api_key=None,
agent_id=None,
):
current_time = datetime.datetime.now(datetime.timezone.utc)
if conversation_id is not None and index is not None:
@@ -202,7 +226,9 @@ def save_conversation(
],
}
if api_key:
api_key_doc = api_key_collection.find_one({"key": api_key})
if agent_id:
conversation_data["agent_id"] = agent_id
api_key_doc = agents_collection.find_one({"key": api_key})
if api_key_doc:
conversation_data["api_key"] = api_key_doc["key"]
conversation_id = conversations_collection.insert_one(
@@ -234,6 +260,7 @@ def complete_stream(
index=None,
should_save_conversation=True,
attachments=None,
agent_id=None,
):
try:
response_full, thought, source_log_docs, tool_calls = "", "", [], []
@@ -241,7 +268,9 @@ def complete_stream(
if attachments:
attachment_ids = [attachment["id"] for attachment in attachments]
logger.info(f"Processing request with {len(attachments)} attachments: {attachment_ids}")
logger.info(
f"Processing request with {len(attachments)} attachments: {attachment_ids}"
)
answer = agent.gen(query=question, retriever=retriever)
@@ -294,7 +323,8 @@ def complete_stream(
llm,
decoded_token,
index,
api_key=user_api_key
api_key=user_api_key,
agent_id=agent_id,
)
else:
conversation_id = None
@@ -366,7 +396,9 @@ class Stream(Resource):
required=False, description="Index of the query to update"
),
"save_conversation": fields.Boolean(
required=False, default=True, description="Whether to save the conversation"
required=False,
default=True,
description="Whether to save the conversation",
),
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
@@ -400,6 +432,14 @@ class Stream(Resource):
chunks = int(data.get("chunks", 2))
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic")
agent_id = data.get("agent_id", None)
agent_type = settings.AGENT_NAME
agent_key = get_agent_key(agent_id, request.decoded_token.get("sub"))
if agent_key:
data.update({"api_key": agent_key})
else:
agent_id = None
if "api_key" in data:
data_key = get_data_from_api_key(data["api_key"])
@@ -408,6 +448,7 @@ class Stream(Resource):
source = {"active_docs": data_key.get("source")}
retriever_name = data_key.get("retriever", retriever_name)
user_api_key = data["api_key"]
agent_type = data_key.get("agent_type", agent_type)
decoded_token = {"sub": data_key.get("user")}
elif "active_docs" in data:
@@ -424,7 +465,9 @@ class Stream(Resource):
if not decoded_token:
return make_response({"error": "Unauthorized"}, 401)
attachments = get_attachments_content(attachment_ids, decoded_token.get("sub"))
attachments = get_attachments_content(
attachment_ids, decoded_token.get("sub")
)
logger.info(
f"/stream - request_data: {data}, source: {source}, attachments: {len(attachments)}",
@@ -436,7 +479,7 @@ class Stream(Resource):
chunks = 0
agent = AgentCreator.create_agent(
settings.AGENT_NAME,
agent_type,
endpoint="stream",
llm_name=settings.LLM_NAME,
gpt_model=gpt_model,
@@ -471,6 +514,7 @@ class Stream(Resource):
isNoneDoc=data.get("isNoneDoc"),
index=index,
should_save_conversation=save_conv,
agent_id=agent_id,
),
mimetype="text/event-stream",
)
@@ -552,6 +596,7 @@ class Answer(Resource):
chunks = int(data.get("chunks", 2))
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic")
agent_type = settings.AGENT_NAME
if "api_key" in data:
data_key = get_data_from_api_key(data["api_key"])
@@ -560,6 +605,7 @@ class Answer(Resource):
source = {"active_docs": data_key.get("source")}
retriever_name = data_key.get("retriever", retriever_name)
user_api_key = data["api_key"]
agent_type = data_key.get("agent_type", agent_type)
decoded_token = {"sub": data_key.get("user")}
elif "active_docs" in data:
@@ -584,7 +630,7 @@ class Answer(Resource):
)
agent = AgentCreator.create_agent(
settings.AGENT_NAME,
agent_type,
endpoint="api/answer",
llm_name=settings.LLM_NAME,
gpt_model=gpt_model,
@@ -611,6 +657,7 @@ class Answer(Resource):
source_log_docs = []
tool_calls = []
stream_ended = False
thought = ""
for line in complete_stream(
question=question,
@@ -633,6 +680,8 @@ class Answer(Resource):
source_log_docs = event["source"]
elif event["type"] == "tool_calls":
tool_calls = event["tool_calls"]
elif event["type"] == "thought":
thought = event["thought"]
elif event["type"] == "error":
logger.error(f"Error from stream: {event['error']}")
return bad_request(500, event["error"])
@@ -664,6 +713,7 @@ class Answer(Resource):
conversation_id,
question,
response_full,
thought,
source_log_docs,
tool_calls,
llm,
@@ -825,18 +875,12 @@ def get_attachments_content(attachment_ids, user):
attachments = []
for attachment_id in attachment_ids:
try:
attachment_doc = attachments_collection.find_one({
"_id": ObjectId(attachment_id),
"user": user
})
attachment_doc = attachments_collection.find_one(
{"_id": ObjectId(attachment_id), "user": user}
)
if attachment_doc:
attachments.append({
"id": str(attachment_doc["_id"]),
"content": attachment_doc["content"],
"token_count": attachment_doc.get("token_count", 0),
"path": attachment_doc.get("path", "")
})
attachments.append(attachment_doc)
except Exception as e:
logger.error(f"Error retrieving attachment {attachment_id}: {e}")

View File

@@ -28,7 +28,7 @@ conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
feedback_collection = db["feedback"]
api_key_collection = db["api_keys"]
agents_collection = db["agents"]
token_usage_collection = db["token_usage"]
shared_conversations_collections = db["shared_conversations"]
user_logs_collection = db["user_logs"]
@@ -138,14 +138,24 @@ class GetConversations(Resource):
try:
conversations = (
conversations_collection.find(
{"api_key": {"$exists": False}, "user": decoded_token.get("sub")}
{
"$or": [
{"api_key": {"$exists": False}},
{"agent_id": {"$exists": True}},
],
"user": decoded_token.get("sub"),
}
)
.sort("date", -1)
.limit(30)
)
list_conversations = [
{"id": str(conversation["_id"]), "name": conversation["name"]}
{
"id": str(conversation["_id"]),
"name": conversation["name"],
"agent_id": conversation.get("agent_id", None),
}
for conversation in conversations
]
except Exception as err:
@@ -179,7 +189,12 @@ class GetSingleConversation(Resource):
except Exception as err:
current_app.logger.error(f"Error retrieving conversation: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(conversation["queries"]), 200)
data = {
"queries": conversation["queries"],
"agent_id": conversation.get("agent_id"),
}
return make_response(jsonify(data), 200)
@user_ns.route("/api/update_conversation_name")
@@ -920,124 +935,398 @@ class UpdatePrompt(Resource):
return make_response(jsonify({"success": True}), 200)
@user_ns.route("/api/get_api_keys")
class GetApiKeys(Resource):
@api.doc(description="Retrieve API keys for the user")
@user_ns.route("/api/get_agent")
class GetAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Get a single 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
)
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": user}
)
if not agent:
return make_response(jsonify({"status": "Not found"}), 404)
data = {
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent["description"],
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent and isinstance(agent["source"], DBRef)
else ""
),
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent["prompt_id"],
"tools": agent.get("tools", []),
"agent_type": agent["agent_type"],
"status": agent["status"],
"createdAt": agent["createdAt"],
"updatedAt": agent["updatedAt"],
"lastUsedAt": agent["lastUsedAt"],
"key": f"{agent['key'][:4]}...{agent['key'][-4:]}",
}
except Exception as err:
current_app.logger.error(f"Error retrieving agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(data), 200)
@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)
user = decoded_token.get("sub")
try:
keys = api_key_collection.find({"user": user})
list_keys = []
for key in keys:
if "source" in key and isinstance(key["source"], DBRef):
source = db.dereference(key["source"])
if source is None:
continue
source_name = source["name"]
elif "retriever" in key:
source_name = key["retriever"]
else:
continue
list_keys.append(
agents = agents_collection.find({"user": user})
list_agents = [
{
"id": str(key["_id"]),
"name": key["name"],
"key": key["key"][:4] + "..." + key["key"][-4:],
"source": source_name,
"prompt_id": key["prompt_id"],
"chunks": key["chunks"],
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent["description"],
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent and isinstance(agent["source"], DBRef)
else ""
),
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent["prompt_id"],
"tools": agent.get("tools", []),
"agent_type": agent["agent_type"],
"status": agent["status"],
"created_at": agent["createdAt"],
"updated_at": agent["updatedAt"],
"last_used_at": agent["lastUsedAt"],
"key": f"{agent['key'][:4]}...{agent['key'][-4:]}",
}
)
for agent in agents
if "source" in agent or "retriever" in agent
]
except Exception as err:
current_app.logger.error(f"Error retrieving API keys: {err}")
current_app.logger.error(f"Error retrieving agents: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_keys), 200)
return make_response(jsonify(list_agents), 200)
@user_ns.route("/api/create_api_key")
class CreateApiKey(Resource):
create_api_key_model = api.model(
"CreateApiKeyModel",
@user_ns.route("/api/create_agent")
class CreateAgent(Resource):
create_agent_model = api.model(
"CreateAgentModel",
{
"name": fields.String(required=True, description="Name of the API key"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"name": fields.String(required=True, description="Name of the agent"),
"description": fields.String(
required=True, description="Description of the agent"
),
"image": fields.String(
required=False, description="Image URL or identifier"
),
"source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"),
"source": fields.String(description="Source ID (optional)"),
"retriever": fields.String(description="Retriever (optional)"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
},
)
@api.expect(create_api_key_model)
@api.doc(description="Create a new API key")
@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)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["name", "prompt_id", "chunks"]
if data.get("status") not in ["draft", "published"]:
return make_response(
jsonify({"success": False, "message": "Invalid status"}), 400
)
required_fields = []
if data.get("status") == "published":
required_fields = [
"name",
"description",
"source",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
else:
required_fields = ["name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
key = str(uuid.uuid4())
new_api_key = {
"name": data["name"],
"key": key,
new_agent = {
"user": user,
"prompt_id": data["prompt_id"],
"chunks": data["chunks"],
"name": data.get("name"),
"description": data.get("description", ""),
"image": data.get("image", ""),
"source": (
DBRef("sources", ObjectId(data.get("source")))
if ObjectId.is_valid(data.get("source"))
else ""
),
"chunks": data.get("chunks", ""),
"retriever": data.get("retriever", ""),
"prompt_id": data.get("prompt_id", ""),
"tools": data.get("tools", []),
"agent_type": data.get("agent_type", ""),
"status": data.get("status"),
"createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None,
"key": key,
}
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key["source"] = DBRef("sources", ObjectId(data["source"]))
if "retriever" in data:
new_api_key["retriever"] = data["retriever"]
resp = api_key_collection.insert_one(new_api_key)
resp = agents_collection.insert_one(new_agent)
new_id = str(resp.inserted_id)
except Exception as err:
current_app.logger.error(f"Error creating API key: {err}")
current_app.logger.error(f"Error creating agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id, "key": key}), 201)
@user_ns.route("/api/delete_api_key")
class DeleteApiKey(Resource):
delete_api_key_model = api.model(
"DeleteApiKeyModel",
{"id": fields.String(required=True, description="API Key ID to delete")},
@user_ns.route("/api/update_agent/<string:agent_id>")
class UpdateAgent(Resource):
update_agent_model = api.model(
"UpdateAgentModel",
{
"name": fields.String(required=True, description="New name of the agent"),
"description": fields.String(
required=True, description="New description of the agent"
),
"image": fields.String(
required=False, description="New image URL or identifier"
),
"source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
},
)
@api.expect(delete_api_key_model)
@api.doc(description="Delete an API key by ID")
def post(self):
@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)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
if not ObjectId.is_valid(agent_id):
return make_response(
jsonify({"success": False, "message": "Invalid agent ID format"}), 400
)
oid = ObjectId(agent_id)
try:
result = api_key_collection.delete_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if result.deleted_count == 0:
return {"success": False, "message": "API Key not found"}, 404
existing_agent = agents_collection.find_one({"_id": oid, "user": user})
except Exception as err:
current_app.logger.error(f"Error deleting API key: {err}")
return {"success": False}, 400
return make_response(
current_app.logger.error(f"Error finding agent {agent_id}: {err}"),
jsonify({"success": False, "message": "Database error finding agent"}),
500,
)
return {"success": True}, 200
if not existing_agent:
return make_response(
jsonify(
{"success": False, "message": "Agent not found or not authorized"}
),
404,
)
update_fields = {}
allowed_fields = [
"name",
"description",
"image",
"source",
"chunks",
"retriever",
"prompt_id",
"tools",
"agent_type",
"status",
]
for field in allowed_fields:
if field in data:
if field == "status":
new_status = data.get("status")
if new_status not in ["draft", "published"]:
return make_response(
jsonify(
{"success": False, "message": "Invalid status value"}
),
400,
)
update_fields[field] = new_status
elif field == "source":
source_id = data.get("source")
if source_id and ObjectId.is_valid(source_id):
update_fields[field] = DBRef("sources", ObjectId(source_id))
elif source_id:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid source ID format provided",
}
),
400,
)
else:
update_fields[field] = ""
else:
update_fields[field] = data[field]
if not update_fields:
return make_response(
jsonify({"success": False, "message": "No update data provided"}), 400
)
final_status = update_fields.get("status", existing_agent.get("status"))
if final_status == "published":
required_published_fields = [
"name",
"description",
"source",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
missing_published_fields = []
for req_field in required_published_fields:
final_value = update_fields.get(
req_field, existing_agent.get(req_field)
)
if req_field == "source" and final_value:
if not isinstance(final_value, DBRef):
missing_published_fields.append(req_field)
if missing_published_fields:
return make_response(
jsonify(
{
"success": False,
"message": f"Cannot publish agent. Missing or invalid required fields: {', '.join(missing_published_fields)}",
}
),
400,
)
update_fields["updatedAt"] = datetime.datetime.now(datetime.timezone.utc)
try:
result = agents_collection.update_one(
{"_id": oid, "user": user}, {"$set": update_fields}
)
if result.matched_count == 0:
return make_response(
jsonify(
{
"success": False,
"message": "Agent not found or update failed unexpectedly",
}
),
404,
)
if result.modified_count == 0 and result.matched_count == 1:
return make_response(
jsonify(
{
"success": True,
"message": "Agent found, but no changes were applied.",
}
),
304,
)
except Exception as err:
current_app.logger.error(f"Error updating agent {agent_id}: {err}")
return make_response(
jsonify({"success": False, "message": "Database error during update"}),
500,
)
return make_response(
jsonify(
{
"success": True,
"id": agent_id,
"message": "Agent updated successfully",
}
),
200,
)
@user_ns.route("/api/delete_agent")
class DeleteAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Delete an agent by ID")
def delete(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
)
try:
deleted_agent = agents_collection.find_one_and_delete(
{"_id": ObjectId(agent_id), "user": user}
)
if not deleted_agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
deleted_id = str(deleted_agent["_id"])
except Exception as err:
current_app.logger.error(f"Error deleting agent: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": deleted_id}), 200)
@user_ns.route("/api/share")
@@ -1112,9 +1401,7 @@ class ShareConversation(Resource):
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
pre_existing_api_document = api_key_collection.find_one(
new_api_key_data
)
pre_existing_api_document = agents_collection.find_one(new_api_key_data)
if pre_existing_api_document:
api_uuid = pre_existing_api_document["key"]
pre_existing = shared_conversations_collections.find_one(
@@ -1173,7 +1460,7 @@ class ShareConversation(Resource):
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
api_key_collection.insert_one(new_api_key_data)
agents_collection.insert_one(new_api_key_data)
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
@@ -1331,9 +1618,9 @@ class GetMessageAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1455,9 +1742,9 @@ class GetTokenAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1614,9 +1901,9 @@ class GetFeedbackAnalytics(Resource):
try:
api_key = (
api_key_collection.find_one(
{"_id": ObjectId(api_key_id), "user": user}
)["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
@@ -1779,7 +2066,7 @@ class GetUserLogs(Resource):
try:
api_key = (
api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
agents_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
@@ -2506,39 +2793,39 @@ class StoreAttachment(Resource):
user = secure_filename(decoded_token.get("sub"))
try:
attachment_id = ObjectId()
original_filename = secure_filename(file.filename)
folder_name = original_filename
save_dir = os.path.join(current_dir, settings.UPLOAD_FOLDER, user, "attachments",folder_name)
save_dir = os.path.join(
current_dir,
settings.UPLOAD_FOLDER,
user,
"attachments",
str(attachment_id),
)
os.makedirs(save_dir, exist_ok=True)
# Create directory structure: user/attachments/filename/
file_path = os.path.join(save_dir, original_filename)
# Handle filename conflicts
if os.path.exists(file_path):
name_parts = os.path.splitext(original_filename)
timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
new_filename = f"{name_parts[0]}_{timestamp}{name_parts[1]}"
file_path = os.path.join(save_dir, new_filename)
original_filename = new_filename
file.save(file_path)
file_info = {"folder": folder_name, "filename": original_filename}
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id),
}
current_app.logger.info(f"Saved file: {file_path}")
# Start async task to process single file
task = store_attachment.delay(
save_dir,
file_info,
user
)
task = store_attachment.delay(save_dir, file_info, user)
return make_response(
jsonify({
jsonify(
{
"success": True,
"task_id": task.id,
"message": "File uploaded successfully. Processing started."
}),
200
"message": "File uploaded successfully. Processing started.",
}
),
200,
)
except Exception as err:

View File

@@ -55,3 +55,12 @@ class BaseLLM(ABC):
def _supports_tools(self):
raise NotImplementedError("Subclass must implement _supports_tools method")
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by this LLM for file uploads.
Returns:
list: List of supported MIME types
"""
return [] # Default: no attachments supported

View File

@@ -1,5 +1,9 @@
from google import genai
from google.genai import types
import os
import logging
import mimetypes
import json
from application.llm.base import BaseLLM
@@ -9,6 +13,138 @@ class GoogleLLM(BaseLLM):
super().__init__(*args, **kwargs)
self.api_key = api_key
self.user_api_key = user_api_key
self.client = genai.Client(api_key=self.api_key)
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by Google Gemini for file uploads.
Returns:
list: List of supported MIME types
"""
return [
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif'
]
def prepare_messages_with_attachments(self, messages, attachments=None):
"""
Process attachments using Google AI's file API for more efficient handling.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content and metadata.
Returns:
list: Messages formatted with file references for Google AI API.
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach files to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
{"type": "text", "text": text_content}
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
files = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
if mime_type in self.get_supported_attachment_types():
try:
file_uri = self._upload_file_to_google(attachment)
logging.info(f"GoogleLLM: Successfully uploaded file, got URI: {file_uri}")
files.append({"file_uri": file_uri, "mime_type": mime_type})
except Exception as e:
logging.error(f"GoogleLLM: Error uploading file: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"[File could not be processed: {attachment.get('path', 'unknown')}]"
})
if files:
logging.info(f"GoogleLLM: Adding {len(files)} files to message")
prepared_messages[user_message_index]["content"].append({
"files": files
})
return prepared_messages
def _upload_file_to_google(self, attachment):
"""
Upload a file to Google AI and return the file URI.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Returns:
str: Google AI file URI for the uploaded file.
"""
if 'google_file_uri' in attachment:
return attachment['google_file_uri']
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir, "application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
mime_type = attachment.get('mime_type')
if not mime_type:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
try:
response = self.client.files.upload(file=file_path)
file_uri = response.uri
from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
if '_id' in attachment:
attachments_collection.update_one(
{"_id": attachment['_id']},
{"$set": {"google_file_uri": file_uri}}
)
return file_uri
except Exception as e:
logging.error(f"Error uploading file to Google AI: {e}")
raise
def _clean_messages_google(self, messages):
cleaned_messages = []
@@ -26,7 +162,7 @@ class GoogleLLM(BaseLLM):
elif isinstance(content, list):
for item in content:
if "text" in item:
parts.append(types.Part.from_text(item["text"]))
parts.append(types.Part.from_text(text=item["text"]))
elif "function_call" in item:
parts.append(
types.Part.from_function_call(
@@ -41,6 +177,14 @@ class GoogleLLM(BaseLLM):
response=item["function_response"]["response"],
)
)
elif "files" in item:
for file_data in item["files"]:
parts.append(
types.Part.from_uri(
file_uri=file_data["file_uri"],
mime_type=file_data["mime_type"]
)
)
else:
raise ValueError(
f"Unexpected content dictionary format:{item}"
@@ -146,11 +290,25 @@ class GoogleLLM(BaseLLM):
cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools
# Check if we have both tools and file attachments
has_attachments = False
for message in messages:
for part in message.parts:
if hasattr(part, 'file_data') and part.file_data is not None:
has_attachments = True
break
if has_attachments:
break
logging.info(f"GoogleLLM: Starting stream generation. Model: {model}, Messages: {json.dumps(messages, default=str)}, Has attachments: {has_attachments}")
response = client.models.generate_content_stream(
model=model,
contents=messages,
config=config,
)
for chunk in response:
if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates:

View File

@@ -1,4 +1,8 @@
import json
import base64
import os
import mimetypes
import logging
from application.core.settings import settings
from application.llm.base import BaseLLM
@@ -65,6 +69,15 @@ class OpenAILLM(BaseLLM):
),
}
)
elif isinstance(item, dict):
content_parts = []
if "text" in item:
content_parts.append({"type": "text", "text": item["text"]})
elif "type" in item and item["type"] == "text" and "text" in item:
content_parts.append(item)
elif "type" in item and item["type"] == "file" and "file" in item:
content_parts.append(item)
cleaned_messages.append({"role": role, "content": content_parts})
else:
raise ValueError(
f"Unexpected content dictionary format: {item}"
@@ -133,6 +146,183 @@ class OpenAILLM(BaseLLM):
def _supports_tools(self):
return True
def get_supported_attachment_types(self):
"""
Return a list of MIME types supported by OpenAI for file uploads.
Returns:
list: List of supported MIME types
"""
return [
'application/pdf',
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif'
]
def prepare_messages_with_attachments(self, messages, attachments=None):
"""
Process attachments using OpenAI's file API for more efficient handling.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content and metadata.
Returns:
list: Messages formatted with file references for OpenAI API.
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach file_id to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
{"type": "text", "text": text_content}
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if not mime_type:
file_path = attachment.get('path')
if file_path:
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
if mime_type and mime_type.startswith('image/'):
try:
base64_image = self._get_base64_image(attachment)
prepared_messages[user_message_index]["content"].append({
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{base64_image}"
}
})
except Exception as e:
logging.error(f"Error processing image attachment: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"[Image could not be processed: {attachment.get('path', 'unknown')}]"
})
# Handle PDFs using the file API
elif mime_type == 'application/pdf':
try:
file_id = self._upload_file_to_openai(attachment)
prepared_messages[user_message_index]["content"].append({
"type": "file",
"file": {"file_id": file_id}
})
except Exception as e:
logging.error(f"Error uploading PDF to OpenAI: {e}")
if 'content' in attachment:
prepared_messages[user_message_index]["content"].append({
"type": "text",
"text": f"File content:\n\n{attachment['content']}"
})
return prepared_messages
def _get_base64_image(self, attachment):
"""
Convert an image file to base64 encoding.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Returns:
str: Base64-encoded image data.
"""
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir, "application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
with open(file_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
def _upload_file_to_openai(self, attachment): ##pdfs
"""
Upload a file to OpenAI and return the file_id.
Args:
attachment (dict): Attachment dictionary with path and metadata.
Expected keys:
- path: Path to the file
- id: Optional MongoDB ID for caching
Returns:
str: OpenAI file_id for the uploaded file.
"""
import os
import logging
if 'openai_file_id' in attachment:
return attachment['openai_file_id']
file_path = attachment.get('path')
if not file_path:
raise ValueError("No file path provided in attachment")
if not os.path.isabs(file_path):
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
file_path = os.path.join(current_dir,"application", file_path)
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
with open(file_path, 'rb') as file:
response = self.client.files.create(
file=file,
purpose="assistants"
)
file_id = response.id
from application.core.mongo_db import MongoDB
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
if '_id' in attachment:
attachments_collection.update_one(
{"_id": attachment['_id']},
{"$set": {"openai_file_id": file_id}}
)
return file_id
except Exception as e:
logging.error(f"Error uploading file to OpenAI: {e}")
raise
class AzureOpenAILLM(OpenAILLM):

View File

@@ -15,7 +15,7 @@ Flask==3.1.0
faiss-cpu==1.9.0.post1
flask-restx==1.3.0
google-genai==1.3.0
google-generativeai==0.8.3
google-generativeai==0.8.5
gTTS==2.5.4
gunicorn==23.0.0
html2text==2024.2.26
@@ -41,7 +41,7 @@ lxml==5.3.1
markupsafe==3.0.2
marshmallow==3.26.1
mpmath==1.3.0
multidict==6.1.0
multidict==6.3.2
mypy-extensions==1.0.0
networkx==3.4.2
numpy==2.2.1

View File

@@ -328,34 +328,30 @@ def attachment_worker(self, directory, file_info, user):
"""
import datetime
import os
import mimetypes
from application.utils import num_tokens_from_string
mongo = MongoDB.get_client()
db = mongo["docsgpt"]
attachments_collection = db["attachments"]
job_name = file_info["folder"]
logging.info(f"Processing attachment: {job_name}", extra={"user": user, "job": job_name})
filename = file_info["filename"]
attachment_id = file_info["attachment_id"]
logging.info(f"Processing attachment: {attachment_id}/{filename}", extra={"user": user})
self.update_state(state="PROGRESS", meta={"current": 10})
folder_name = file_info["folder"]
filename = file_info["filename"]
file_path = os.path.join(directory, filename)
logging.info(f"Processing file: {file_path}", extra={"user": user, "job": job_name})
if not os.path.exists(file_path):
logging.warning(f"File not found: {file_path}", extra={"user": user, "job": job_name})
return {"error": "File not found"}
logging.warning(f"File not found: {file_path}", extra={"user": user})
raise FileNotFoundError(f"File not found: {file_path}")
try:
reader = SimpleDirectoryReader(
input_files=[file_path]
)
documents = reader.load_data()
self.update_state(state="PROGRESS", meta={"current": 50})
@@ -364,33 +360,37 @@ def attachment_worker(self, directory, file_info, user):
content = documents[0].text
token_count = num_tokens_from_string(content)
file_path_relative = f"{user}/attachments/{folder_name}/{filename}"
file_path_relative = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{attachment_id}/{filename}"
attachment_id = attachments_collection.insert_one({
mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
doc_id = ObjectId(attachment_id)
attachments_collection.insert_one({
"_id": doc_id,
"user": user,
"path": file_path_relative,
"content": content,
"token_count": token_count,
"mime_type": mime_type,
"date": datetime.datetime.now(),
}).inserted_id
})
logging.info(f"Stored attachment with ID: {attachment_id}",
extra={"user": user, "job": job_name})
extra={"user": user})
self.update_state(state="PROGRESS", meta={"current": 100})
return {
"attachment_id": str(attachment_id),
"filename": filename,
"folder": folder_name,
"path": file_path_relative,
"token_count": token_count
"token_count": token_count,
"attachment_id": attachment_id,
"mime_type": mime_type
}
else:
logging.warning("No content was extracted from the file",
extra={"user": user, "job": job_name})
return {"error": "No content was extracted from the file"}
extra={"user": user})
raise ValueError("No content was extracted from the file")
except Exception as e:
logging.error(f"Error processing file {filename}: {e}",
extra={"user": user, "job": job_name}, exc_info=True)
return {"error": f"Error processing file: {str(e)}"}
logging.error(f"Error processing file {filename}: {e}", extra={"user": user}, exc_info=True)
raise

View File

@@ -73,9 +73,44 @@ The easiest way to launch DocsGPT is using the provided `setup.sh` script. This
## Launching DocsGPT (Windows)
For Windows users, we recommend following the Docker deployment guide for detailed instructions. Please refer to the [Docker Deployment documentation](/Deploying/Docker-Deploying) for step-by-step instructions on setting up DocsGPT on Windows using Docker.
For Windows users, we provide a PowerShell script that offers the same functionality as the macOS/Linux setup script.
**Important for Windows:** Ensure Docker Desktop is installed and running correctly on your Windows system before proceeding.
**Steps:**
1. **Download the DocsGPT Repository:**
First, you need to download the DocsGPT repository to your local machine. You can do this using Git:
```powershell
git clone https://github.com/arc53/DocsGPT.git
cd DocsGPT
```
2. **Run the `setup.ps1` script:**
Execute the PowerShell setup script:
```powershell
PowerShell -ExecutionPolicy Bypass -File .\setup.ps1
```
3. **Follow the interactive setup:**
Just like the Linux/macOS script, the PowerShell script will guide you through setting DocsGPT.
The script will handle environment configuration and start DocsGPT based on your selections.
4. **Access DocsGPT in your browser:**
Once the setup is complete and Docker containers are running, navigate to [http://localhost:5173/](http://localhost:5173/) in your web browser to access the DocsGPT web application.
5. **Stopping DocsGPT:**
To stop DocsGPT run the Docker Compose down command displayed at the end of the setup script's execution.
**Important for Windows:** Ensure Docker Desktop is installed and running correctly on your Windows system before proceeding. The script will attempt to start Docker if it's not running, but you may need to start it manually if there are issues.
**Alternative Method:**
If you prefer a more manual approach, you can follow our [Docker Deployment documentation](/Deploying/Docker-Deploying) for detailed instructions on setting up DocsGPT on Windows using Docker commands directly.
## Advanced Configuration

View File

@@ -51,8 +51,8 @@
"husky": "^8.0.0",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.14",
@@ -8812,10 +8812,11 @@
}
},
"node_modules/prettier": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8839,10 +8840,11 @@
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz",
"integrity": "sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==",
"version": "0.6.11",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz",
"integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.21.3"
},
@@ -8851,7 +8853,7 @@
"@prettier/plugin-pug": "*",
"@shopify/prettier-plugin-liquid": "*",
"@trivago/prettier-plugin-sort-imports": "*",
"@zackad/prettier-plugin-twig-melody": "*",
"@zackad/prettier-plugin-twig": "*",
"prettier": "^3.0",
"prettier-plugin-astro": "*",
"prettier-plugin-css-order": "*",
@@ -8878,7 +8880,7 @@
"@trivago/prettier-plugin-sort-imports": {
"optional": true
},
"@zackad/prettier-plugin-twig-melody": {
"@zackad/prettier-plugin-twig": {
"optional": true
},
"prettier-plugin-astro": {

View File

@@ -29,8 +29,8 @@
"react-chartjs-2": "^5.3.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0",
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^8.0.5",
@@ -62,8 +62,8 @@
"husky": "^8.0.0",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.14",

View File

@@ -4,4 +4,5 @@ module.exports = {
semi: true,
singleQuote: true,
printWidth: 80,
}
plugins: ['prettier-plugin-tailwindcss'],
};

View File

@@ -12,13 +12,14 @@ import useTokenAuth from './hooks/useTokenAuth';
import Navigation from './Navigation';
import PageNotFound from './PageNotFound';
import Setting from './settings';
import Agents from './agents';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { isAuthLoading } = useTokenAuth();
if (isAuthLoading) {
return (
<div className="h-screen flex items-center justify-center">
<div className="flex h-screen items-center justify-center">
<Spinner />
</div>
);
@@ -31,7 +32,7 @@ function MainLayout() {
const [navOpen, setNavOpen] = useState(!isMobile);
return (
<div className="dark:bg-raisin-black relative h-screen overflow-auto">
<div className="relative h-screen overflow-auto dark:bg-raisin-black">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<div
className={`h-[calc(100dvh-64px)] md:h-screen ${
@@ -52,7 +53,7 @@ export default function App() {
return <div />;
}
return (
<div className="h-full relative overflow-auto">
<div className="relative h-full overflow-auto">
<Routes>
<Route
element={
@@ -64,6 +65,7 @@ export default function App() {
<Route index element={<Conversation />} />
<Route path="/about" element={<About />} />
<Route path="/settings/*" element={<Setting />} />
<Route path="/agents/*" element={<Agents />} />
</Route>
<Route path="/share/:identifier" element={<SharedConversation />} />
<Route path="/*" element={<PageNotFound />} />

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { NavLink, useNavigate } from 'react-router-dom';
import { Agent } from './agents/types';
import conversationService from './api/services/conversationService';
import userService from './api/services/userService';
import Add from './assets/add.svg';
@@ -12,11 +13,12 @@ import Expand from './assets/expand.svg';
import Github from './assets/github.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
import Robot from './assets/robot.svg';
import SettingGear from './assets/settingGear.svg';
import Spark from './assets/spark.svg';
import SpinnerDark from './assets/spinner-dark.svg';
import Spinner from './assets/spinner.svg';
import Twitter from './assets/TwitterX.svg';
import UploadIcon from './assets/upload.svg';
import Help from './components/Help';
import {
handleAbort,
@@ -33,13 +35,15 @@ import JWTModal from './modals/JWTModal';
import { ActiveState } from './models/misc';
import { getConversations } from './preferences/preferenceApi';
import {
selectApiKeyStatus,
selectConversationId,
selectConversations,
selectModalStateDeleteConv,
selectSelectedAgent,
selectToken,
setConversations,
setModalStateDeleteConv,
setSelectedAgent,
setAgents,
} from './preferences/preferenceSlice';
import Upload from './upload/Upload';
@@ -50,36 +54,28 @@ interface NavigationProps {
export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const { t } = useTranslation();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const conversations = useSelector(selectConversations);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const conversationId = useSelector(selectConversationId);
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
const selectedAgent = useSelector(selectSelectedAgent);
const { isMobile } = useMediaQuery();
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
const isApiKeySet = useSelector(selectApiKeyStatus);
const { showTokenModal, handleTokenSubmit } = useTokenAuth();
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [recentAgents, setRecentAgents] = useState<Agent[]>([]);
const navRef = useRef(null);
const navigate = useNavigate();
useEffect(() => {
if (!conversations?.data) {
fetchConversations();
}
if (queries.length === 0) {
resetConversation();
}
}, [conversations?.data, dispatch]);
async function fetchConversations() {
dispatch(setConversations({ ...conversations, loading: true }));
return await getConversations(token)
@@ -92,6 +88,29 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
});
}
async function getAgents() {
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents');
const data: Agent[] = await response.json();
dispatch(setAgents(data));
setRecentAgents(
data
.filter((agent: Agent) => agent.status === 'published')
.sort(
(a: Agent, b: Agent) =>
new Date(b.last_used_at ?? 0).getTime() -
new Date(a.last_used_at ?? 0).getTime(),
)
.slice(0, 3),
);
}
useEffect(() => {
if (recentAgents.length === 0) getAgents();
if (!conversations?.data) fetchConversations();
if (queries.length === 0) resetConversation();
}, [conversations?.data, dispatch]);
const handleDeleteAllConversations = () => {
setIsDeletingConversation(true);
conversationService
@@ -113,18 +132,34 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
.catch((error) => console.error(error));
};
const handleAgentClick = (agent: Agent) => {
resetConversation();
dispatch(setSelectedAgent(agent));
if (isMobile) setNavOpen(!navOpen);
navigate('/');
};
const handleConversationClick = (index: string) => {
conversationService
.getConversation(index, token)
.then((response) => response.json())
.then((data) => {
navigate('/');
dispatch(setConversation(data));
dispatch(setConversation(data.queries));
dispatch(
updateConversationId({
query: { conversationId: index },
}),
);
if (data.agent_id) {
userService.getAgent(data.agent_id, token).then((response) => {
if (response.ok) {
response.json().then((agent: Agent) => {
dispatch(setSelectedAgent(agent));
});
}
});
} else dispatch(setSelectedAgent(null));
});
};
@@ -136,6 +171,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
query: { conversationId: null },
}),
);
dispatch(setSelectedAgent(null));
};
const newChat = () => {
@@ -170,8 +206,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
return (
<>
{!navOpen && (
<div className="duration-25 absolute top-3 left-3 z-20 hidden transition-all md:block">
<div className="flex gap-3 items-center">
<div className="duration-25 absolute left-3 top-3 z-20 hidden transition-all md:block">
<div className="flex items-center gap-3">
<button
onClick={() => {
setNavOpen(!navOpen);
@@ -198,7 +234,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/>
</button>
)}
<div className="text-[#949494] font-medium text-[20px]">
<div className="text-[20px] font-medium text-[#949494]">
DocsGPT
</div>
</div>
@@ -208,13 +244,13 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
ref={navRef}
className={`${
!navOpen && '-ml-96 md:-ml-[18rem]'
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-r-[1px] border-b-0 bg-lotion dark:bg-chinese-black transition-all dark:border-r-purple-taupe dark:text-white`}
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-b-0 border-r-[1px] bg-lotion transition-all dark:border-r-purple-taupe dark:bg-chinese-black dark:text-white`}
>
<div
className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'}
>
<div
className="my-auto mx-4 flex cursor-pointer gap-1.5"
className="mx-4 my-auto flex cursor-pointer gap-1.5"
onClick={() => {
if (isMobile) {
setNavOpen(!navOpen);
@@ -252,7 +288,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className={({ isActive }) =>
`${
isActive ? 'bg-transparent' : ''
} group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray dark:border-purple-taupe dark:text-white hover:bg-transparent`
} group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray hover:bg-transparent dark:border-purple-taupe dark:text-white`
}
>
<img
@@ -269,7 +305,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="mb-auto h-[78vh] overflow-y-auto overflow-x-hidden dark:text-white"
>
{conversations?.loading && !isDeletingConversation && (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="animate-spin cursor-pointer bg-transparent"
@@ -277,10 +313,77 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
{recentAgents?.length > 0 ? (
<div>
<div className="my-auto mx-4 mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="ml-4 mt-1 text-sm font-semibold">Agents</p>
</div>
<div className="agents-container">
<div>
{recentAgents.map((agent, idx) => (
<div
key={idx}
className={`mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal ${
agent.id === selectedAgent?.id && !conversationId
? 'bg-bright-gray dark:bg-dark-charcoal'
: ''
}`}
onClick={() => handleAgentClick(agent)}
>
<div className="flex w-6 justify-center">
<img
src={agent.image ?? Robot}
alt="agent-logo"
className="h-6 w-6 rounded-full"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
{agent.name}
</p>
</div>
))}
</div>
<div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
onClick={() => {
dispatch(setSelectedAgent(null));
navigate('/agents');
}}
>
<div className="flex w-6 justify-center">
<img
src={Spark}
alt="manage-agents"
className="h-[18px] w-[18px]"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
Manage Agents
</p>
</div>
</div>
</div>
) : (
<div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal"
onClick={() => navigate('/agents')}
>
<div className="flex w-6 justify-center">
<img
src={Spark}
alt="manage-agents"
className="h-[18px] w-[18px]"
/>
</div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray">
Manage Agents
</p>
</div>
)}
{conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7">
<div className="mx-4 my-auto mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="ml-4 mt-1 text-sm font-semibold">{t('chats')}</p>
</div>
<div className="conversations-container">
{conversations.data?.map((conversation) => (
@@ -316,7 +419,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}}
to="/settings"
className={({ isActive }) =>
`my-auto mx-4 flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${
`mx-4 my-auto flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${
isActive ? 'bg-gray-3000 dark:bg-transparent' : ''
}`
}
@@ -324,7 +427,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<img
src={SettingGear}
alt="Settings"
className="ml-2 w- filter dark:invert"
className="w- ml-2 filter dark:invert"
/>
<p className="my-auto text-sm text-eerie-black dark:text-white">
{t('settings.label')}
@@ -332,7 +435,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</NavLink>
</div>
<div className="flex flex-col justify-end text-eerie-black dark:text-white">
<div className="flex justify-between items-center py-1">
<div className="flex items-center justify-between py-1">
<Help />
<div className="flex items-center gap-1 pr-4">
@@ -381,9 +484,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
</div>
<div className="sticky z-10 h-16 w-full border-b-2 bg-gray-50 dark:border-b-purple-taupe dark:bg-chinese-black md:hidden">
<div className="flex gap-6 items-center h-full ml-6 ">
<div className="ml-6 flex h-full items-center gap-6">
<button
className=" h-6 w-6 md:hidden"
className="h-6 w-6 md:hidden"
onClick={() => setNavOpen(true)}
>
<img
@@ -392,7 +495,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="w-7 filter dark:invert"
/>
</button>
<div className="text-[#949494] font-medium text-[20px]">DocsGPT</div>
<div className="text-[20px] font-medium text-[#949494]">DocsGPT</div>
</div>
</div>
<DeleteConvModal

View File

@@ -0,0 +1,32 @@
import { useNavigate, useParams } from 'react-router-dom';
import ArrowLeft from '../assets/arrow-left.svg';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
export default function AgentLogs() {
const navigate = useNavigate();
const { agentId } = useParams();
return (
<div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={() => navigate('/agents')}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray">
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white">
Agent Logs
</h1>
</div>
<Analytics agentId={agentId} />
<Logs agentId={agentId} tableHeader="Agent endpoint logs" />
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import MessageInput from '../components/MessageInput';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
import {
addQuery,
fetchAnswer,
handleAbort,
resendQuery,
resetConversation,
selectQueries,
selectStatus,
} from '../conversation/conversationSlice';
import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
export default function AgentPreview() {
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectQueries);
const status = useSelector(selectStatus);
const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState('');
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const fetchStream = useRef<any>(null);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(
fetchAnswer({ question, indx: index, isPreview: true }),
);
},
[dispatch],
);
const handleQuestion = useCallback(
({
question,
isRetry = false,
index = undefined,
}: {
question: string;
isRetry?: boolean;
index?: number;
}) => {
const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return;
if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
} else {
if (!isRetry) {
const newQuery: Query = { prompt: trimmedQuestion };
dispatch(addQuery(newQuery));
}
handleFetchAnswer({ question: trimmedQuestion, index: undefined });
}
},
[dispatch, handleFetchAnswer],
);
const handleQuestionSubmission = (
updatedQuestion?: string,
updated?: boolean,
indx?: number,
) => {
if (
updated === true &&
updatedQuestion !== undefined &&
indx !== undefined
) {
handleQuestion({
question: updatedQuestion,
index: indx,
isRetry: false,
});
} else if (input.trim() && status !== 'loading') {
const currentInput = input.trim();
if (lastQueryReturnedErr && queries.length > 0) {
const lastQueryIndex = queries.length - 1;
handleQuestion({
question: currentInput,
isRetry: true,
index: lastQueryIndex,
});
} else {
handleQuestion({
question: currentInput,
isRetry: false,
index: undefined,
});
}
setInput('');
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleQuestionSubmission();
}
};
useEffect(() => {
dispatch(resetConversation());
return () => {
if (fetchStream.current) fetchStream.current.abort();
handleAbort();
dispatch(resetConversation());
};
}, [dispatch]);
useEffect(() => {
if (queries.length > 0) {
const lastQuery = queries[queries.length - 1];
setLastQueryReturnedErr(!!lastQuery.error);
} else setLastQueryReturnedErr(false);
}, [queries]);
return (
<div>
<div className="flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden dark:bg-raisin-black">
<div className="h-[512px] w-full overflow-y-auto">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<MessageInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSubmit={() => handleQuestionSubmission()}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
/>
<p className="w-full self-center bg-transparent pt-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline">
This is a preview of the agent. You can publish it to start using it
in conversations.
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,614 @@
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
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 MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup';
import AgentDetailsModal from '../modals/AgentDetailsModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, Prompt } from '../models/misc';
import {
selectSelectedAgent,
selectSourceDocs,
selectToken,
setSelectedAgent,
} from '../preferences/preferenceSlice';
import PromptsModal from '../preferences/PromptsModal';
import { UserToolType } from '../settings/types';
import AgentPreview from './AgentPreview';
import { Agent } from './types';
const embeddingsName =
import.meta.env.VITE_EMBEDDINGS_NAME ||
'huggingface_sentence-transformers/all-mpnet-base-v2';
export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const navigate = useNavigate();
const dispatch = useDispatch();
const { agentId } = useParams();
const token = useSelector(selectToken);
const sourceDocs = useSelector(selectSourceDocs);
const selectedAgent = useSelector(selectSelectedAgent);
const [effectiveMode, setEffectiveMode] = useState(mode);
const [agent, setAgent] = useState<Agent>({
id: agentId || '',
name: '',
description: '',
image: '',
source: '',
chunks: '',
retriever: '',
prompt_id: '',
tools: [],
agent_type: '',
status: '',
});
const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[]
>([]);
const [userTools, setUserTools] = useState<OptionType[]>([]);
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<
Set<string | number>
>(new Set());
const [selectedToolIds, setSelectedToolIds] = useState<Set<string | number>>(
new Set(),
);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
const [addPromptModal, setAddPromptModal] = useState<ActiveState>('INACTIVE');
const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null);
const toolAnchorButtonRef = useRef<HTMLButtonElement>(null);
const modeConfig = {
new: {
heading: 'New Agent',
buttonText: 'Create Agent',
showDelete: false,
showSaveDraft: true,
showLogs: false,
showAccessDetails: false,
},
edit: {
heading: 'Edit Agent',
buttonText: 'Save Changes',
showDelete: true,
showSaveDraft: false,
showLogs: true,
showAccessDetails: true,
},
draft: {
heading: 'New Agent (Draft)',
buttonText: 'Publish Draft',
showDelete: true,
showSaveDraft: true,
showLogs: false,
showAccessDetails: false,
},
};
const chunks = ['0', '2', '4', '6', '8', '10'];
const agentTypes = [
{ label: 'Classic', value: 'classic' },
{ label: 'ReAct', value: 'react' },
];
const isPublishable = () => {
return (
agent.name &&
agent.description &&
(agent.source || agent.retriever) &&
agent.chunks &&
agent.prompt_id &&
agent.agent_type
);
};
const handleCancel = () => {
if (selectedAgent) dispatch(setSelectedAgent(null));
navigate('/agents');
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
navigate('/agents');
};
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 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') {
setAgentDetails('ACTIVE');
setEffectiveMode('edit');
}
};
useEffect(() => {
const getTools = async () => {
const response = await userService.getUserTools(token);
if (!response.ok) throw new Error('Failed to fetch tools');
const data = await response.json();
const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({
id: tool.id,
label: tool.displayName,
icon: `/toolIcons/tool_${tool.name}.svg`,
}));
setUserTools(tools);
};
const getPrompts = async () => {
const response = await userService.getPrompts(token);
if (!response.ok) {
throw new Error('Failed to fetch prompts');
}
const data = await response.json();
setPrompts(data);
};
getTools();
getPrompts();
}, [token]);
useEffect(() => {
if ((mode === 'edit' || mode === 'draft') && agentId) {
const getAgent = async () => {
const response = await userService.getAgent(agentId, token);
if (!response.ok) {
navigate('/agents');
throw new Error('Failed to fetch agent');
}
const data = await response.json();
if (data.source) setSelectedSourceIds(new Set([data.source]));
else if (data.retriever)
setSelectedSourceIds(new Set([data.retriever]));
if (data.tools) setSelectedToolIds(new Set(data.tools));
if (data.status === 'draft') setEffectiveMode('draft');
setAgent(data);
};
getAgent();
}
}, [agentId, mode, token]);
useEffect(() => {
const selectedSource = Array.from(selectedSourceIds).map((id) =>
sourceDocs?.find(
(source) =>
source.id === id || source.retriever === id || source.name === id,
),
);
if (selectedSource[0]?.model === embeddingsName) {
if (selectedSource[0] && 'id' in selectedSource[0]) {
setAgent((prev) => ({
...prev,
source: selectedSource[0]?.id || 'default',
retriever: '',
}));
} else
setAgent((prev) => ({
...prev,
source: '',
retriever: selectedSource[0]?.retriever || 'classic',
}));
}
}, [selectedSourceIds]);
useEffect(() => {
const selectedTool = Array.from(selectedToolIds).map((id) =>
userTools.find((tool) => tool.id === id),
);
setAgent((prev) => ({
...prev,
tools: selectedTool
.map((tool) => tool?.id)
.filter((id): id is string => typeof id === 'string'),
}));
}, [selectedToolIds]);
useEffect(() => {
if (isPublishable()) dispatch(setSelectedAgent(agent));
}, [agent, dispatch]);
return (
<div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={handleCancel}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray">
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white">
{modeConfig[effectiveMode].heading}
</h1>
<div className="flex flex-wrap items-center gap-1">
<button
className="mr-4 rounded-3xl py-2 text-sm font-medium text-purple-30 dark:bg-transparent dark:text-light-gray"
onClick={handleCancel}
>
Cancel
</button>
{modeConfig[effectiveMode].showDelete && agent.id && (
<button
className="group flex items-center gap-2 rounded-3xl border border-solid border-red-2000 px-5 py-2 text-sm font-medium text-red-2000 transition-colors hover:bg-red-2000 hover:text-white"
onClick={() => setDeleteConfirmation('ACTIVE')}
>
<span className="block h-4 w-4 bg-[url('/src/assets/red-trash.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/white-trash.svg')]" />
Delete
</button>
)}
{modeConfig[effectiveMode].showSaveDraft && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={handleSaveDraft}
>
Save Draft
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={() => navigate(`/agents/logs/${agent.id}`)}
>
Logs
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
<button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white"
onClick={() => setAgentDetails('ACTIVE')}
>
Access Details
</button>
)}
<button
disabled={!isPublishable()}
className={`${!isPublishable() && 'cursor-not-allowed opacity-30'} rounded-3xl bg-purple-30 px-5 py-2 text-sm font-medium text-white hover:bg-violets-are-blue`}
onClick={handlePublish}
>
Publish
</button>
</div>
</div>
<div className="mt-5 flex w-full grid-cols-5 flex-col gap-10 min-[1180px]:grid min-[1180px]:gap-5">
<div className="col-span-2 flex flex-col gap-5">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Meta</h2>
<input
className="mt-3 w-full rounded-3xl border border-silver bg-white px-5 py-3 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver"
type="text"
value={agent.name}
placeholder="Agent name"
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="mt-3 h-32 w-full rounded-3xl border border-silver bg-white px-5 py-4 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver"
placeholder="Describe your agent"
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
}
/>
</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>
<div className="mt-3">
<div className="flex flex-wrap items-center gap-1">
<button
ref={sourceAnchorButtonRef}
onClick={() => setIsSourcePopupOpen(!isSourcePopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver"
>
{selectedSourceIds.size > 0
? Array.from(selectedSourceIds)
.map(
(id) =>
sourceDocs?.find(
(source) =>
source.id === id ||
source.name === id ||
source.retriever === id,
)?.name,
)
.filter(Boolean)
.join(', ')
: 'Select source'}
</button>
<MultiSelectPopup
isOpen={isSourcePopupOpen}
onClose={() => setIsSourcePopupOpen(false)}
anchorRef={sourceAnchorButtonRef}
options={
sourceDocs?.map((doc: Doc) => ({
id: doc.id || doc.retriever || doc.name,
label: doc.name,
icon: SourceIcon,
})) || []
}
selectedIds={selectedSourceIds}
onSelectionChange={(newSelectedIds: Set<string | number>) => {
setSelectedSourceIds(newSelectedIds);
setIsSourcePopupOpen(false);
}}
title="Select Source"
searchPlaceholder="Search sources..."
noOptionsMessage="No source available"
singleSelect={true}
/>
</div>
<div className="mt-3">
<Dropdown
options={chunks}
selectedValue={agent.chunks ? agent.chunks : null}
onSelect={(value: string) =>
setAgent({ ...agent, chunks: value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Chunks per query"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Prompt</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<div className="min-w-20 flex-grow basis-full sm:basis-0">
<Dropdown
options={prompts.map((prompt) => ({
label: prompt.name,
value: prompt.id,
}))}
selectedValue={
agent.prompt_id
? prompts.filter(
(prompt) => prompt.id === agent.prompt_id,
)[0].name || null
: null
}
onSelect={(option: { label: string; value: string }) =>
setAgent({ ...agent, prompt_id: option.value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Select a prompt"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
<button
className="w-20 flex-shrink-0 basis-full rounded-3xl border-2 border-solid border-violets-are-blue px-5 py-[11px] text-sm text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white sm:basis-auto"
onClick={() => setAddPromptModal('ACTIVE')}
>
Add
</button>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Tools</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<button
ref={toolAnchorButtonRef}
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver"
>
{selectedToolIds.size > 0
? Array.from(selectedToolIds)
.map(
(id) => userTools.find((tool) => tool.id === id)?.label,
)
.filter(Boolean)
.join(', ')
: 'Select tools'}
</button>
<MultiSelectPopup
isOpen={isToolsPopupOpen}
onClose={() => setIsToolsPopupOpen(false)}
anchorRef={toolAnchorButtonRef}
options={userTools}
selectedIds={selectedToolIds}
onSelectionChange={(newSelectedIds: Set<string | number>) =>
setSelectedToolIds(newSelectedIds)
}
title="Select Tools"
searchPlaceholder="Search tools..."
noOptionsMessage="No tools available"
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Agent type</h2>
<div className="mt-3">
<Dropdown
options={agentTypes}
selectedValue={
agent.agent_type
? agentTypes.find((type) => type.value === agent.agent_type)
?.label || null
: null
}
onSelect={(option: { label: string; value: string }) =>
setAgent({ ...agent, agent_type: option.value })
}
size="w-full"
rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border"
darkBorderColor="[#7E7E7E]"
placeholder="Select type"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Preview</h2>
<AgentPreviewArea />
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
<AgentDetailsModal
agent={agent}
mode={effectiveMode}
modalState={agentDetails}
setModalState={setAgentDetails}
/>
<AddPromptModal
prompts={prompts}
setPrompts={setPrompts}
isOpen={addPromptModal}
onClose={() => setAddPromptModal('INACTIVE')}
onSelect={(name: string, id: string, type: string) => {
setAgent({ ...agent, prompt_id: id });
}}
/>
</div>
);
}
function AgentPreviewArea() {
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="h-full w-full rounded-[30px] border border-[#F6F6F6] bg-white dark:border-[#7E7E7E] dark:bg-[#222327] max-[1180px]:h-[48rem]">
{selectedAgent?.id ? (
<div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]">
<AgentPreview />
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<p className="text-xs text-[#18181B] dark:text-[#949494]">
Published agents can be previewd here
</p>
</div>
)}
</div>
);
}
function AddPromptModal({
prompts,
setPrompts,
isOpen,
onClose,
onSelect,
}: {
prompts: Prompt[];
setPrompts?: React.Dispatch<React.SetStateAction<Prompt[]>>;
isOpen: ActiveState;
onClose: () => void;
onSelect?: (name: string, id: string, type: string) => void;
}) {
const token = useSelector(selectToken);
const [newPromptName, setNewPromptName] = useState('');
const [newPromptContent, setNewPromptContent] = useState('');
const handleAddPrompt = async () => {
try {
const response = await userService.createPrompt(
{
name: newPromptName,
content: newPromptContent,
},
token,
);
if (!response.ok) {
throw new Error('Failed to add prompt');
}
const newPrompt = await response.json();
if (setPrompts) {
setPrompts([
...prompts,
{ name: newPromptName, id: newPrompt.id, type: 'private' },
]);
}
onClose();
setNewPromptName('');
setNewPromptContent('');
onSelect?.(newPromptName, newPrompt.id, newPromptContent);
} catch (error) {
console.error(error);
}
};
return (
<PromptsModal
modalState={isOpen}
setModalState={onClose}
type="ADD"
existingPrompts={prompts}
newPromptName={newPromptName}
setNewPromptName={setNewPromptName}
newPromptContent={newPromptContent}
setNewPromptContent={setNewPromptContent}
editPromptName={''}
setEditPromptName={() => {}}
editPromptContent={''}
setEditPromptContent={() => {}}
currentPromptEdit={{ id: '', name: '', type: '' }}
handleAddPrompt={handleAddPrompt}
/>
);
}

View File

@@ -0,0 +1,275 @@
import React, { SyntheticEvent, useEffect, useRef, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Route, Routes, useNavigate } from 'react-router-dom';
import userService from '../api/services/userService';
import Copy from '../assets/copy-linear.svg';
import Edit from '../assets/edit.svg';
import Monitoring from '../assets/monitoring.svg';
import Trash from '../assets/red-trash.svg';
import Robot from '../assets/robot.svg';
import ThreeDots from '../assets/three-dots.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { selectToken, setSelectedAgent } from '../preferences/preferenceSlice';
import AgentLogs from './AgentLogs';
import NewAgent from './NewAgent';
import { Agent } from './types';
import Spinner from '../components/Spinner';
export default function Agents() {
return (
<Routes>
<Route path="/" element={<AgentsList />} />
<Route path="/new" element={<NewAgent mode="new" />} />
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="/logs/:agentId" element={<AgentLogs />} />
</Routes>
);
}
function AgentsList() {
const navigate = useNavigate();
const token = useSelector(selectToken);
const [userAgents, setUserAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const getAgents = async () => {
try {
setLoading(true);
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents');
const data = await response.json();
setUserAgents(data);
setLoading(false);
} catch (error) {
console.error('Error:', error);
setLoading(false);
}
};
useEffect(() => {
getAgents();
}, [token]);
return (
<div className="p-4 md:p-12">
<h1 className="mb-0 text-[40px] font-bold text-[#212121] dark:text-[#E0E0E0]">
Agents
</h1>
<p className="mt-5 text-[15px] text-[#71717A] dark:text-[#949494]">
Discover and create custom versions of DocsGPT that combine
instructions, extra knowledge, and any combination of skills.
</p>
{/* <div className="mt-6">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Premade by DocsGPT
</h2>
<div className="mt-4 flex w-full flex-wrap gap-4">
{Array.from({ length: 5 }, (_, index) => (
<div
key={index}
className="relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 dark:bg-[#383838]"
>
<button onClick={() => {}} className="absolute right-4 top-4">
<img
src={Copy}
alt={'use-agent'}
className="h-[19px] w-[19px]"
/>
</button>
<div className="w-full">
<div className="flex w-full items-center px-1">
<img
src={Robot}
alt="agent-logo"
className="h-7 w-7 rounded-full"
/>
</div>
<div className="mt-2">
<p
title={''}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-raisin-black-light dark:text-bright-gray"
>
{}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-old-silver dark:text-sonic-silver-light">
{}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4"></div>
</div>
))}
</div>
</div> */}
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Created by You
</h2>
<button
className="rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
</div>
<div className="flex w-full flex-wrap gap-4">
{loading ? (
<div className="flex h-72 w-full items-center justify-center">
<Spinner />
</div>
) : userAgents.length > 0 ? (
userAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
setUserAgents={setUserAgents}
/>
))
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>You dont have any created agents yet </p>
<button
className="ml-2 rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
</div>
)}
</div>
</div>
</div>
);
}
function AgentCard({
agent,
setUserAgents,
}: {
agent: Agent;
setUserAgents: React.Dispatch<React.SetStateAction<Agent[]>>;
}) {
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
const menuOptions: MenuOption[] = [
{
icon: Monitoring,
label: 'Logs',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/logs/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/edit/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Trash,
label: 'Delete',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
variant: 'danger',
iconWidth: 12,
iconHeight: 12,
},
];
const handleClick = () => {
dispatch(setSelectedAgent(agent));
navigate(`/`);
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
const data = await response.json();
setUserAgents((prevAgents) =>
prevAgents.filter((prevAgent) => prevAgent.id !== data.id),
);
};
return (
<div
className="relative flex h-44 w-48 cursor-pointer flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 dark:bg-[#383838]"
onClick={(e) => handleClick()}
>
<div
ref={menuRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(true);
}}
className="absolute right-4 top-4 z-50 cursor-pointer"
>
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="top-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}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-[#020617] dark:text-[#E0E0E0]"
>
{agent.name}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B] dark:text-sonic-silver-light">
{agent.description}
</p>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
export type Agent = {
id?: string;
name: string;
description: string;
image: string;
source: string;
chunks: string;
retriever: string;
prompt_id: string;
tools: string[];
agent_type: string;
status: string;
key?: string;
created_at?: string;
updated_at?: string;
last_used_at?: string;
};

View File

@@ -8,6 +8,11 @@ const endpoints = {
API_KEYS: '/api/get_api_keys',
CREATE_API_KEY: '/api/create_api_key',
DELETE_API_KEY: '/api/delete_api_key',
AGENT: (id: string) => `/api/get_agent?id=${id}`,
AGENTS: '/api/get_agents',
CREATE_AGENT: '/api/create_agent',
UPDATE_AGENT: (agent_id: string) => `/api/update_agent/${agent_id}`,
DELETE_AGENT: (id: string) => `/api/delete_agent?id=${id}`,
PROMPTS: '/api/get_prompts',
CREATE_PROMPT: '/api/create_prompt',
DELETE_PROMPT: '/api/delete_prompt',

View File

@@ -17,6 +17,20 @@ const userService = {
apiClient.post(endpoints.USER.CREATE_API_KEY, data, token),
deleteAPIKey: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DELETE_API_KEY, data, token),
getAgent: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT(id), token),
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),
updateAgent: (
agent_id: string,
data: any,
token: string | null,
): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_AGENT(agent_id), data, token),
deleteAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.DELETE_AGENT(id), token),
getPrompts: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.PROMPTS, token),
createPrompt: (data: any, token: string | null): Promise<any> =>

View File

@@ -0,0 +1,4 @@
<svg width="17" height="19" viewBox="0 0 17 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.75 8.70825C3.75 6.46942 3.75 5.34921 4.44588 4.65413C5.14096 3.95825 6.26117 3.95825 8.5 3.95825H10.875C13.1138 3.95825 14.234 3.95825 14.9291 4.65413C15.625 5.34921 15.625 6.46942 15.625 8.70825V12.6666C15.625 14.9054 15.625 16.0256 14.9291 16.7207C14.234 17.4166 13.1138 17.4166 10.875 17.4166H8.5C6.26117 17.4166 5.14096 17.4166 4.44588 16.7207C3.75 16.0256 3.75 14.9054 3.75 12.6666V8.70825Z" stroke="#B9B9B9" stroke-width="1.5"/>
<path d="M3.75 15.0416C3.12011 15.0416 2.51602 14.7914 2.07062 14.346C1.62522 13.9006 1.375 13.2965 1.375 12.6666V7.91658C1.375 4.93121 1.375 3.43813 2.30283 2.51109C3.23067 1.58404 4.72296 1.58325 7.70833 1.58325H10.875C11.5049 1.58325 12.109 1.83347 12.5544 2.27887C12.9998 2.72427 13.25 3.32836 13.25 3.95825" stroke="#B9B9B9" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,3 @@
<svg width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.85 16.087C4.84924 16.2663 4.91145 16.4239 5.03693 16.5494C5.16233 16.6747 5.31971 16.7373 5.499 16.7373L5.4997 16.7373C5.67889 16.7364 5.83627 16.6743 5.9617 16.5497C6.08758 16.4247 6.15 16.267 6.15 16.0873V10.7993C6.15 10.619 6.08842 10.4607 5.96269 10.3358C5.83772 10.2117 5.68058 10.1501 5.5017 10.1493C5.32173 10.1484 5.16366 10.2105 5.03793 10.3362C4.9124 10.4618 4.85 10.6195 4.85 10.7993V16.087ZM4.85 16.087C4.85 16.0868 4.85 16.0867 4.85 16.0866L5 16.0873M4.85 16.087V16.0873H5M5 16.0873C4.99933 16.2286 5.047 16.3473 5.143 16.4433C5.239 16.5393 5.35767 16.5873 5.499 16.5873L5 16.0873ZM17.4984 0.850356C17.6844 0.844257 17.8447 0.916616 17.9702 1.05647C18.0903 1.18276 18.1491 1.33782 18.1439 1.51269C18.1389 1.6837 18.0787 1.83386 17.9605 1.95296L17.9601 1.95335L12.7431 7.17035L12.7421 7.17131C12.5779 7.33262 12.3846 7.45674 12.1641 7.54381C11.9451 7.63023 11.7235 7.67428 11.5 7.67428C11.277 7.67428 11.0585 7.63017 10.8453 7.54316M17.4984 0.850356L10.8453 7.54316M17.4984 0.850356C17.3204 0.855502 17.1648 0.921665 17.0399 1.04721L17.4984 0.850356ZM10.8453 7.54316C10.632 7.45637 10.4379 7.33351 10.2633 7.17548L10.2578 7.1705L10.258 7.17037L7.83596 4.74937L7.83593 4.74935C7.75166 4.66508 7.6439 4.62029 7.5 4.62029C7.3561 4.62029 7.24834 4.66508 7.16407 4.74935L1.96007 9.95335C1.83516 10.0783 1.67973 10.1443 1.50197 10.1502L1.50128 10.1502C1.31531 10.1555 1.15551 10.0825 1.03084 9.94215C0.910023 9.81657 0.850921 9.66177 0.856065 9.48688C0.861095 9.31586 0.921352 9.1658 1.03993 9.04722L6.25793 3.82922L6.25859 3.82856C6.43436 3.65497 6.63139 3.52612 6.84951 3.44477C7.06174 3.36537 7.27883 3.32528 7.5 3.32528C7.72114 3.32528 7.94133 3.36536 8.15989 3.44418C8.38508 3.5254 8.58031 3.65486 8.74407 3.83122L11.164 6.2502L11.1641 6.25022C11.2483 6.33449 11.3561 6.37928 11.5 6.37928C11.6439 6.37928 11.7517 6.33449 11.8359 6.25022L11.8359 6.25021L17.0396 1.04758L10.8453 7.54316ZM1.5 16.7373L1.5007 16.7373C1.68022 16.7364 1.83649 16.6741 1.9617 16.5497C2.08758 16.4247 2.15 16.267 2.15 16.0873V15.2993C2.15 15.119 2.08842 14.9607 1.96269 14.8358C1.83772 14.7117 1.68058 14.6501 1.5017 14.6493C1.32173 14.6484 1.16366 14.7105 1.03793 14.8362C0.912396 14.9618 0.85 15.1195 0.85 15.2993V16.0873C0.85 16.2663 0.91197 16.4235 1.03657 16.549C1.16172 16.675 1.31979 16.7373 1.5 16.7373ZM9.499 16.7373L9.4997 16.7373C9.67889 16.7364 9.83627 16.6743 9.9617 16.5497C10.0876 16.4247 10.15 16.267 10.15 16.0873V12.7993C10.15 12.619 10.0884 12.4607 9.96269 12.3358C9.83772 12.2117 9.68058 12.1501 9.5017 12.1493C9.32173 12.1484 9.16366 12.2105 9.03793 12.3362C8.9124 12.4618 8.85 12.6195 8.85 12.7993V16.0873C8.85 16.2663 8.91197 16.4235 9.03656 16.549C9.16158 16.6749 9.31924 16.7373 9.499 16.7373ZM13.499 16.7373L13.4997 16.7373C13.6789 16.7364 13.8363 16.6743 13.9617 16.5497C14.0876 16.4247 14.15 16.267 14.15 16.0873V11.2993C14.15 11.119 14.0884 10.9607 13.9627 10.8358C13.8377 10.7117 13.6806 10.6501 13.5017 10.6493C13.3217 10.6484 13.1637 10.7105 13.0379 10.8362C12.9124 10.9618 12.85 11.1195 12.85 11.2993L12.85 16.0866L13 16.0873H12.85V16.0868C12.8492 16.2662 12.9114 16.4238 13.0369 16.5494C13.1623 16.6747 13.3197 16.7373 13.499 16.7373ZM17.499 16.7373L17.4997 16.7373C17.6789 16.7364 17.8363 16.6743 17.9617 16.5497C18.0876 16.4247 18.15 16.267 18.15 16.0873V7.29928C18.15 7.11895 18.0884 6.9607 17.9627 6.83585C17.8377 6.71175 17.6806 6.65013 17.5017 6.64929C17.3217 6.64844 17.1637 6.7105 17.0379 6.83622C16.9124 6.96176 16.85 7.11954 16.85 7.29928L16.85 16.0866L17 16.0873H16.85V16.0868C16.8492 16.2662 16.9114 16.4238 17.0369 16.5494C17.1623 16.6747 17.3197 16.7373 17.499 16.7373Z" fill="#747474" stroke="#747474" stroke-width="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,3 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.66427 17.7747C6.66427 18.2167 6.84488 18.6406 7.16637 18.9532C7.48787 19.2658 7.9239 19.4413 8.37856 19.4413H15.2357C15.6904 19.4413 16.1264 19.2658 16.4479 18.9532C16.7694 18.6406 16.95 18.2167 16.95 17.7747V7.77468H6.66427V17.7747ZM8.37856 9.44135H15.2357V17.7747H8.37856V9.44135ZM14.8071 5.27468L13.95 4.44135H9.66427L8.80713 5.27468H5.80713V6.94135H17.8071V5.27468H14.8071Z" fill="#D30000"/>
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66574 13.7747C1.66574 14.2168 1.84635 14.6407 2.16784 14.9533C2.48933 15.2658 2.92537 15.4414 3.38002 15.4414H10.2372C10.6918 15.4414 11.1279 15.2658 11.4493 14.9533C11.7708 14.6407 11.9515 14.2168 11.9515 13.7747V3.77474H1.66574V13.7747ZM3.38002 5.44141H10.2372V13.7747H3.38002V5.44141ZM9.80859 1.27474L8.95145 0.441406H4.66574L3.80859 1.27474H0.808594V2.94141H12.8086V1.27474H9.80859Z" fill="#f44336"/>
</svg>

Before

Width:  |  Height:  |  Size: 511 B

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,22 @@
<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"/>
<defs>
<linearGradient id="paint0_linear_8958_15228" x1="10.688" y1="1" x2="10.688" y2="16.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">
<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">
<stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -0,0 +1,12 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8836_14948)">
<path d="M25.7094 18.5522C22.8007 20.7575 20.0485 23.1618 17.4725 25.7479C5.33468 37.8856 -0.811037 51.4105 3.7385 55.9643C8.28803 60.5138 21.8171 54.3638 33.9507 42.2303C36.5369 39.6529 38.9412 36.8992 41.1463 33.9891" stroke="#3F4147" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.1421 33.989C48.2993 43.4892 51.2142 52.4261 47.6804 55.9599C43.1266 60.5137 29.6018 54.3637 17.464 42.2302C5.33477 30.0881 -0.810942 16.5676 3.73859 12.0138C7.27238 8.48424 16.2093 11.3992 25.7095 18.5521" stroke="#3F4147" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23.5781 33.9891C23.5781 34.5551 23.8029 35.0978 24.2031 35.498C24.6033 35.8982 25.1461 36.123 25.7121 36.123C26.278 36.123 26.8208 35.8982 27.221 35.498C27.6212 35.0978 27.846 34.5551 27.846 33.9891C27.846 33.4232 27.6212 32.8804 27.221 32.4802C26.8208 32.08 26.278 31.8552 25.7121 31.8552C25.1461 31.8552 24.6033 32.08 24.2031 32.4802C23.8029 32.8804 23.5781 33.4232 23.5781 33.9891ZM34.2904 15.4069C32.9461 15.1721 32.9461 13.2473 34.2904 13.0169C36.6655 12.6055 38.864 11.4952 40.6049 9.82805C42.3458 8.16089 43.5501 6.01251 44.0638 3.65746L44.1407 3.28616C44.4309 1.96312 46.3173 1.95459 46.616 3.27335L46.7184 3.70441C47.2541 6.04779 48.4698 8.18083 50.2131 9.83599C51.9563 11.4912 54.1495 12.5947 56.5174 13.0083C57.8661 13.2431 57.8661 15.1807 56.5174 15.4111C54.1495 15.8247 51.9563 16.9283 50.2131 18.5835C48.4698 20.2386 47.2541 22.3717 46.7184 24.7151L46.616 25.1461C46.3173 26.4692 44.4309 26.4606 44.1407 25.1376L44.0553 24.7663C43.5415 22.4112 42.3372 20.2629 40.5963 18.5957C38.8554 16.9285 36.657 15.8183 34.2819 15.4069H34.2904Z" stroke="#5F6167" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_8836_14948">
<rect width="59.75" height="59.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,12 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8612_14680)">
<path d="M25.7094 18.5525C22.8007 20.7577 20.0485 23.162 17.4725 25.7481C5.33468 37.8859 -0.811037 51.4107 3.7385 55.9645C8.28803 60.5141 21.8171 54.3641 33.9507 42.2306C36.5369 39.6532 38.9412 36.8995 41.1463 33.9893" stroke="#DCDCDC" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M41.1421 33.9891C48.2993 43.4893 51.2142 52.4262 47.6804 55.96C43.1266 60.5138 29.6018 54.3638 17.464 42.2303C5.33477 30.0883 -0.810942 16.5677 3.73859 12.0139C7.27238 8.48436 16.2093 11.3993 25.7095 18.5522" stroke="#DCDCDC" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23.5781 33.9891C23.5781 34.5551 23.8029 35.0978 24.2031 35.498C24.6033 35.8982 25.1461 36.123 25.7121 36.123C26.278 36.123 26.8208 35.8982 27.221 35.498C27.6212 35.0978 27.846 34.5551 27.846 33.9891C27.846 33.4232 27.6212 32.8804 27.221 32.4802C26.8208 32.08 26.278 31.8552 25.7121 31.8552C25.1461 31.8552 24.6033 32.08 24.2031 32.4802C23.8029 32.8804 23.5781 33.4232 23.5781 33.9891ZM34.2904 15.4069C32.9461 15.1721 32.9461 13.2473 34.2904 13.0169C36.6655 12.6055 38.864 11.4952 40.6049 9.82805C42.3458 8.16089 43.5501 6.01251 44.0638 3.65746L44.1407 3.28616C44.4309 1.96312 46.3173 1.95459 46.616 3.27335L46.7184 3.70441C47.2541 6.04779 48.4698 8.18083 50.2131 9.83599C51.9563 11.4912 54.1495 12.5947 56.5174 13.0083C57.8661 13.2431 57.8661 15.1807 56.5174 15.4111C54.1495 15.8247 51.9563 16.9283 50.2131 18.5835C48.4698 20.2386 47.2541 22.3717 46.7184 24.7151L46.616 25.1461C46.3173 26.4692 44.4309 26.4606 44.1407 25.1376L44.0553 24.7663C43.5415 22.4112 42.3372 20.2629 40.5963 18.5957C38.8554 16.9285 36.657 15.8183 34.2819 15.4069H34.2904Z" stroke="#999999" stroke-width="4.26786" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_8612_14680">
<rect width="59.75" height="59.75" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="19" height="19" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.04071 11.3081H1.51004C1.33769 11.3088 1.1726 11.3776 1.05073 11.4995C0.928855 11.6213 0.860077 11.7864 0.859375 11.9588V17.4921C0.859375 17.8521 1.15137 18.1428 1.51004 18.1428H7.04337C7.40337 18.1428 7.69404 17.8508 7.69404 17.4921V11.9588C7.69334 11.7864 7.62456 11.6213 7.50269 11.4995C7.38082 11.3776 7.21573 11.3088 7.04337 11.3081H7.04071ZM17.1594 11.3081H11.626C11.4537 11.3088 11.2886 11.3776 11.1667 11.4995C11.0449 11.6213 10.9761 11.7864 10.9754 11.9588V17.4921C10.9754 17.8521 11.266 18.1428 11.626 18.1428H17.1594C17.5194 18.1428 17.81 17.8508 17.81 17.4921V11.9588C17.8093 11.7864 17.7406 11.6213 17.6187 11.4995C17.4968 11.3776 17.3317 11.3088 17.1594 11.3081ZM7.04071 1.19079H1.51004C1.33769 1.19149 1.1726 1.26027 1.05073 1.38214C0.928855 1.50401 0.860077 1.6691 0.859375 1.84145V7.37479C0.859375 7.73479 1.15137 8.02545 1.51004 8.02545H7.04337C7.40337 8.02545 7.69404 7.73345 7.69404 7.37479V1.84012C7.69334 1.66777 7.62456 1.50268 7.50269 1.3808C7.38082 1.25893 7.21573 1.19016 7.04337 1.18945L7.04071 1.19079ZM10.862 5.11212C10.4607 5.04279 10.4607 4.46812 10.862 4.39745C11.5717 4.27403 12.2286 3.94205 12.7489 3.44385C13.2692 2.94565 13.6293 2.3038 13.7834 1.60012L13.8074 1.48945C13.894 1.09345 14.458 1.08945 14.5487 1.48545L14.578 1.61479C14.7379 2.31545 15.1012 2.95326 15.6224 3.4481C16.1436 3.94294 16.7994 4.27276 17.5074 4.39612C17.91 4.46545 17.91 5.04412 17.5074 5.11479C16.7994 5.23815 16.1436 5.56796 15.6224 6.0628C15.1012 6.55765 14.7379 7.19545 14.578 7.89612L14.5487 8.02412C14.458 8.42012 13.8954 8.41745 13.8074 8.02145L13.7834 7.91079C13.6295 7.20686 13.2695 6.56472 12.7492 6.06627C12.2289 5.56781 11.5719 5.23564 10.862 5.11212Z" stroke="#747474" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.66574 13.7747C1.66574 14.2168 1.84635 14.6407 2.16784 14.9533C2.48933 15.2658 2.92537 15.4414 3.38002 15.4414H10.2372C10.6918 15.4414 11.1279 15.2658 11.4493 14.9533C11.7708 14.6407 11.9515 14.2168 11.9515 13.7747V3.77474H1.66574V13.7747ZM3.38002 5.44141H10.2372V13.7747H3.38002V5.44141ZM9.80859 1.27474L8.95145 0.441406H4.66574L3.80859 1.27474H0.808594V2.94141H12.8086V1.27474H9.80859Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -90,7 +90,7 @@ export default function ContextMenu({
onClick={(e) => e.stopPropagation()}
>
<div
className="flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion"
className="flex w-32 flex-col rounded-xl bg-lotion text-sm shadow-xl dark:bg-charleston-green-2 md:w-36"
style={{ minWidth: '144px' }}
>
{options.map((option, index) => (
@@ -102,19 +102,14 @@ export default function ContextMenu({
option.onClick(event);
setIsOpen(false);
}}
className={`
flex justify-start items-center gap-4 p-3
transition-colors duration-200 ease-in-out
${index === 0 ? 'rounded-t-xl' : ''}
${index === options.length - 1 ? 'rounded-b-xl' : ''}
${
className={`flex items-center justify-start gap-4 p-3 transition-colors duration-200 ease-in-out ${index === 0 ? 'rounded-t-xl' : ''} ${index === options.length - 1 ? 'rounded-b-xl' : ''} ${
option.variant === 'danger'
? 'dark:text-red-2000 dark:hover:bg-charcoal-grey text-rosso-corsa hover:bg-bright-gray'
: 'dark:text-bright-gray dark:hover:bg-charcoal-grey text-eerie-black hover:bg-bright-gray'
}
`}
? 'text-rosso-corsa hover:bg-bright-gray dark:text-red-2000 dark:hover:bg-charcoal-grey'
: 'text-eerie-black hover:bg-bright-gray dark:text-bright-gray dark:hover:bg-charcoal-grey'
} `}
>
{option.icon && (
<div className="flex w-4 justify-center">
<img
width={option.iconWidth || 16}
height={option.iconHeight || 16}
@@ -122,6 +117,7 @@ export default function ContextMenu({
alt={option.label}
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
/>
</div>
)}
<span>{option.label}</span>
</button>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import Arrow2 from '../assets/dropdown-arrow.svg';
import Edit from '../assets/edit.svg';
import Trash from '../assets/trash.svg';
@@ -9,6 +10,10 @@ function Dropdown({
onSelect,
size = 'w-32',
rounded = 'xl',
buttonBackgroundColor = 'white',
buttonDarkBackgroundColor = 'transparent',
optionsBackgroundColor = 'white',
optionsDarkBackgroundColor = 'dark-charcoal',
border = 'border-2',
borderColor = 'silver',
darkBorderColor = 'dim-gray',
@@ -17,6 +22,8 @@ function Dropdown({
showDelete,
onDelete,
placeholder,
placeholderTextColor = 'gray-500',
darkPlaceholderTextColor = 'gray-400',
contentSize = 'text-base',
}: {
options:
@@ -37,6 +44,10 @@ function Dropdown({
| ((value: { value: number; description: string }) => void);
size?: string;
rounded?: 'xl' | '3xl';
buttonBackgroundColor?: string;
buttonDarkBackgroundColor?: string;
optionsBackgroundColor?: string;
optionsDarkBackgroundColor?: string;
border?: 'border' | 'border-2';
borderColor?: string;
darkBorderColor?: string;
@@ -45,6 +56,8 @@ function Dropdown({
showDelete?: boolean;
onDelete?: (value: string) => void;
placeholder?: string;
placeholderTextColor?: string;
darkPlaceholderTextColor?: string;
contentSize?: string;
}) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
@@ -71,7 +84,7 @@ function Dropdown({
<div
className={[
typeof selectedValue === 'string'
? 'relative mt-2'
? 'relative'
: 'relative align-middle',
size,
].join(' ')}
@@ -79,7 +92,7 @@ function Dropdown({
>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full cursor-pointer items-center justify-between ${border} border-${borderColor} bg-white px-5 py-3 dark:border-${darkBorderColor} dark:bg-transparent ${
className={`flex w-full cursor-pointer items-center justify-between ${border} border-${borderColor} bg-${buttonBackgroundColor} px-5 py-3 dark:border-${darkBorderColor} dark:bg-${buttonDarkBackgroundColor} ${
isOpen ? `${borderTopRadius}` : `${borderRadius}`
}`}
>
@@ -89,8 +102,9 @@ function Dropdown({
</span>
) : (
<span
className={`truncate dark:text-bright-gray ${
!selectedValue && 'text-silver dark:text-gray-400'
className={`truncate ${selectedValue && `dark:text-bright-gray`} ${
!selectedValue &&
`text-${placeholderTextColor} dark:text-${darkPlaceholderTextColor}`
} ${contentSize}`}
>
{selectedValue && 'label' in selectedValue
@@ -116,7 +130,7 @@ function Dropdown({
</button>
{isOpen && (
<div
className={`absolute left-0 right-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} border-${borderColor} bg-white shadow-lg dark:border-${darkBorderColor} dark:bg-dark-charcoal`}
className={`absolute left-0 right-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} border-${borderColor} bg-${optionsBackgroundColor} shadow-lg dark:border-${darkBorderColor} dark:bg-${optionsDarkBackgroundColor}`}
>
{options.map((option: any, index) => (
<div

View File

@@ -13,6 +13,7 @@ const Input = ({
className = '',
colorVariant = 'silver',
borderVariant = 'thick',
textSize = 'medium',
children,
labelBgClassName = 'bg-white dark:bg-raisin-black',
onChange,
@@ -28,6 +29,10 @@ const Input = ({
thin: 'border',
thick: 'border-2',
};
const textSizeStyles = {
small: 'text-sm',
medium: 'text-base',
};
const inputRef = useRef<HTMLInputElement>(null);
@@ -35,15 +40,7 @@ const Input = ({
<div className={`relative ${className}`}>
<input
ref={inputRef}
className={`peer h-[42px] w-full rounded-full px-3 py-1
bg-transparent outline-none
text-jet dark:text-bright-gray
placeholder-transparent
${colorStyles[colorVariant]}
${borderStyles[borderVariant]}
[&:-webkit-autofill]:bg-transparent
[&:-webkit-autofill]:appearance-none
[&:-webkit-autofill_selected]:bg-transparent`}
className={`peer h-[42px] w-full rounded-full bg-transparent px-3 py-1 text-jet placeholder-transparent outline-none dark:text-bright-gray ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`}
type={type}
id={id}
name={name}
@@ -61,15 +58,11 @@ const Input = ({
{placeholder && (
<label
htmlFor={id}
className={`absolute left-3 -top-2.5 px-2 text-xs transition-all
peer-placeholder-shown:top-2.5 peer-placeholder-shown:left-3 peer-placeholder-shown:text-base
peer-placeholder-shown:text-gray-4000 peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs
peer-focus:text-gray-4000 dark:text-silver dark:peer-placeholder-shown:text-gray-400
cursor-none pointer-events-none ${labelBgClassName}`}
className={`absolute -top-2.5 left-3 px-2 ${textSizeStyles[textSize]} transition-all peer-placeholder-shown:left-3 peer-placeholder-shown:top-2.5 peer-placeholder-shown:${textSizeStyles[textSize]} pointer-events-none cursor-none peer-placeholder-shown:text-gray-4000 peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs peer-focus:text-gray-4000 dark:text-silver dark:peer-placeholder-shown:text-gray-400 ${labelBgClassName}`}
>
{placeholder}
{required && (
<span className="text-[#D30000] dark:text-[#D42626] ml-0.5">*</span>
<span className="ml-0.5 text-[#D30000] dark:text-[#D42626]">*</span>
)}
</label>
)}

View File

@@ -1,43 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import { useSelector, useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import { useDispatch, useSelector } from 'react-redux';
import endpoints from '../api/endpoints';
import userService from '../api/services/userService';
import AlertIcon from '../assets/alert.svg';
import ClipIcon from '../assets/clip.svg';
import ExitIcon from '../assets/exit.svg';
import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg';
import ToolIcon from '../assets/tool.svg';
import SpinnerDark from '../assets/spinner-dark.svg';
import Spinner from '../assets/spinner.svg';
import ToolIcon from '../assets/tool.svg';
import {
addAttachment,
removeAttachment,
selectAttachments,
updateAttachment,
} from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import { ActiveState } from '../models/misc';
import {
selectSelectedDocs,
selectToken,
} from '../preferences/preferenceSlice';
import Upload from '../upload/Upload';
import { getOS, isTouchDevice } from '../utils/browserUtils';
import SourcesPopup from './SourcesPopup';
import ToolsPopup from './ToolsPopup';
import { selectSelectedDocs, selectToken } from '../preferences/preferenceSlice';
import { ActiveState } from '../models/misc';
import Upload from '../upload/Upload';
import ClipIcon from '../assets/clip.svg';
import { setAttachments } from '../conversation/conversationSlice';
interface MessageInputProps {
type MessageInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: () => void;
loading: boolean;
}
showSourceButton?: boolean;
showToolButton?: boolean;
};
interface UploadState {
type UploadState = {
taskId: string;
fileName: string;
progress: number;
attachment_id?: string;
token_count?: number;
status: 'uploading' | 'processing' | 'completed' | 'failed';
}
};
export default function MessageInput({
value,
onChange,
onSubmit,
loading,
showSourceButton = true,
showToolButton = true,
}: MessageInputProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
@@ -46,14 +62,37 @@ export default function MessageInput({
const toolButtonRef = useRef<HTMLButtonElement>(null);
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] = useState<ActiveState>('INACTIVE');
const [uploads, setUploads] = useState<UploadState[]>([]);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const dispatch = useDispatch();
const browserOS = getOS();
const isTouch = isTouchDevice();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') &&
event.ctrlKey &&
event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
setIsSourcesPopupOpen(!isSourcesPopupOpen);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [browserOS]);
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
@@ -64,56 +103,59 @@ export default function MessageInput({
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
const uploadState: UploadState = {
taskId: '',
const newAttachment = {
fileName: file.name,
progress: 0,
status: 'uploading'
status: 'uploading' as const,
taskId: '',
};
setUploads(prev => [...prev, uploadState]);
const uploadIndex = uploads.length;
dispatch(addAttachment(newAttachment));
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, progress }
: upload
));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { progress },
}),
);
}
});
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
console.log('File uploaded successfully:', response);
if (response.task_id) {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, taskId: response.task_id, status: 'processing' }
: upload
));
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
} else {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Error uploading file:', xhr.responseText);
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
}
};
xhr.onerror = () => {
setUploads(prev => prev.map((upload, index) =>
index === uploadIndex
? { ...upload, status: 'failed' }
: upload
));
console.error('Network error during file upload');
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
@@ -123,64 +165,63 @@ export default function MessageInput({
};
useEffect(() => {
let timeoutIds: number[] = [];
const checkTaskStatus = () => {
const processingUploads = uploads.filter(upload =>
upload.status === 'processing' && upload.taskId
const processingAttachments = attachments.filter(
(att) => att.status === 'processing' && att.taskId,
);
processingUploads.forEach(upload => {
processingAttachments.forEach((attachment) => {
userService
.getTaskStatus(upload.taskId, null)
.getTaskStatus(attachment.taskId!, null)
.then((data) => data.json())
.then((data) => {
console.log('Task status:', data);
setUploads(prev => prev.map(u => {
if (u.taskId !== upload.taskId) return u;
if (data.status === 'SUCCESS') {
return {
...u,
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
attachment_id: data.result?.attachment_id,
token_count: data.result?.token_count
};
id: data.result?.attachment_id,
token_count: data.result?.token_count,
},
}),
);
} else if (data.status === 'FAILURE') {
return { ...u, status: 'failed' };
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
} else if (data.status === 'PROGRESS' && data.result?.current) {
return { ...u, progress: data.result.current };
}
return u;
}));
if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') {
const timeoutId = window.setTimeout(() => checkTaskStatus(), 2000);
timeoutIds.push(timeoutId);
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { progress: data.result.current },
}),
);
}
})
.catch((error) => {
console.error('Error checking task status:', error);
setUploads(prev => prev.map(u =>
u.taskId === upload.taskId
? { ...u, status: 'failed' }
: u
));
.catch(() => {
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
});
});
};
if (uploads.some(upload => upload.status === 'processing')) {
const timeoutId = window.setTimeout(checkTaskStatus, 2000);
timeoutIds.push(timeoutId);
const interval = setInterval(() => {
if (attachments.some((att) => att.status === 'processing')) {
checkTaskStatus();
}
}, 2000);
return () => {
timeoutIds.forEach(id => clearTimeout(id));
};
}, [uploads]);
return () => clearInterval(interval);
}, [attachments, dispatch]);
const handleInput = () => {
if (inputRef.current) {
@@ -213,41 +254,57 @@ export default function MessageInput({
console.log('Selected document:', doc);
};
const handleSubmit = () => {
const completedAttachments = uploads
.filter(upload => upload.status === 'completed' && upload.attachment_id)
.map(upload => ({
fileName: upload.fileName,
id: upload.attachment_id as string
}));
dispatch(setAttachments(completedAttachments));
onSubmit();
};
return (
<div className="flex flex-col w-full mx-2">
<div className="flex flex-col w-full rounded-[23px] border dark:border-grey border-dark-gray bg-lotion dark:bg-transparent relative">
<div className="flex flex-wrap gap-1.5 sm:gap-2 px-4 sm:px-6 pt-3 pb-0">
{uploads.map((upload, index) => (
<div className="mx-2 flex w-full flex-col">
<div className="relative flex w-full flex-col rounded-[23px] border border-dark-gray bg-lotion dark:border-grey dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-4 pb-0 pt-3 sm:gap-2 sm:px-6">
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe bg-white dark:bg-[#1F2028] text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray"
className={`group relative flex items-center rounded-[32px] border border-[#AAAAAA] bg-white px-2 py-1 text-[12px] text-[#5D5D5D] dark:border-purple-taupe dark:bg-[#1F2028] dark:text-bright-gray sm:px-3 sm:py-1.5 sm:text-[14px] ${
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`}
title={attachment.fileName}
>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{upload.fileName}</span>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{attachment.fileName}
</span>
{upload.status === 'completed' && (
<span className="ml-2 text-green-500"></span>
{attachment.status === 'completed' && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white p-1 opacity-0 transition-opacity hover:bg-white/95 focus:opacity-100 group-hover:opacity-100 dark:bg-[#1F2028] dark:hover:bg-[#1F2028]/95"
onClick={() => {
if (attachment.id) {
dispatch(removeAttachment(attachment.id));
}
}}
aria-label="Remove attachment"
>
<img
src={ExitIcon}
alt="Remove"
className="h-2.5 w-2.5 filter dark:invert"
/>
</button>
)}
{upload.status === 'failed' && (
<span className="ml-2 text-red-500"></span>
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Upload failed"
className="ml-2 h-3.5 w-3.5"
title="Upload failed"
/>
)}
{(upload.status === 'uploading' || upload.status === 'processing') && (
<div className="ml-2 w-4 h-4 relative">
<svg className="w-4 h-4" viewBox="0 0 24 24">
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="relative ml-2 h-4 w-4">
<svg className="h-4 w-4" viewBox="0 0 24 24">
{/* Background circle */}
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
@@ -266,7 +323,7 @@ export default function MessageInput({
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={62.83 - (upload.progress / 100) * 62.83}
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
transform="rotate(-90 12 12)"
/>
</svg>
@@ -287,41 +344,67 @@ export default function MessageInput({
onChange={onChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion dark:bg-transparent py-3 sm:py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-4 sm:px-6 no-scrollbar"
className="inputbox-style no-scrollbar w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion px-4 py-3 text-base leading-tight opacity-100 focus:outline-none dark:bg-transparent dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 sm:px-6 sm:py-5"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
</div>
<div className="flex items-center px-3 sm:px-4 py-1.5 sm:py-2">
<div className="flex-grow flex flex-wrap gap-1 sm:gap-2">
<div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex flex-grow flex-wrap gap-1 sm:gap-2">
{showSourceButton && (
<button
ref={sourceButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
className="xs:px-3 xs:py-1.5 flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C] sm:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={
selectedDocs
? selectedDocs.name
: t('conversation.sources.title')
}
>
<img src={SourceIcon} alt="Sources" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
<img
src={SourceIcon}
alt="Sources"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
{!isTouch && (
<span className="ml-1 hidden text-[10px] text-gray-500 dark:text-gray-400 sm:inline-block">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
</span>
)}
</button>
)}
{showToolButton && (
<button
ref={toolButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img src={ToolIcon} alt="Tools" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
<img
src={ToolIcon}
alt="Tools"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{t('settings.tools.label')}
</span>
</button>
<label className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors cursor-pointer">
<img src={ClipIcon} alt="Attach" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium">
)}
<label className="xs:px-3 xs:py-1.5 flex cursor-pointer items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]">
<img
src={ClipIcon}
alt="Attach"
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
Attach
</span>
<input
@@ -337,18 +420,18 @@ export default function MessageInput({
<button
onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center p-2 sm:p-2.5 rounded-full ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
disabled={loading}
>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="w-3.5 sm:w-4 h-3.5 sm:h-4 animate-spin"
className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
alt={t('loading')}
/>
) : (
<img
className={`w-3.5 sm:w-4 h-3.5 sm:h-4 ${isDarkTheme ? 'filter invert' : ''}`}
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
src={PaperPlane}
alt={t('send')}
/>

View File

@@ -0,0 +1,278 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import CheckmarkIcon from '../assets/checkmark.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import { useDarkTheme } from '../hooks';
import Input from './Input';
export type OptionType = {
id: string | number;
label: string;
icon?: string | React.ReactNode;
[key: string]: any;
};
type MultiSelectPopupProps = {
isOpen: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement>;
options: OptionType[];
selectedIds: Set<string | number>;
onSelectionChange: (newSelectedIds: Set<string | number>) => void;
title?: string;
searchPlaceholder?: string;
noOptionsMessage?: string;
loading?: boolean;
footerContent?: React.ReactNode;
showSearch?: boolean;
singleSelect?: boolean;
};
export default function MultiSelectPopup({
isOpen,
onClose,
anchorRef,
options,
selectedIds,
onSelectionChange,
title,
searchPlaceholder,
noOptionsMessage,
loading = false,
footerContent,
showSearch = true,
singleSelect = false,
}: MultiSelectPopupProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const popupRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [popupPosition, setPopupPosition] = useState({
top: 0,
left: 0,
maxHeight: 0,
showAbove: false,
});
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleOptionClick = (optionId: string | number) => {
let newSelectedIds: Set<string | number>;
if (singleSelect) newSelectedIds = new Set<string | number>();
else newSelectedIds = new Set(selectedIds);
if (newSelectedIds.has(optionId)) {
newSelectedIds.delete(optionId);
} else newSelectedIds.add(optionId);
onSelectionChange(newSelectedIds);
};
const renderIcon = (icon: string | React.ReactNode) => {
if (typeof icon === 'string') {
if (icon.startsWith('/') || icon.startsWith('http')) {
return (
<img
src={icon}
alt=""
className="mr-3 h-5 w-5 flex-shrink-0"
aria-hidden="true"
/>
);
}
return (
<span className="mr-3 h-5 w-5 flex-shrink-0" aria-hidden="true">
{icon}
</span>
);
}
return <span className="mr-3 flex-shrink-0">{icon}</span>;
};
useLayoutEffect(() => {
if (!isOpen || !anchorRef.current) return;
const updatePosition = () => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const popupPadding = 16;
const popupMinWidth = 300;
const popupMaxWidth = 462;
const popupDefaultHeight = 300;
const spaceAbove = rect.top;
const spaceBelow = viewportHeight - rect.bottom;
const showAbove =
spaceBelow < popupDefaultHeight && spaceAbove >= popupDefaultHeight;
const maxHeight = Math.max(
150,
showAbove ? spaceAbove - popupPadding : spaceBelow - popupPadding,
);
const availableWidth = viewportWidth - 20;
const calculatedWidth = Math.min(popupMaxWidth, availableWidth);
let left = rect.left;
if (left + calculatedWidth > viewportWidth - 10) {
left = viewportWidth - calculatedWidth - 10;
}
left = Math.max(10, left);
setPopupPosition({
top: showAbove ? rect.top - 8 : rect.bottom + 8,
left: left,
maxHeight: Math.min(600, maxHeight),
showAbove,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [isOpen, anchorRef]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(event.target as Node)
)
onClose();
};
if (isOpen) document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose, anchorRef, isOpen]);
useEffect(() => {
if (!isOpen) setSearchTerm('');
}, [isOpen]);
if (!isOpen) return null;
return (
<div
ref={popupRef}
className="fixed z-[9999] flex flex-col rounded-lg border border-light-silver bg-lotion shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] dark:border-dim-gray dark:bg-charleston-green-2"
style={{
top: popupPosition.showAbove ? undefined : popupPosition.top,
bottom: popupPosition.showAbove
? window.innerHeight - popupPosition.top + 8
: undefined,
left: popupPosition.left,
maxWidth: `${Math.min(462, window.innerWidth - 20)}px`,
width: '100%',
maxHeight: `${popupPosition.maxHeight}px`,
}}
>
{(title || showSearch) && (
<div className="flex-shrink-0 p-4">
{title && (
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
{title}
</h3>
)}
{showSearch && (
<Input
id="multi-select-search"
name="multi-select-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={
searchPlaceholder ||
t('settings.tools.searchPlaceholder', 'Search...')
}
labelBgClassName="bg-lotion dark:bg-charleston-green-2"
borderVariant="thin"
className="mb-4"
textSize="small"
/>
)}
</div>
)}
<div className="mx-4 mb-4 flex-grow overflow-auto rounded-md border border-[#D9D9D9] dark:border-dim-gray">
{loading ? (
<div className="flex h-full items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
</div>
) : (
<div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 dark:[&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-track]:bg-gray-200 dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C] [&::-webkit-scrollbar]:w-2">
{filteredOptions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4 py-8 text-center">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No options found"
className="mx-auto mb-3 h-16 w-16"
/>
<p className="text-sm text-gray-500 dark:text-gray-400">
{searchTerm
? 'No results found'
: noOptionsMessage || 'No options available'}
</p>
</div>
) : (
filteredOptions.map((option) => {
const isSelected = selectedIds.has(option.id);
return (
<div
key={option.id}
onClick={() => handleOptionClick(option.id)}
className="flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0 hover:bg-gray-100 dark:border-dim-gray dark:hover:bg-charleston-green-3"
role="option"
aria-selected={isSelected}
>
<div className="mr-3 flex flex-grow items-center overflow-hidden">
{option.icon && renderIcon(option.icon)}
<p
className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white"
title={option.label}
>
{option.label}
</p>
</div>
<div className="flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-sm border border-[#C6C6C6] bg-white dark:border-[#757783] dark:bg-charleston-green-2`}
aria-hidden="true"
>
{isSelected && (
<img
src={CheckmarkIcon}
alt="checkmark"
width={10}
height={10}
/>
)}
</div>
</div>
</div>
);
})
)}
</div>
)}
</div>
{footerContent && (
<div className="flex-shrink-0 border-t border-light-silver p-4 dark:border-dim-gray">
{footerContent}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ArrowLeft from '../assets/arrow-left.svg';
import ArrowRight from '../assets/arrow-right.svg';
import { useTranslation } from 'react-i18next';
type HiddenGradientType = 'left' | 'right' | undefined;
@@ -10,7 +11,6 @@ const useTabs = () => {
const tabs = [
t('settings.general.label'),
t('settings.documents.label'),
t('settings.apiKeys.label'),
t('settings.analytics.label'),
t('settings.logs.label'),
t('settings.tools.label'),
@@ -48,18 +48,18 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
[containerRef.current],
);
return (
<div className="relative mt-6 flex flex-row items-center space-x-1 md:space-x-0 overflow-auto">
<div className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0">
<div
className={`${hiddenGradient === 'left' ? 'hidden' : ''} md:hidden absolute inset-y-0 left-6 w-14 bg-gradient-to-r from-white dark:from-raisin-black pointer-events-none`}
className={`${hiddenGradient === 'left' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 left-6 w-14 bg-gradient-to-r from-white dark:from-raisin-black md:hidden`}
></div>
<div
className={`${hiddenGradient === 'right' ? 'hidden' : ''} md:hidden absolute inset-y-0 right-6 w-14 bg-gradient-to-l from-white dark:from-raisin-black pointer-events-none`}
className={`${hiddenGradient === 'right' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 right-6 w-14 bg-gradient-to-l from-white dark:from-raisin-black md:hidden`}
></div>
<div className="md:hidden z-10">
<div className="z-10 md:hidden">
<button
onClick={() => scrollTabs(-1)}
className="flex h-6 w-6 items-center rounded-full justify-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
className="flex h-6 w-6 items-center justify-center rounded-full transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="Scroll tabs left"
>
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
@@ -67,7 +67,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
</div>
<div
ref={containerRef}
className="flex flex-nowrap overflow-x-auto no-scrollbar md:space-x-4 scroll-smooth snap-x"
className="no-scrollbar flex snap-x flex-nowrap overflow-x-auto scroll-smooth md:space-x-4"
role="tablist"
aria-label="Settings tabs"
>
@@ -75,7 +75,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
<button
key={index}
onClick={() => setActiveTab(tab)}
className={`snap-start h-9 rounded-3xl px-4 font-bold transition-colors ${
className={`h-9 snap-start rounded-3xl px-4 font-bold transition-colors ${
activeTab === tab
? 'bg-[#F4F4F5] text-neutral-900 dark:bg-dark-charcoal dark:text-white'
: 'text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-white'
@@ -89,10 +89,10 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
</button>
))}
</div>
<div className="md:hidden z-10">
<div className="z-10 md:hidden">
<button
onClick={() => scrollTabs(1)}
className="flex h-6 w-6 rounded-full items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700"
className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label="Scroll tabs right"
>
<img src={ArrowRight} alt="right-arrow" className="h-3" />

View File

@@ -207,7 +207,7 @@ export default function SourcesPopup({
<div className="px-4 md:px-6 py-4 opacity-75 hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
<a
href="/settings/documents"
className="text-violets-are-blue text-base font-medium flex items-center gap-2"
className="text-violets-are-blue text-base font-medium inline-flex items-center gap-2"
onClick={onClose}
>
Go to Documents

View File

@@ -217,10 +217,10 @@ export default function ToolsPopup({
</div>
)}
<div className="p-4 flex-shrink-0">
<div className="p-4 flex-shrink-0 opacity-75 hover:opacity-100 transition-opacity duration-200">
<a
href="/settings/tools"
className="text-base text-purple-30 font-medium hover:text-violets-are-blue flex items-center"
className="text-base text-purple-30 font-medium inline-flex items-center"
>
{t('settings.tools.manageTools')}
<img

View File

@@ -3,6 +3,7 @@ export type InputProps = {
value: string | string[] | number;
colorVariant?: 'silver' | 'jet' | 'gray';
borderVariant?: 'thin' | 'thick';
textSize?: 'small' | 'medium';
isAutoFocused?: boolean;
id?: string;
maxLength?: number;

View File

@@ -1,18 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useDropzone } from 'react-dropzone';
import DragFileUpload from '../assets/DragFileUpload.svg';
import newChatIcon from '../assets/openNewChat.svg';
import ShareIcon from '../assets/share.svg';
import MessageInput from '../components/MessageInput';
import { useMediaQuery } from '../hooks';
import { ShareConversationModal } from '../modals/ShareConversationModal';
import { ActiveState } from '../models/misc';
import {
selectConversationId,
selectSelectedAgent,
selectToken,
} from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import Upload from '../upload/Upload';
import { handleSendFeedback } from './conversationHandlers';
import ConversationMessages from './ConversationMessages';
import { FEEDBACK, Query } from './conversationModels';
import {
addQuery,
@@ -24,28 +30,29 @@ import {
updateConversationId,
updateQuery,
} from './conversationSlice';
import Upload from '../upload/Upload';
import { ActiveState } from '../models/misc';
import ConversationMessages from './ConversationMessages';
import MessageInput from '../components/MessageInput';
export default function Conversation() {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const status = useSelector(selectStatus);
const conversationId = useSelector(selectConversationId);
const dispatch = useDispatch<AppDispatch>();
const [input, setInput] = useState('');
const fetchStream = useRef<any>(null);
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState<string>('');
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [files, setFiles] = useState<File[]>([]);
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
useState<boolean>(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
const fetchStream = useRef<any>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
setUploadModalState('ACTIVE');
setFiles(acceptedFiles);
@@ -83,35 +90,36 @@ export default function Conversation() {
},
});
useEffect(() => {
if (queries.length) {
queries[queries.length - 1].error && setLastQueryReturnedErr(true);
queries[queries.length - 1].response && setLastQueryReturnedErr(false); //considering a query that initially returned error can later include a response property on retry
}
}, [queries[queries.length - 1]]);
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
},
[dispatch, selectedAgent],
);
const handleQuestion = ({
const handleQuestion = useCallback(
({
question,
isRetry = false,
updated = null,
indx = undefined,
index = undefined,
}: {
question: string;
isRetry?: boolean;
updated?: boolean | null;
indx?: number;
index?: number;
}) => {
if (updated === true) {
!isRetry &&
dispatch(resendQuery({ index: indx as number, prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question, indx }));
const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return;
if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
} else {
question = question.trim();
if (question === '') return;
!isRetry && dispatch(addQuery({ prompt: question }));
fetchStream.current = dispatch(fetchAnswer({ question }));
if (!isRetry) dispatch(addQuery({ prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index });
}
};
},
[dispatch, handleFetchAnswer],
);
const handleFeedback = (query: Query, feedback: FEEDBACK, index: number) => {
const prevFeedback = query.feedback;
@@ -143,10 +151,9 @@ export default function Conversation() {
indx?: number,
) => {
if (updated === true) {
handleQuestion({ question: updatedQuestion as string, updated, indx });
handleQuestion({ question: updatedQuestion as string, index: indx });
} else if (input && status !== 'loading') {
if (lastQueryReturnedErr) {
// update last failed query with new prompt
dispatch(
updateQuery({
index: queries.length - 1,
@@ -167,6 +174,7 @@ export default function Conversation() {
setInput('');
}
};
const resetConversation = () => {
dispatch(setConversation([]));
dispatch(
@@ -175,22 +183,29 @@ export default function Conversation() {
}),
);
};
const newChat = () => {
if (queries && queries.length > 0) resetConversation();
};
useEffect(() => {
if (queries.length) {
queries[queries.length - 1].error && setLastQueryReturnedErr(true);
queries[queries.length - 1].response && setLastQueryReturnedErr(false);
}
}, [queries[queries.length - 1]]);
return (
<div className="flex flex-col gap-1 h-full justify-end">
<div className="flex h-full flex-col justify-end gap-1">
{conversationId && queries.length > 0 && (
<div className="absolute top-4 right-20">
<div className="flex mt-2 items-center gap-4">
<div className="absolute right-20 top-4">
<div className="mt-2 flex items-center gap-4">
{isMobile && queries.length > 0 && (
<button
title="Open New Chat"
onClick={() => {
newChat();
}}
className="hover:bg-bright-gray dark:hover:bg-[#28292E] rounded-full p-2"
className="rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E]"
>
<img
className="h-5 w-5 filter dark:invert"
@@ -205,7 +220,7 @@ export default function Conversation() {
onClick={() => {
setShareModalState(true);
}}
className="hover:bg-bright-gray dark:hover:bg-[#28292E] rounded-full p-2"
className="rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E]"
>
<img
className="h-5 w-5 filter dark:invert"
@@ -233,7 +248,7 @@ export default function Conversation() {
status={status}
/>
<div className="flex flex-col items-end self-center rounded-2xl bg-opacity-0 z-3 w-full md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12 max-w-[1300px] h-auto py-1">
<div className="z-3 flex h-auto w-full max-w-[1300px] flex-col items-end self-center rounded-2xl bg-opacity-0 py-1 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div
{...getRootProps()}
className="flex w-full items-center rounded-[40px]"
@@ -247,20 +262,22 @@ export default function Conversation() {
onChange={(e) => setInput(e.target.value)}
onSubmit={handleQuestionSubmission}
loading={status === 'loading'}
showSourceButton={selectedAgent ? false : true}
showToolButton={selectedAgent ? false : true}
/>
</div>
<p className="text-gray-4000 hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs dark:text-sonic-silver md:inline md:w-full">
<p className="hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline md:w-full">
{t('tagline')}
</p>
</div>
{handleDragActive && (
<div className="pointer-events-none fixed top-0 left-0 z-30 flex flex-col size-full items-center justify-center bg-opacity-50 bg-white dark:bg-gray-alpha">
<div className="pointer-events-none fixed left-0 top-0 z-30 flex size-full flex-col items-center justify-center bg-white bg-opacity-50 dark:bg-gray-alpha">
<img className="filter dark:invert" src={DragFileUpload} />
<span className="px-2 text-2xl font-bold text-outer-space dark:text-silver">
{t('modals.uploadDoc.drag.title')}
</span>
<span className="p-2 text-s w-48 text-center text-outer-space dark:text-silver">
<span className="text-s w-48 p-2 text-center text-outer-space dark:text-silver">
{t('modals.uploadDoc.drag.description')}
</span>
</div>

View File

@@ -58,7 +58,6 @@ const ConversationBubble = forwardRef<
updated?: boolean,
index?: number,
) => void;
attachments?: { fileName: string; id: string }[];
}
>(function ConversationBubble(
{
@@ -73,7 +72,6 @@ const ConversationBubble = forwardRef<
retryBtn,
questionNumber,
handleUpdatedQuestionSubmission,
attachments,
},
ref,
) {
@@ -100,35 +98,6 @@ const ConversationBubble = forwardRef<
handleUpdatedQuestionSubmission?.(editInputBox, true, questionNumber);
};
let bubble;
const renderAttachments = () => {
if (!attachments || attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center rounded-md bg-gray-100 px-2 py-1 text-sm dark:bg-gray-700"
>
<svg
className="mr-1 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
<span>{attachment.fileName}</span>
</div>
))}
</div>
);
};
if (type === 'QUESTION') {
bubble = (
<div
@@ -157,7 +126,6 @@ const ConversationBubble = forwardRef<
>
{message}
</div>
{renderAttachments()}
</div>
<button
onClick={() => {

View File

@@ -23,6 +23,7 @@ interface ConversationMessagesProps {
handleFeedback?: (query: Query, feedback: FEEDBACK, index: number) => void;
queries: Query[];
status: Status;
showHeroOnEmpty?: boolean;
}
export default function ConversationMessages({
@@ -31,6 +32,7 @@ export default function ConversationMessages({
queries,
status,
handleFeedback,
showHeroOnEmpty = true,
}: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
@@ -141,7 +143,7 @@ export default function ConversationMessages({
ref={conversationRef}
onWheel={handleUserInterruption}
onTouchMove={handleUserInterruption}
className="flex justify-center w-full overflow-y-auto h-screen sm:pt-12"
className="flex justify-center w-full overflow-y-auto h-full sm:pt-12"
>
{queries.length > 0 && !hasScrolledToLast && (
<button
@@ -161,7 +163,6 @@ export default function ConversationMessages({
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={index}>
<ConversationBubble
className={'first:mt-5'}
key={`${index}QUESTION`}
@@ -170,14 +171,13 @@ export default function ConversationMessages({
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
attachments={query.attachments}
/>
{prepResponseView(query, index)}
</Fragment>
))
) : (
) : showHeroOnEmpty ? (
<Hero handleQuestion={handleQuestion} />
)}
) : null}
</div>
</div>
);

View File

@@ -13,7 +13,9 @@ export function handleFetchAnswer(
promptId: string | null,
chunks: string,
token_limit: number,
agentId?: string,
attachments?: string[],
save_conversation: boolean = true,
): Promise<
| {
result: any;
@@ -50,6 +52,8 @@ export function handleFetchAnswer(
chunks: chunks,
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
agent_id: agentId,
save_conversation: save_conversation,
};
// Add attachments to payload if they exist
@@ -97,7 +101,9 @@ export function handleFetchAnswerSteaming(
token_limit: number,
onEvent: (event: MessageEvent) => void,
indx?: number,
agentId?: string,
attachments?: string[],
save_conversation: boolean = true,
): Promise<Answer> {
history = history.map((item) => {
return {
@@ -116,6 +122,8 @@ export function handleFetchAnswerSteaming(
token_limit: token_limit,
isNoneDoc: selectedDocs === null,
index: indx,
agent_id: agentId,
save_conversation: save_conversation,
};
// Add attachments to payload if they exist

View File

@@ -9,11 +9,21 @@ export interface Message {
type: MESSAGE_TYPE;
}
export interface Attachment {
id?: string;
fileName: string;
status: 'uploading' | 'processing' | 'completed' | 'failed';
progress: number;
taskId?: string;
token_count?: number;
}
export interface ConversationState {
queries: Query[];
status: Status;
conversationId: string | null;
attachments?: { fileName: string; id: string }[];
attachments: Attachment[];
}
export interface Answer {
@@ -51,5 +61,7 @@ export interface RetrievalPayload {
token_limit: number;
isNoneDoc: boolean;
index?: number;
agent_id?: string;
attachments?: string[];
save_conversation?: boolean;
}

View File

@@ -7,7 +7,13 @@ import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from './conversationHandlers';
import { Answer, ConversationState, Query, Status } from './conversationModels';
import {
Answer,
Query,
Status,
ConversationState,
Attachment,
} from './conversationModels';
const initialState: ConversationState = {
queries: [],
@@ -28,17 +34,22 @@ export function handleAbort() {
export const fetchAnswer = createAsyncThunk<
Answer,
{ question: string; indx?: number }
>('fetchAnswer', async ({ question, indx }, { dispatch, getState }) => {
if (abortController) {
abortController.abort();
}
{ question: string; indx?: number; isPreview?: boolean }
>(
'fetchAnswer',
async ({ question, indx, isPreview = false }, { dispatch, getState }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
let isSourceUpdated = false;
const state = getState() as RootState;
const attachments = state.conversation.attachments?.map(a => a.id) || [];
const attachmentIds = state.conversation.attachments
.filter((a) => a.id && a.status === 'completed')
.map((a) => a.id) as string[];
const currentConversationId = state.conversation.conversationId;
const conversationIdToSend = isPreview ? null : currentConversationId;
const save_conversation = isPreview ? false : true;
if (state.preference) {
if (API_STREAMING) {
@@ -48,15 +59,17 @@ export const fetchAnswer = createAsyncThunk<
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
conversationIdToSend,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.conversation.queries.length - 1;
if (data.type === 'end') {
dispatch(conversationSlice.actions.setStatus('idle'));
if (!isPreview) {
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
@@ -64,26 +77,28 @@ export const fetchAnswer = createAsyncThunk<
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
}
if (!isSourceUpdated) {
dispatch(
updateStreamingSource({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
query: { sources: [] },
}),
);
}
} else if (data.type === 'id') {
if (!isPreview) {
dispatch(
updateConversationId({
query: { conversationId: data.id },
}),
);
}
} else if (data.type === 'thought') {
const result = data.thought;
console.log('thought', result);
dispatch(
updateThought({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
query: { thought: result },
}),
);
@@ -91,14 +106,14 @@ export const fetchAnswer = createAsyncThunk<
isSourceUpdated = true;
dispatch(
updateStreamingSource({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_calls') {
dispatch(
updateToolCalls({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
query: { tool_calls: data.tool_calls },
}),
);
@@ -107,22 +122,23 @@ export const fetchAnswer = createAsyncThunk<
dispatch(conversationSlice.actions.setStatus('failed'));
dispatch(
conversationSlice.actions.raiseError({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
message: data.error,
}),
);
} else {
const result = data.answer;
dispatch(
updateStreamingQuery({
index: indx ?? state.conversation.queries.length - 1,
query: { response: result },
index: targetIndex,
query: { response: data.answer },
}),
);
}
},
indx,
attachments
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
} else {
const answer = await handleFetchAnswer(
@@ -135,7 +151,9 @@ export const fetchAnswer = createAsyncThunk<
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
attachments
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
if (answer) {
let sourcesPrepped = [];
@@ -150,9 +168,11 @@ export const fetchAnswer = createAsyncThunk<
return source;
});
const targetIndex = indx ?? state.conversation.queries.length - 1;
dispatch(
updateQuery({
index: indx ?? state.conversation.queries.length - 1,
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
@@ -161,12 +181,12 @@ export const fetchAnswer = createAsyncThunk<
},
}),
);
if (!isPreview) {
dispatch(
updateConversationId({
query: { conversationId: answer.conversationId },
}),
);
dispatch(conversationSlice.actions.setStatus('idle'));
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
@@ -175,6 +195,8 @@ export const fetchAnswer = createAsyncThunk<
console.error('Failed to fetch conversations: ', error);
});
}
dispatch(conversationSlice.actions.setStatus('idle'));
}
}
}
return {
@@ -187,7 +209,8 @@ export const fetchAnswer = createAsyncThunk<
sources: [],
tool_calls: [],
};
});
},
);
export const conversationSlice = createSlice({
name: 'conversation',
@@ -286,9 +309,41 @@ export const conversationSlice = createSlice({
const { index, message } = action.payload;
state.queries[index].error = message;
},
setAttachments: (state, action: PayloadAction<{ fileName: string; id: string }[]>) => {
setAttachments: (state, action: PayloadAction<Attachment[]>) => {
state.attachments = action.payload;
},
addAttachment: (state, action: PayloadAction<Attachment>) => {
state.attachments.push(action.payload);
},
updateAttachment: (
state,
action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>,
) => {
const index = state.attachments.findIndex(
(att) => att.taskId === action.payload.taskId,
);
if (index !== -1) {
state.attachments[index] = {
...state.attachments[index],
...action.payload.updates,
};
}
},
removeAttachment: (state, action: PayloadAction<string>) => {
state.attachments = state.attachments.filter(
(att) => att.taskId !== action.payload && att.id !== action.payload,
);
},
resetConversation: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
state.conversationId = initialState.conversationId;
state.attachments = initialState.attachments;
handleAbort();
},
},
extraReducers(builder) {
builder
@@ -312,6 +367,11 @@ export const selectQueries = (state: RootState) => state.conversation.queries;
export const selectStatus = (state: RootState) => state.conversation.status;
export const selectAttachments = (state: RootState) =>
state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter((att) => att.status === 'completed');
export const {
addQuery,
updateQuery,
@@ -323,5 +383,9 @@ export const {
updateToolCalls,
setConversation,
setAttachments,
addAttachment,
updateAttachment,
removeAttachment,
resetConversation,
} = conversationSlice.actions;
export default conversationSlice.reducer;

View File

@@ -0,0 +1,168 @@
import { useCallback, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { Prompt } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
type UsePromptManagerProps = {
initialPrompts: Prompt[];
onPromptSelect: (name: string, id: string, type: string) => void;
onPromptsUpdate: (updatedPrompts: Prompt[]) => void;
};
type PromptContentResponse = {
id: string;
name: string;
content: string;
};
type PromptCreateResponse = {
id: string;
};
export const usePromptManager = ({
initialPrompts,
onPromptSelect,
onPromptsUpdate,
}: UsePromptManagerProps) => {
const token = useSelector(selectToken);
const [prompts, setPrompts] = useState<Prompt[]>(initialPrompts);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setPrompts(initialPrompts);
}, [initialPrompts]);
const handleApiCall = async <T>(
apiCall: () => Promise<Response>,
errorMessage: string,
): Promise<T | null> => {
setIsLoading(true);
setError(null);
try {
const response = await apiCall();
if (!response.ok) {
const errorData = await response.text();
console.error(`${errorMessage}: ${response.status} ${errorData}`);
throw new Error(`${errorMessage} (Status: ${response.status})`);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return (await response.json()) as T;
}
return null;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
console.error(err);
return null;
} finally {
setIsLoading(false);
}
};
const addPrompt = useCallback(
async (name: string, content: string): Promise<Prompt | null> => {
const newPromptData = await handleApiCall<PromptCreateResponse>(
() => userService.createPrompt({ name, content }, token),
'Failed to add prompt',
);
if (newPromptData) {
const newPrompt: Prompt = {
name,
id: newPromptData.id,
type: 'private',
};
const updatedPrompts = [...prompts, newPrompt];
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
onPromptSelect(newPrompt.name, newPrompt.id, newPrompt.type);
return newPrompt;
}
return null;
},
[token, prompts, onPromptsUpdate, onPromptSelect],
);
const deletePrompt = useCallback(
async (idToDelete: string): Promise<void> => {
const originalPrompts = [...prompts];
const updatedPrompts = prompts.filter(
(prompt) => prompt.id !== idToDelete,
);
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
const result = await handleApiCall<null>(
() => userService.deletePrompt({ id: idToDelete }, token),
'Failed to delete prompt',
);
if (result === null && error) {
setPrompts(originalPrompts);
onPromptsUpdate(originalPrompts);
} else {
if (updatedPrompts.length > 0) {
onPromptSelect(
updatedPrompts[0].name,
updatedPrompts[0].id,
updatedPrompts[0].type,
);
}
}
},
[token, prompts, onPromptsUpdate, onPromptSelect, error],
);
const fetchPromptContent = useCallback(
async (id: string): Promise<string | null> => {
const promptDetails = await handleApiCall<PromptContentResponse>(
() => userService.getSinglePrompt(id, token),
'Failed to fetch prompt content',
);
return promptDetails ? promptDetails.content : null;
},
[token],
);
const updatePrompt = useCallback(
async (
id: string,
name: string,
content: string,
type: string,
): Promise<boolean> => {
const result = await handleApiCall<{ success: boolean }>(
() => userService.updatePrompt({ id, name, content }, token),
'Failed to update prompt',
);
if (result?.success) {
const updatedPrompts = prompts.map((p) =>
p.id === id ? { ...p, name, type } : p,
);
setPrompts(updatedPrompts);
onPromptsUpdate(updatedPrompts);
onPromptSelect(name, id, type);
return true;
}
return false;
},
[token, prompts, onPromptsUpdate, onPromptSelect],
);
return {
prompts,
isLoading,
error,
addPrompt,
deletePrompt,
fetchPromptContent,
updatePrompt,
setError,
};
};

View File

@@ -0,0 +1,68 @@
import { Agent } from '../agents/types';
import { ActiveState } from '../models/misc';
import WrapperModal from './WrapperModal';
import { useNavigate } from 'react-router-dom';
type AgentDetailsModalProps = {
agent: Agent;
mode: 'new' | 'edit' | 'draft';
modalState: ActiveState;
setModalState: (state: ActiveState) => void;
};
export default function AgentDetailsModal({
agent,
mode,
modalState,
setModalState,
}: AgentDetailsModalProps) {
const navigate = useNavigate();
if (modalState !== 'ACTIVE') return null;
return (
<WrapperModal
className="sm:w-[512px]"
close={() => {
// if (mode === 'new') navigate('/agents');
setModalState('INACTIVE');
}}
>
<div>
<h2 className="text-xl font-semibold text-jet dark:text-bright-gray">
Access Details
</h2>
<div className="mt-8 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
Public link
</h2>
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
API Key
</h2>
{agent.key ? (
<span className="font-mono text-sm text-gray-700 dark:text-[#ECECF1]">
{agent.key}
</span>
) : (
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
Webhooks
</h2>
<button className="hover:bg-vi</button>olets-are-blue w-28 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white">
Generate
</button>
</div>
</div>
</div>
</WrapperModal>
);
}

View File

@@ -21,6 +21,12 @@ export type GetDocsResponse = {
nextCursor: string;
};
export type Prompt = {
name: string;
id: string;
type: string;
};
export type PromptProps = {
prompts: { name: string; id: string; type: string }[];
selectedPrompt: { name: string; id: string; type: string };

View File

@@ -1,6 +1,7 @@
import conversationService from '../api/services/conversationService';
import userService from '../api/services/userService';
import { Doc, GetDocsResponse } from '../models/misc';
import { GetConversationsResult, ConversationSummary } from './types';
//Fetches all JSON objects from the source. We only use the objects with the "model" property in SelectDocsModal.tsx. Hopefully can clean up the source file later.
export async function getDocs(token: string | null): Promise<Doc[] | null> {
@@ -49,23 +50,37 @@ export async function getDocsWithPagination(
}
}
export async function getConversations(token: string | null): Promise<{
data: { name: string; id: string }[] | null;
loading: boolean;
}> {
export async function getConversations(
token: string | null,
): Promise<GetConversationsResult> {
try {
const response = await conversationService.getConversations(token);
const data = await response.json();
const conversations: { name: string; id: string }[] = [];
if (!response.ok) {
console.error('Error fetching conversations:', response.statusText);
return { data: null, loading: false };
}
data.forEach((conversation: object) => {
conversations.push(conversation as { name: string; id: string });
});
const rawData: unknown = await response.json();
if (!Array.isArray(rawData)) {
console.error(
'Invalid data format received from API: Expected an array.',
rawData,
);
return { data: null, loading: false };
}
const conversations: ConversationSummary[] = rawData.map((item: any) => ({
id: item.id,
name: item.name,
agent_id: item.agent_id ?? null,
}));
return { data: conversations, loading: false };
} catch (error) {
console.log(error);
console.error(
'An unexpected error occurred while fetching conversations:',
error,
);
return { data: null, loading: false };
}
}

View File

@@ -1,12 +1,14 @@
import {
PayloadAction,
createListenerMiddleware,
createSlice,
isAnyOf,
PayloadAction,
} from '@reduxjs/toolkit';
import { setLocalApiKey, setLocalRecentDocs } from './preferenceApi';
import { RootState } from '../store';
import { Agent } from '../agents/types';
import { ActiveState, Doc } from '../models/misc';
import { RootState } from '../store';
import { setLocalApiKey, setLocalRecentDocs } from './preferenceApi';
export interface Preference {
apiKey: string;
@@ -22,6 +24,8 @@ export interface Preference {
token: string | null;
modalState: ActiveState;
paginatedDocuments: Doc[] | null;
agents: Agent[] | null;
selectedAgent: Agent | null;
}
const initialState: Preference = {
@@ -46,6 +50,8 @@ const initialState: Preference = {
token: localStorage.getItem('authToken') || null,
modalState: 'INACTIVE',
paginatedDocuments: null,
agents: null,
selectedAgent: null,
};
export const prefSlice = createSlice({
@@ -82,6 +88,12 @@ export const prefSlice = createSlice({
setModalStateDeleteConv: (state, action: PayloadAction<ActiveState>) => {
state.modalState = action.payload;
},
setAgents: (state, action) => {
state.agents = action.payload;
},
setSelectedAgent: (state, action) => {
state.selectedAgent = action.payload;
},
},
});
@@ -96,6 +108,8 @@ export const {
setTokenLimit,
setModalStateDeleteConv,
setPaginatedDocuments,
setAgents,
setSelectedAgent,
} = prefSlice.actions;
export default prefSlice.reducer;
@@ -170,3 +184,6 @@ export const selectTokenLimit = (state: RootState) =>
state.preference.token_limit;
export const selectPaginatedDocuments = (state: RootState) =>
state.preference.paginatedDocuments;
export const selectAgents = (state: RootState) => state.preference.agents;
export const selectSelectedAgent = (state: RootState) =>
state.preference.selectedAgent;

View File

@@ -0,0 +1,10 @@
export type ConversationSummary = {
id: string;
name: string;
agent_id: string | null;
};
export type GetConversationsResult = {
data: ConversationSummary[] | null;
loading: boolean;
};

View File

@@ -7,11 +7,12 @@ import {
Title,
Tooltip,
} from 'chart.js';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Bar } from 'react-chartjs-2';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Agent } from '../agents/types';
import userService from '../api/services/userService';
import Dropdown from '../components/Dropdown';
import SkeletonLoader from '../components/SkeletonLoader';
@@ -19,7 +20,6 @@ import { useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { htmlLegendPlugin } from '../utils/chartUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { APIKeyData } from './types';
import type { ChartData } from 'chart.js';
ChartJS.register(
@@ -31,7 +31,11 @@ ChartJS.register(
Legend,
);
export default function Analytics() {
type AnalyticsProps = {
agentId?: string;
};
export default function Analytics({ agentId }: AnalyticsProps) {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -67,8 +71,7 @@ export default function Analytics() {
string,
{ positive: number; negative: number }
> | null>(null);
const [chatbots, setChatbots] = useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] = useState<APIKeyData | null>();
const [agent, setAgent] = useState<Agent>();
const [messagesFilter, setMessagesFilter] = useState<{
label: string;
value: string;
@@ -94,37 +97,33 @@ export default function Analytics() {
const [loadingMessages, setLoadingMessages] = useLoaderState(true);
const [loadingTokens, setLoadingTokens] = useLoaderState(true);
const [loadingFeedback, setLoadingFeedback] = useLoaderState(true);
const [loadingChatbots, setLoadingChatbots] = useLoaderState(true);
const [loadingAgent, setLoadingAgent] = useLoaderState(true);
const fetchChatbots = async () => {
setLoadingChatbots(true);
const fetchAgent = async (agentId: string) => {
setLoadingAgent(true);
try {
const response = await userService.getAPIKeys(token);
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
const response = await userService.getAgent(agentId ?? '', token);
if (!response.ok) throw new Error('Failed to fetch Chatbots');
const agent = await response.json();
setAgent(agent);
} catch (error) {
console.error(error);
} finally {
setLoadingChatbots(false);
setLoadingAgent(false);
}
};
const fetchMessagesData = async (chatbot_id?: string, filter?: string) => {
const fetchMessagesData = async (agent_id?: string, filter?: string) => {
setLoadingMessages(true);
try {
const response = await userService.getMessageAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setMessagesData(data.messages);
} catch (error) {
@@ -134,19 +133,17 @@ export default function Analytics() {
}
};
const fetchTokenData = async (chatbot_id?: string, filter?: string) => {
const fetchTokenData = async (agent_id?: string, filter?: string) => {
setLoadingTokens(true);
try {
const response = await userService.getTokenAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setTokenUsageData(data.token_usage);
} catch (error) {
@@ -156,19 +153,17 @@ export default function Analytics() {
}
};
const fetchFeedbackData = async (chatbot_id?: string, filter?: string) => {
const fetchFeedbackData = async (agent_id?: string, filter?: string) => {
setLoadingFeedback(true);
try {
const response = await userService.getFeedbackAnalytics(
{
api_key_id: chatbot_id,
api_key_id: agent_id,
filter_option: filter,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch analytics data');
}
if (!response.ok) throw new Error('Failed to fetch analytics data');
const data = await response.json();
setFeedbackData(data.feedback);
} catch (error) {
@@ -179,69 +174,32 @@ export default function Analytics() {
};
useEffect(() => {
fetchChatbots();
if (agentId) fetchAgent(agentId);
}, []);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = messagesFilter;
fetchMessagesData(id, filter?.value);
}, [selectedChatbot, messagesFilter]);
}, [agent, messagesFilter]);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = tokenUsageFilter;
fetchTokenData(id, filter?.value);
}, [selectedChatbot, tokenUsageFilter]);
}, [agent, tokenUsageFilter]);
useEffect(() => {
const id = selectedChatbot?.id;
const id = agent?.id;
const filter = feedbackFilter;
fetchFeedbackData(id, filter?.value);
}, [selectedChatbot, feedbackFilter]);
}, [agent, feedbackFilter]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
{loadingChatbots ? (
<SkeletonLoader component="dropdown" />
) : (
<div className="flex flex-col gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.filterByChatbot')}
</p>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: t('settings.analytics.none'), value: '' },
]}
placeholder={t('settings.analytics.selectChatbot')}
onSelect={(chatbot: { label: string; value: string }) => {
setSelectedChatbot(
chatbots.find((item) => item.id === chatbot.value),
);
}}
selectedValue={
(selectedChatbot && {
label: selectedChatbot.name,
value: selectedChatbot.id,
}) ||
null
}
rounded="3xl"
border="border"
darkBorderColor="dim-gray"
/>
</div>
)}
{/* Messages Analytics */}
<div className="mt-8 w-full flex flex-col [@media(min-width:1080px)]:flex-row gap-3">
<div className="h-[345px] [@media(min-width:1080px)]:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="mt-8 flex w-full flex-col gap-3 [@media(min-width:1080px)]:flex-row">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.messages')}
@@ -250,10 +208,7 @@ export default function Analytics() {
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
onSelect={(selectedOption: { label: string; value: string }) => {
setMessagesFilter(selectedOption);
}}
selectedValue={messagesFilter ?? null}
@@ -262,7 +217,7 @@ export default function Analytics() {
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-1"
className="flex flex-row items-center justify-end"
@@ -292,7 +247,7 @@ export default function Analytics() {
</div>
{/* Token Usage Analytics */}
<div className="h-[345px] [@media(min-width:1080px)]:w-1/2 w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.tokenUsage')}
@@ -301,10 +256,7 @@ export default function Analytics() {
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
onSelect={(selectedOption: { label: string; value: string }) => {
setTokenUsageFilter(selectedOption);
}}
selectedValue={tokenUsageFilter ?? null}
@@ -313,7 +265,7 @@ export default function Analytics() {
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-2"
className="flex flex-row items-center justify-end"
@@ -344,8 +296,8 @@ export default function Analytics() {
</div>
{/* Feedback Analytics */}
<div className="mt-8 w-full flex flex-col gap-3">
<div className="h-[345px] w-full px-6 py-5 border rounded-2xl border-silver dark:border-silver/40 overflow-hidden">
<div className="mt-8 flex w-full flex-col gap-3">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
{t('settings.analytics.userFeedback')}
@@ -354,10 +306,7 @@ export default function Analytics() {
size="w-[125px]"
options={filterOptions}
placeholder={t('settings.analytics.filterPlaceholder')}
onSelect={(selectedOption: {
label: string;
value: string;
}) => {
onSelect={(selectedOption: { label: string; value: string }) => {
setFeedbackFilter(selectedOption);
}}
selectedValue={feedbackFilter ?? null}
@@ -366,7 +315,7 @@ export default function Analytics() {
contentSize="text-sm"
/>
</div>
<div className="mt-px relative h-[245px] w-full">
<div className="relative mt-px h-[245px] w-full">
<div
id="legend-container-3"
className="flex flex-row items-center justify-end"
@@ -405,7 +354,6 @@ export default function Analytics() {
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,53 +5,35 @@ import { useSelector } from 'react-redux';
import userService from '../api/services/userService';
import ChevronRight from '../assets/chevron-right.svg';
import CopyButton from '../components/CopyButton';
import Dropdown from '../components/Dropdown';
import SkeletonLoader from '../components/SkeletonLoader';
import { useLoaderState } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
import { APIKeyData, LogData } from './types';
import { LogData } from './types';
export default function Logs() {
const { t } = useTranslation();
type LogsProps = {
agentId?: string;
tableHeader?: string;
};
export default function Logs({ agentId, tableHeader }: LogsProps) {
const token = useSelector(selectToken);
const [chatbots, setChatbots] = useState<APIKeyData[]>([]);
const [selectedChatbot, setSelectedChatbot] = useState<APIKeyData | null>();
const [logs, setLogs] = useState<LogData[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingChatbots, setLoadingChatbots] = useLoaderState(true);
const [loadingLogs, setLoadingLogs] = useLoaderState(true);
const fetchChatbots = async () => {
setLoadingChatbots(true);
try {
const response = await userService.getAPIKeys(token);
if (!response.ok) {
throw new Error('Failed to fetch Chatbots');
}
const chatbots = await response.json();
setChatbots(chatbots);
} catch (error) {
console.error(error);
} finally {
setLoadingChatbots(false);
}
};
const fetchLogs = async () => {
setLoadingLogs(true);
try {
const response = await userService.getLogs(
{
page: page,
api_key_id: selectedChatbot?.id,
api_key_id: agentId,
page_size: 10,
},
token,
);
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
if (!response.ok) throw new Error('Failed to fetch logs');
const olderLogs = await response.json();
setLogs((prevLogs) => [...prevLogs, ...olderLogs.logs]);
setHasMore(olderLogs.has_more);
@@ -62,62 +44,18 @@ export default function Logs() {
}
};
useEffect(() => {
fetchChatbots();
}, []);
useEffect(() => {
if (hasMore) fetchLogs();
}, [page, selectedChatbot]);
}, [page, agentId]);
return (
<div className="mt-12">
<div className="flex flex-col items-start">
{loadingChatbots ? (
<SkeletonLoader component="dropdown" />
) : (
<div className="flex flex-col gap-3">
<label
id="chatbot-filter-label"
className="font-bold text-jet dark:text-bright-gray"
>
{t('settings.logs.filterByChatbot')}
</label>
<Dropdown
size="w-[55vw] sm:w-[360px]"
options={[
...chatbots.map((chatbot) => ({
label: chatbot.name,
value: chatbot.id,
})),
{ label: t('settings.logs.none'), value: '' },
]}
placeholder={t('settings.logs.selectChatbot')}
onSelect={(chatbot: { label: string; value: string }) => {
setSelectedChatbot(
chatbots.find((item) => item.id === chatbot.value),
);
setLogs([]);
setPage(1);
setHasMore(true);
}}
selectedValue={
(selectedChatbot && {
label: selectedChatbot.name,
value: selectedChatbot.id,
}) ||
null
}
rounded="3xl"
border="border"
darkBorderColor="dim-gray"
/>
</div>
)}
</div>
<div className="mt-8">
<LogsTable logs={logs} setPage={setPage} loading={loadingLogs} />
<LogsTable
logs={logs}
setPage={setPage}
loading={loadingLogs}
tableHeader={tableHeader}
/>
</div>
</div>
);
@@ -127,8 +65,9 @@ type LogsTableProps = {
logs: LogData[];
setPage: React.Dispatch<React.SetStateAction<number>>;
loading: boolean;
tableHeader?: string;
};
function LogsTable({ logs, setPage, loading }: LogsTableProps) {
function LogsTable({ logs, setPage, loading, tableHeader }: LogsTableProps) {
const { t } = useTranslation();
const observerRef = useRef<IntersectionObserver | null>(null);
const [openLogId, setOpenLogId] = useState<string | null>(null);
@@ -171,13 +110,13 @@ function LogsTable({ logs, setPage, loading }: LogsTableProps) {
}, []);
return (
<div className="logs-table rounded-xl h-[55vh] w-full overflow-hidden bg-white dark:bg-black border border-light-silver dark:border-transparent">
<div className="h-8 bg-black/10 dark:bg-[#191919] flex flex-col items-start justify-center">
<div className="logs-table h-[55vh] w-full overflow-hidden rounded-xl border border-light-silver bg-white dark:border-transparent dark:bg-black">
<div className="flex h-8 flex-col items-start justify-center bg-black/10 dark:bg-[#191919]">
<p className="px-3 text-xs dark:text-gray-6000">
{t('settings.logs.tableHeader')}
{tableHeader ? tableHeader : t('settings.logs.tableHeader')}
</p>
</div>
<div className="flex flex-col items-start h-[51vh] overflow-y-auto bg-transparent flex-grow gap-2 p-4">
<div className="flex h-[51vh] flex-grow flex-col items-start gap-2 overflow-y-auto bg-transparent p-4">
{logs?.map((log, index) => {
if (index === logs.length - 1) {
return (
@@ -211,18 +150,18 @@ function Log({
return (
<details
id={log.id}
className="group bg-transparent [&_summary::-webkit-details-marker]:hidden w-full hover:bg-[#F9F9F9] hover:dark:bg-dark-charcoal rounded-xl group-open:opacity-80 [&[open]]:border [&[open]]:border-[#d9d9d9]"
className="group w-full rounded-xl bg-transparent hover:bg-[#F9F9F9] group-open:opacity-80 hover:dark:bg-dark-charcoal [&[open]]:border [&[open]]:border-[#d9d9d9] [&_summary::-webkit-details-marker]:hidden"
onToggle={(e) => {
if ((e.target as HTMLDetailsElement).open) {
onToggle(log.id);
}
}}
>
<summary className="flex flex-row items-start gap-2 text-gray-900 cursor-pointer px-4 py-3 group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B] group-open:rounded-t-xl p-2">
<summary className="flex cursor-pointer flex-row items-start gap-2 p-2 px-4 py-3 text-gray-900 group-open:rounded-t-xl group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B]">
<img
src={ChevronRight}
alt="Expand log entry"
className="mt-[3px] w-3 h-3 transition duration-300 group-open:rotate-90"
className="mt-[3px] h-3 w-3 transition duration-300 group-open:rotate-90"
/>
<span className="flex flex-row gap-2">
<h2 className="text-xs text-black/60 dark:text-bright-gray">{`${log.timestamp}`}</h2>
@@ -236,8 +175,8 @@ function Log({
</h2>
</span>
</summary>
<div className="px-4 py-3 group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B] group-open:rounded-b-xl">
<p className="px-2 leading-relaxed text-gray-700 dark:text-gray-400 text-xs break-words">
<div className="px-4 py-3 group-open:rounded-b-xl group-open:bg-[#F1F1F1] dark:group-open:bg-[#1B1B1B]">
<p className="break-words px-2 text-xs leading-relaxed text-gray-700 dark:text-gray-400">
{JSON.stringify(filteredLog, null, 2)}
</p>
<div className="my-px w-fit">

View File

@@ -1,7 +1,13 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation, useNavigate, Routes, Route, Navigate } from 'react-router-dom';
import {
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import userService from '../api/services/userService';
import SettingsBar from '../components/SettingsBar';
@@ -10,12 +16,11 @@ import { Doc } from '../models/misc';
import {
selectPaginatedDocuments,
selectSourceDocs,
selectToken,
setPaginatedDocuments,
setSourceDocs,
selectToken,
} from '../preferences/preferenceSlice';
import Analytics from './Analytics';
import APIKeys from './APIKeys';
import Documents from './Documents';
import General from './General';
import Logs from './Logs';
@@ -27,13 +32,16 @@ export default function Settings() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(null);
const [widgetScreenshot, setWidgetScreenshot] = React.useState<File | null>(
null,
);
const getActiveTabFromPath = () => {
const path = location.pathname;
if (path.includes('/settings/documents')) return t('settings.documents.label');
if (path.includes('/settings/apikeys')) return t('settings.apiKeys.label');
if (path.includes('/settings/analytics')) return t('settings.analytics.label');
if (path.includes('/settings/documents'))
return t('settings.documents.label');
if (path.includes('/settings/analytics'))
return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
if (path.includes('/settings/tools')) return t('settings.tools.label');
if (path.includes('/settings/widgets')) return 'Widgets';
@@ -45,9 +53,10 @@ export default function Settings() {
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === t('settings.general.label')) navigate('/settings');
else if (tab === t('settings.documents.label')) navigate('/settings/documents');
else if (tab === t('settings.apiKeys.label')) navigate('/settings/apikeys');
else if (tab === t('settings.analytics.label')) navigate('/settings/analytics');
else if (tab === t('settings.documents.label'))
navigate('/settings/documents');
else if (tab === t('settings.analytics.label'))
navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
else if (tab === t('settings.tools.label')) navigate('/settings/tools');
else if (tab === 'Widgets') navigate('/settings/widgets');
@@ -93,29 +102,37 @@ export default function Settings() {
};
return (
<div className="p-4 md:p-12 h-full overflow-auto">
<div className="h-full overflow-auto p-4 md:p-12">
<p className="text-2xl font-bold text-eerie-black dark:text-bright-gray">
{t('settings.label')}
</p>
<SettingsBar activeTab={activeTab} setActiveTab={(tab) => handleTabChange(tab as string)} />
<SettingsBar
activeTab={activeTab}
setActiveTab={(tab) => handleTabChange(tab as string)}
/>
<Routes>
<Route index element={<General />} />
<Route path="documents" element={
<Route
path="documents"
element={
<Documents
paginatedDocuments={paginatedDocuments}
handleDeleteDocument={handleDeleteClick}
/>
} />
<Route path="apikeys" element={<APIKeys />} />
}
/>
<Route path="analytics" element={<Analytics />} />
<Route path="logs" element={<Logs />} />
<Route path="tools" element={<Tools />} />
<Route path="widgets" element={
<Route
path="widgets"
element={
<Widgets
widgetScreenshot={widgetScreenshot}
onWidgetScreenshotChange={updateWidgetScreenshot}
/>
} />
}
/>
<Route path="*" element={<Navigate to="/settings" replace />} />
</Routes>
</div>

View File

@@ -1,4 +1,5 @@
import { configureStore } from '@reduxjs/toolkit';
import { conversationSlice } from './conversation/conversationSlice';
import { sharedConversationSlice } from './conversation/sharedConversationSlice';
import {
@@ -40,6 +41,8 @@ const preloadedState: { preference: Preference } = {
],
modalState: 'INACTIVE',
paginatedDocuments: null,
agents: null,
selectedAgent: null,
},
};
const store = configureStore({

View File

@@ -0,0 +1,10 @@
export function getOS() {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf('Mac') !== -1) return 'mac';
if (userAgent.indexOf('Win') !== -1) return 'win';
return 'linux';
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}

782
setup.ps1 Normal file
View File

@@ -0,0 +1,782 @@
# DocsGPT Setup PowerShell Script for Windows
# PowerShell -ExecutionPolicy Bypass -File .\setup.ps1
# Script execution policy - uncomment if needed
# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
# Set error action preference
$ErrorActionPreference = "Stop"
# Get current script directory
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Definition
$COMPOSE_FILE = Join-Path -Path $SCRIPT_DIR -ChildPath "deployment\docker-compose.yaml"
$ENV_FILE = Join-Path -Path $SCRIPT_DIR -ChildPath ".env"
# Function to write colored text
function Write-ColorText {
param (
[Parameter(Mandatory=$true)][string]$Text,
[Parameter()][string]$ForegroundColor = "White",
[Parameter()][switch]$Bold
)
$params = @{
ForegroundColor = $ForegroundColor
NoNewline = $false
}
if ($Bold) {
# PowerShell doesn't have bold
Write-Host $Text @params
} else {
Write-Host $Text @params
}
}
# Animation function (Windows PowerShell version of animate_dino)
function Animate-Dino {
[Console]::CursorVisible = $false
# Clear screen
Clear-Host
# Static DocsGPT text
$static_text = @(
" ____ ____ ____ _____ "
" | _ \ ___ ___ ___ / ___| _ \_ _|"
" | | | |/ _ \ / __/ __| | _| |_) || | "
" | |_| | (_) | (__\__ \ |_| | __/ | | "
" |____/ \___/ \___|___/\____|_| |_| "
" "
)
# Print static text
foreach ($line in $static_text) {
Write-Host $line
}
# Dino ASCII art
$dino_lines = @(
" ######### "
" ############# "
" ##################"
" ####################"
" ######################"
" ####################### ######"
" ############################### "
" ################################## "
" ################ ############ "
" ################## ########## "
" ##################### ######## "
" ###################### ###### ### "
" ############ ########## #### ## "
" ############# ######### ##### "
" ############## ######### "
" ############## ########## "
"############ ####### "
" ###### ###### #### "
" ################ "
" ################# "
)
# Save cursor position
$cursorPos = $Host.UI.RawUI.CursorPosition
# Build-up animation
for ($i = 0; $i -lt $dino_lines.Count; $i++) {
# Restore cursor position
$Host.UI.RawUI.CursorPosition = $cursorPos
# Display lines up to current index
for ($j = 0; $j -le $i; $j++) {
Write-Host $dino_lines[$j]
}
# Slow down animation
Start-Sleep -Milliseconds 50
}
# Pause at end of animation
Start-Sleep -Milliseconds 500
# Clear the animation
$Host.UI.RawUI.CursorPosition = $cursorPos
# Clear from cursor to end of screen
for ($i = 0; $i -lt $dino_lines.Count; $i++) {
Write-Host (" " * $dino_lines[0].Length)
}
# Restore cursor position for next output
$Host.UI.RawUI.CursorPosition = $cursorPos
# Show cursor again
[Console]::CursorVisible = $true
}
# Check and start Docker function
function Check-AndStartDocker {
# Check if Docker is running
try {
$dockerRunning = $false
# First try with 'docker info' which should work if Docker is fully operational
try {
$dockerInfo = docker info 2>&1
# If we get here without an exception, Docker is running
Write-ColorText "Docker is already running." -ForegroundColor "Green"
return $true
} catch {
# Docker info command failed
}
# Check if Docker process is running
$dockerProcess = Get-Process "Docker Desktop" -ErrorAction SilentlyContinue
if ($dockerProcess) {
# Docker Desktop is running, but might not be fully initialized
Write-ColorText "Docker Desktop is starting up. Waiting for it to be ready..." -ForegroundColor "Yellow"
# Wait for Docker to become operational
$attempts = 0
$maxAttempts = 30
while ($attempts -lt $maxAttempts) {
try {
$null = docker ps 2>&1
Write-ColorText "Docker is now operational." -ForegroundColor "Green"
return $true
} catch {
Write-Host "." -NoNewline
Start-Sleep -Seconds 2
$attempts++
}
}
Write-ColorText "`nDocker Desktop is running but not responding to commands. Please check Docker status." -ForegroundColor "Red"
return $false
}
# Docker is not running, attempt to start it
Write-ColorText "Docker is not running. Attempting to start Docker Desktop..." -ForegroundColor "Yellow"
# Docker Desktop locations to check
$dockerPaths = @(
"${env:ProgramFiles}\Docker\Docker\Docker Desktop.exe",
"${env:ProgramFiles(x86)}\Docker\Docker\Docker Desktop.exe",
"$env:LOCALAPPDATA\Docker\Docker\Docker Desktop.exe"
)
$dockerPath = $null
foreach ($path in $dockerPaths) {
if (Test-Path $path) {
$dockerPath = $path
break
}
}
if ($null -eq $dockerPath) {
Write-ColorText "Docker Desktop not found. Please install Docker Desktop or start it manually." -ForegroundColor "Red"
return $false
}
# Start Docker Desktop
try {
Start-Process $dockerPath
Write-Host -NoNewline "Waiting for Docker to start"
# Wait for Docker to be ready
$attempts = 0
$maxAttempts = 60 # 60 x 2 seconds = maximum 2 minutes wait
while ($attempts -lt $maxAttempts) {
try {
$null = docker ps 2>&1
Write-Host "`nDocker has started successfully!"
return $true
} catch {
# Show waiting animation
Write-Host -NoNewline "."
Start-Sleep -Seconds 2
$attempts++
if ($attempts % 3 -eq 0) {
Write-Host "`r" -NoNewline
Write-Host "Waiting for Docker to start " -NoNewline
}
}
}
Write-ColorText "`nDocker did not start within the expected time. Please start Docker Desktop manually." -ForegroundColor "Red"
return $false
} catch {
Write-ColorText "Failed to start Docker Desktop. Please start it manually." -ForegroundColor "Red"
return $false
}
} catch {
Write-ColorText "Error checking Docker status: $_" -ForegroundColor "Red"
return $false
}
}
# Function to prompt the user for the main menu choice
function Prompt-MainMenu {
Write-Host ""
Write-ColorText "Welcome to DocsGPT Setup!" -ForegroundColor "White" -Bold
Write-ColorText "How would you like to proceed?" -ForegroundColor "White"
Write-ColorText "1) Use DocsGPT Public API Endpoint (simple and free)" -ForegroundColor "Yellow"
Write-ColorText "2) Serve Local (with Ollama)" -ForegroundColor "Yellow"
Write-ColorText "3) Connect Local Inference Engine" -ForegroundColor "Yellow"
Write-ColorText "4) Connect Cloud API Provider" -ForegroundColor "Yellow"
Write-Host ""
$script:main_choice = Read-Host "Choose option (1-4)"
}
# Function to prompt for Local Inference Engine options
function Prompt-LocalInferenceEngineOptions {
Clear-Host
Write-Host ""
Write-ColorText "Connect Local Inference Engine" -ForegroundColor "White" -Bold
Write-ColorText "Choose your local inference engine:" -ForegroundColor "White"
Write-ColorText "1) LLaMa.cpp" -ForegroundColor "Yellow"
Write-ColorText "2) Ollama" -ForegroundColor "Yellow"
Write-ColorText "3) Text Generation Inference (TGI)" -ForegroundColor "Yellow"
Write-ColorText "4) SGLang" -ForegroundColor "Yellow"
Write-ColorText "5) vLLM" -ForegroundColor "Yellow"
Write-ColorText "6) Aphrodite" -ForegroundColor "Yellow"
Write-ColorText "7) FriendliAI" -ForegroundColor "Yellow"
Write-ColorText "8) LMDeploy" -ForegroundColor "Yellow"
Write-ColorText "b) Back to Main Menu" -ForegroundColor "Yellow"
Write-Host ""
$script:engine_choice = Read-Host "Choose option (1-8, or b)"
}
# Function to prompt for Cloud API Provider options
function Prompt-CloudAPIProviderOptions {
Clear-Host
Write-Host ""
Write-ColorText "Connect Cloud API Provider" -ForegroundColor "White" -Bold
Write-ColorText "Choose your Cloud API Provider:" -ForegroundColor "White"
Write-ColorText "1) OpenAI" -ForegroundColor "Yellow"
Write-ColorText "2) Google (Vertex AI, Gemini)" -ForegroundColor "Yellow"
Write-ColorText "3) Anthropic (Claude)" -ForegroundColor "Yellow"
Write-ColorText "4) Groq" -ForegroundColor "Yellow"
Write-ColorText "5) HuggingFace Inference API" -ForegroundColor "Yellow"
Write-ColorText "6) Azure OpenAI" -ForegroundColor "Yellow"
Write-ColorText "7) Novita" -ForegroundColor "Yellow"
Write-ColorText "b) Back to Main Menu" -ForegroundColor "Yellow"
Write-Host ""
$script:provider_choice = Read-Host "Choose option (1-7, or b)"
}
# Function to prompt for Ollama CPU/GPU options
function Prompt-OllamaOptions {
Clear-Host
Write-Host ""
Write-ColorText "Serve Local with Ollama" -ForegroundColor "White" -Bold
Write-ColorText "Choose how to serve Ollama:" -ForegroundColor "White"
Write-ColorText "1) CPU" -ForegroundColor "Yellow"
Write-ColorText "2) GPU" -ForegroundColor "Yellow"
Write-ColorText "b) Back to Main Menu" -ForegroundColor "Yellow"
Write-Host ""
$script:ollama_choice = Read-Host "Choose option (1-2, or b)"
}
# 1) Use DocsGPT Public API Endpoint (simple and free)
function Use-DocsPublicAPIEndpoint {
Write-Host ""
Write-ColorText "Setting up DocsGPT Public API Endpoint..." -ForegroundColor "White"
# Create .env file
"LLM_NAME=docsgpt" | Out-File -FilePath $ENV_FILE -Encoding utf8 -Force
"VITE_API_STREAMING=true" | Add-Content -Path $ENV_FILE -Encoding utf8
Write-ColorText ".env file configured for DocsGPT Public API." -ForegroundColor "Green"
# Start Docker if needed
$dockerRunning = Check-AndStartDocker
if (-not $dockerRunning) {
Write-ColorText "Docker is required but could not be started. Please start Docker Desktop manually and try again." -ForegroundColor "Red"
return
}
Write-Host ""
Write-ColorText "Starting Docker Compose..." -ForegroundColor "White"
# Run Docker compose commands
try {
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build
if ($LASTEXITCODE -ne 0) {
throw "Docker compose build failed with exit code $LASTEXITCODE"
}
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker compose up failed with exit code $LASTEXITCODE"
}
Write-Host ""
Write-ColorText "DocsGPT is now running on http://localhost:5173" -ForegroundColor "Green"
Write-ColorText "You can stop the application by running: docker compose -f `"$COMPOSE_FILE`" down" -ForegroundColor "Yellow"
}
catch {
Write-Host ""
Write-ColorText "Error starting Docker Compose: $_" -ForegroundColor "Red"
Write-ColorText "Please ensure Docker Compose is installed and in your PATH." -ForegroundColor "Red"
Write-ColorText "Refer to Docker documentation for installation instructions: https://docs.docker.com/compose/install/" -ForegroundColor "Red"
exit 1 # Exit script with error
}
}
# 2) Serve Local (with Ollama)
function Serve-LocalOllama {
$script:model_name = ""
$default_model = "llama3.2:1b"
$docker_compose_file_suffix = ""
function Get-ModelNameOllama {
$model_name_input = Read-Host "Enter Ollama Model Name (press Enter for default: $default_model (1.3GB))"
if ([string]::IsNullOrEmpty($model_name_input)) {
$script:model_name = $default_model
} else {
$script:model_name = $model_name_input
}
}
while ($true) {
Clear-Host
Prompt-OllamaOptions
switch ($ollama_choice) {
"1" { # CPU
$docker_compose_file_suffix = "cpu"
Get-ModelNameOllama
break
}
"2" { # GPU
Write-Host ""
Write-ColorText "For this option to work correctly you need to have a supported GPU and configure Docker to utilize it." -ForegroundColor "Yellow"
Write-ColorText "Refer to: https://hub.docker.com/r/ollama/ollama for more information." -ForegroundColor "Yellow"
$confirm_gpu = Read-Host "Continue with GPU setup? (y/b)"
if ($confirm_gpu -eq "y" -or $confirm_gpu -eq "Y") {
$docker_compose_file_suffix = "gpu"
Get-ModelNameOllama
break
}
elseif ($confirm_gpu -eq "b" -or $confirm_gpu -eq "B") {
Clear-Host
return
}
else {
Write-Host ""
Write-ColorText "Invalid choice. Please choose y or b." -ForegroundColor "Red"
Start-Sleep -Seconds 1
}
}
"b" { Clear-Host; return }
"B" { Clear-Host; return }
default {
Write-Host ""
Write-ColorText "Invalid choice. Please choose 1-2, or b." -ForegroundColor "Red"
Start-Sleep -Seconds 1
}
}
if (-not [string]::IsNullOrEmpty($docker_compose_file_suffix)) {
break
}
}
Write-Host ""
Write-ColorText "Configuring for Ollama ($($docker_compose_file_suffix.ToUpper()))..." -ForegroundColor "White"
# Create .env file
"API_KEY=xxxx" | Out-File -FilePath $ENV_FILE -Encoding utf8 -Force
"LLM_NAME=openai" | Add-Content -Path $ENV_FILE -Encoding utf8
"MODEL_NAME=$model_name" | Add-Content -Path $ENV_FILE -Encoding utf8
"VITE_API_STREAMING=true" | Add-Content -Path $ENV_FILE -Encoding utf8
"OPENAI_BASE_URL=http://host.docker.internal:11434/v1" | Add-Content -Path $ENV_FILE -Encoding utf8
"EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" | Add-Content -Path $ENV_FILE -Encoding utf8
Write-ColorText ".env file configured for Ollama ($($docker_compose_file_suffix.ToUpper()))." -ForegroundColor "Green"
Write-ColorText "Note: MODEL_NAME is set to '$model_name'. You can change it later in the .env file." -ForegroundColor "Yellow"
# Start Docker if needed
$dockerRunning = Check-AndStartDocker
if (-not $dockerRunning) {
Write-ColorText "Docker is required but could not be started. Please start Docker Desktop manually and try again." -ForegroundColor "Red"
return
}
# Setup compose file paths
$optional_compose = Join-Path -Path (Split-Path -Parent $COMPOSE_FILE) -ChildPath "optional\docker-compose.optional.ollama-$docker_compose_file_suffix.yaml"
try {
Write-Host ""
Write-ColorText "Starting Docker Compose with Ollama ($docker_compose_file_suffix)..." -ForegroundColor "White"
# Build the containers
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" -f "$optional_compose" build
if ($LASTEXITCODE -ne 0) {
throw "Docker compose build failed with exit code $LASTEXITCODE"
}
# Start the containers
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" -f "$optional_compose" up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker compose up failed with exit code $LASTEXITCODE"
}
# Wait for Ollama container to be ready
Write-ColorText "Waiting for Ollama container to be ready..." -ForegroundColor "White"
$ollamaReady = $false
$maxAttempts = 30 # Maximum number of attempts (30 x 5 seconds = 2.5 minutes)
$attempts = 0
while (-not $ollamaReady -and $attempts -lt $maxAttempts) {
$containerStatus = & docker compose -f "$COMPOSE_FILE" -f "$optional_compose" ps --services --filter "status=running" --format "{{.Service}}"
if ($containerStatus -like "*ollama*") {
$ollamaReady = $true
Write-ColorText "Ollama container is running." -ForegroundColor "Green"
} else {
Write-Host "Ollama container not yet ready, waiting... (Attempt $($attempts+1)/$maxAttempts)"
Start-Sleep -Seconds 5
$attempts++
}
}
if (-not $ollamaReady) {
Write-ColorText "Ollama container did not start within the expected time. Please check Docker logs for errors." -ForegroundColor "Red"
return
}
# Pull the Ollama model
Write-ColorText "Pulling $model_name model for Ollama..." -ForegroundColor "White"
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" -f "$optional_compose" exec -it ollama ollama pull "$model_name"
Write-Host ""
Write-ColorText "DocsGPT is now running with Ollama ($docker_compose_file_suffix) on http://localhost:5173" -ForegroundColor "Green"
Write-ColorText "You can stop the application by running: docker compose -f `"$COMPOSE_FILE`" -f `"$optional_compose`" down" -ForegroundColor "Yellow"
}
catch {
Write-Host ""
Write-ColorText "Error running Docker Compose: $_" -ForegroundColor "Red"
Write-ColorText "Please ensure Docker Compose is installed and in your PATH." -ForegroundColor "Red"
Write-ColorText "Refer to Docker documentation for installation instructions: https://docs.docker.com/compose/install/" -ForegroundColor "Red"
exit 1
}
}
# 3) Connect Local Inference Engine
function Connect-LocalInferenceEngine {
$script:engine_name = ""
$script:openai_base_url = ""
$script:model_name = ""
function Get-ModelName {
$model_name_input = Read-Host "Enter Model Name (press Enter for None)"
if ([string]::IsNullOrEmpty($model_name_input)) {
$script:model_name = "None"
} else {
$script:model_name = $model_name_input
}
}
while ($true) {
Clear-Host
Prompt-LocalInferenceEngineOptions
switch ($engine_choice) {
"1" { # LLaMa.cpp
$script:engine_name = "LLaMa.cpp"
$script:openai_base_url = "http://localhost:8000/v1"
Get-ModelName
break
}
"2" { # Ollama
$script:engine_name = "Ollama"
$script:openai_base_url = "http://localhost:11434/v1"
Get-ModelName
break
}
"3" { # TGI
$script:engine_name = "TGI"
$script:openai_base_url = "http://localhost:8080/v1"
Get-ModelName
break
}
"4" { # SGLang
$script:engine_name = "SGLang"
$script:openai_base_url = "http://localhost:30000/v1"
Get-ModelName
break
}
"5" { # vLLM
$script:engine_name = "vLLM"
$script:openai_base_url = "http://localhost:8000/v1"
Get-ModelName
break
}
"6" { # Aphrodite
$script:engine_name = "Aphrodite"
$script:openai_base_url = "http://localhost:2242/v1"
Get-ModelName
break
}
"7" { # FriendliAI
$script:engine_name = "FriendliAI"
$script:openai_base_url = "http://localhost:8997/v1"
Get-ModelName
break
}
"8" { # LMDeploy
$script:engine_name = "LMDeploy"
$script:openai_base_url = "http://localhost:23333/v1"
Get-ModelName
break
}
"b" { Clear-Host; return }
"B" { Clear-Host; return }
default {
Write-Host ""
Write-ColorText "Invalid choice. Please choose 1-8, or b." -ForegroundColor "Red"
Start-Sleep -Seconds 1
}
}
if (-not [string]::IsNullOrEmpty($script:engine_name)) {
break
}
}
Write-Host ""
Write-ColorText "Configuring for Local Inference Engine: $engine_name..." -ForegroundColor "White"
# Create .env file
"API_KEY=None" | Out-File -FilePath $ENV_FILE -Encoding utf8 -Force
"LLM_NAME=openai" | Add-Content -Path $ENV_FILE -Encoding utf8
"MODEL_NAME=$model_name" | Add-Content -Path $ENV_FILE -Encoding utf8
"VITE_API_STREAMING=true" | Add-Content -Path $ENV_FILE -Encoding utf8
"OPENAI_BASE_URL=$openai_base_url" | Add-Content -Path $ENV_FILE -Encoding utf8
"EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2" | Add-Content -Path $ENV_FILE -Encoding utf8
Write-ColorText ".env file configured for $engine_name with OpenAI API format." -ForegroundColor "Green"
Write-ColorText "Note: MODEL_NAME is set to '$model_name'. You can change it later in the .env file." -ForegroundColor "Yellow"
# Start Docker if needed
$dockerRunning = Check-AndStartDocker
if (-not $dockerRunning) {
Write-ColorText "Docker is required but could not be started. Please start Docker Desktop manually and try again." -ForegroundColor "Red"
return
}
try {
Write-Host ""
Write-ColorText "Starting Docker Compose..." -ForegroundColor "White"
# Build the containers
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" build
if ($LASTEXITCODE -ne 0) {
throw "Docker compose build failed with exit code $LASTEXITCODE"
}
# Start the containers
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker compose up failed with exit code $LASTEXITCODE"
}
Write-Host ""
Write-ColorText "DocsGPT is now configured to connect to $engine_name at $openai_base_url" -ForegroundColor "Green"
Write-ColorText "Ensure your $engine_name inference server is running at that address" -ForegroundColor "Yellow"
Write-Host ""
Write-ColorText "DocsGPT is running at http://localhost:5173" -ForegroundColor "Green"
Write-ColorText "You can stop the application by running: docker compose -f `"$COMPOSE_FILE`" down" -ForegroundColor "Yellow"
}
catch {
Write-Host ""
Write-ColorText "Error running Docker Compose: $_" -ForegroundColor "Red"
Write-ColorText "Please ensure Docker Compose is installed and in your PATH." -ForegroundColor "Red"
Write-ColorText "Refer to Docker documentation for installation instructions: https://docs.docker.com/compose/install/" -ForegroundColor "Red"
exit 1
}
}
# 4) Connect Cloud API Provider
function Connect-CloudAPIProvider {
$script:provider_name = ""
$script:llm_name = ""
$script:model_name = ""
$script:api_key = ""
function Get-APIKey {
Write-ColorText "Your API key will be stored locally in the .env file and will not be sent anywhere else" -ForegroundColor "Yellow"
$script:api_key = Read-Host "Please enter your API key"
}
while ($true) {
Clear-Host
Prompt-CloudAPIProviderOptions
switch ($provider_choice) {
"1" { # OpenAI
$script:provider_name = "OpenAI"
$script:llm_name = "openai"
$script:model_name = "gpt-4o"
Get-APIKey
break
}
"2" { # Google
$script:provider_name = "Google (Vertex AI, Gemini)"
$script:llm_name = "google"
$script:model_name = "gemini-2.0-flash"
Get-APIKey
break
}
"3" { # Anthropic
$script:provider_name = "Anthropic (Claude)"
$script:llm_name = "anthropic"
$script:model_name = "claude-3-5-sonnet-latest"
Get-APIKey
break
}
"4" { # Groq
$script:provider_name = "Groq"
$script:llm_name = "groq"
$script:model_name = "llama-3.1-8b-instant"
Get-APIKey
break
}
"5" { # HuggingFace Inference API
$script:provider_name = "HuggingFace Inference API"
$script:llm_name = "huggingface"
$script:model_name = "meta-llama/Llama-3.1-8B-Instruct"
Get-APIKey
break
}
"6" { # Azure OpenAI
$script:provider_name = "Azure OpenAI"
$script:llm_name = "azure_openai"
$script:model_name = "gpt-4o"
Get-APIKey
break
}
"7" { # Novita
$script:provider_name = "Novita"
$script:llm_name = "novita"
$script:model_name = "deepseek/deepseek-r1"
Get-APIKey
break
}
"b" { Clear-Host; return }
"B" { Clear-Host; return }
default {
Write-Host ""
Write-ColorText "Invalid choice. Please choose 1-7, or b." -ForegroundColor "Red"
Start-Sleep -Seconds 1
}
}
if (-not [string]::IsNullOrEmpty($script:provider_name)) {
break
}
}
Write-Host ""
Write-ColorText "Configuring for Cloud API Provider: $provider_name..." -ForegroundColor "White"
# Create .env file
"API_KEY=$api_key" | Out-File -FilePath $ENV_FILE -Encoding utf8 -Force
"LLM_NAME=$llm_name" | Add-Content -Path $ENV_FILE -Encoding utf8
"MODEL_NAME=$model_name" | Add-Content -Path $ENV_FILE -Encoding utf8
"VITE_API_STREAMING=true" | Add-Content -Path $ENV_FILE -Encoding utf8
Write-ColorText ".env file configured for $provider_name." -ForegroundColor "Green"
# Start Docker if needed
$dockerRunning = Check-AndStartDocker
if (-not $dockerRunning) {
Write-ColorText "Docker is required but could not be started. Please start Docker Desktop manually and try again." -ForegroundColor "Red"
return
}
try {
Write-Host ""
Write-ColorText "Starting Docker Compose..." -ForegroundColor "White"
# Run Docker compose commands
& docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up -d --build
if ($LASTEXITCODE -ne 0) {
throw "Docker compose build or up failed with exit code $LASTEXITCODE"
}
Write-Host ""
Write-ColorText "DocsGPT is now configured to use $provider_name on http://localhost:5173" -ForegroundColor "Green"
Write-ColorText "You can stop the application by running: docker compose -f `"$COMPOSE_FILE`" down" -ForegroundColor "Yellow"
}
catch {
Write-Host ""
Write-ColorText "Error running Docker Compose: $_" -ForegroundColor "Red"
Write-ColorText "Please ensure Docker Compose is installed and in your PATH." -ForegroundColor "Red"
Write-ColorText "Refer to Docker documentation for installation instructions: https://docs.docker.com/compose/install/" -ForegroundColor "Red"
exit 1
}
}
# Main script execution
Animate-Dino
while ($true) {
Clear-Host
Prompt-MainMenu
$exitLoop = $false # Add this flag
switch ($main_choice) {
"1" {
Use-DocsPublicAPIEndpoint
$exitLoop = $true # Set flag to true on completion
break
}
"2" {
Serve-LocalOllama
# Only exit the loop if user didn't press "b" to go back
if ($ollama_choice -ne "b" -and $ollama_choice -ne "B") {
$exitLoop = $true
}
break
}
"3" {
Connect-LocalInferenceEngine
# Only exit the loop if user didn't press "b" to go back
if ($engine_choice -ne "b" -and $engine_choice -ne "B") {
$exitLoop = $true
}
break
}
"4" {
Connect-CloudAPIProvider
# Only exit the loop if user didn't press "b" to go back
if ($provider_choice -ne "b" -and $provider_choice -ne "B") {
$exitLoop = $true
}
break
}
default {
Write-Host ""
Write-ColorText "Invalid choice. Please choose 1-4." -ForegroundColor "Red"
Start-Sleep -Seconds 1
}
}
# Only break out of the loop if a function completed successfully
if ($exitLoop) {
break
}
}
Write-Host ""
Write-ColorText "DocsGPT Setup Complete." -ForegroundColor "Green"
exit 0