Compare commits
224 Commits
tool-proxi
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2037848b4e | ||
|
|
50fbe3d5af | ||
|
|
8af9a5e921 | ||
|
|
9807788ecb | ||
|
|
5e2f329f15 | ||
|
|
9572a7adaa | ||
|
|
1ba94f4f5f | ||
|
|
237afa0a3a | ||
|
|
9d8073d468 | ||
|
|
3bf7f67adf | ||
|
|
594ce05292 | ||
|
|
fe02ca68d5 | ||
|
|
21ef27ee9b | ||
|
|
09d37f669f | ||
|
|
416b776062 | ||
|
|
5ed05d4020 | ||
|
|
4004bfb5ef | ||
|
|
45aace8966 | ||
|
|
d9fc623dcb | ||
|
|
dbb822f6b0 | ||
|
|
3d64dffc32 | ||
|
|
b2809b2e9a | ||
|
|
29e89d2965 | ||
|
|
e7d54a639e | ||
|
|
22df98e9bb | ||
|
|
0d45c44c6f | ||
|
|
63c6912841 | ||
|
|
73bce73034 | ||
|
|
8babb6e68f | ||
|
|
d1d28df8a1 | ||
|
|
23bfd4683c | ||
|
|
a52a3e3158 | ||
|
|
44e524e3c3 | ||
|
|
9a430f73e2 | ||
|
|
fdea40ec11 | ||
|
|
526d340849 | ||
|
|
fe95f6ad81 | ||
|
|
39e73c37ab | ||
|
|
39b36b6857 | ||
|
|
44e98748c5 | ||
|
|
8a7aeee955 | ||
|
|
1c7befb8d3 | ||
|
|
d5d59ac62c | ||
|
|
562f0762a0 | ||
|
|
e46aedce21 | ||
|
|
57cc09b1d7 | ||
|
|
cbfa5a5118 | ||
|
|
ec7f14b82d | ||
|
|
6520be5b85 | ||
|
|
d84c416421 | ||
|
|
32803c89a3 | ||
|
|
a86bcb5c29 | ||
|
|
7d76a33790 | ||
|
|
24bb2e469d | ||
|
|
e1aa2cc0b8 | ||
|
|
d073947f3b | ||
|
|
3243740dd1 | ||
|
|
f9bd566a3b | ||
|
|
183251487c | ||
|
|
ff532210f7 | ||
|
|
d0a04d9801 | ||
|
|
ea6533db4e | ||
|
|
89d5e7bee5 | ||
|
|
7e6cdee592 | ||
|
|
990c2fb416 | ||
|
|
09e054c6aa | ||
|
|
23f648f53a | ||
|
|
07fa656e7c | ||
|
|
7858c48f11 | ||
|
|
e56d54c3f0 | ||
|
|
f37ca95c10 | ||
|
|
72e51bb072 | ||
|
|
dcfcbf54be | ||
|
|
204936b2d0 | ||
|
|
98856b39ac | ||
|
|
ad5f707486 | ||
|
|
5ecfb0ce6d | ||
|
|
2147b3f06f | ||
|
|
7daed3daaf | ||
|
|
481df4d604 | ||
|
|
cf333873fd | ||
|
|
ae700e8f3a | ||
|
|
16386a9524 | ||
|
|
7e7ce276b2 | ||
|
|
71c6b41b83 | ||
|
|
4b2faae29a | ||
|
|
7e28e562d0 | ||
|
|
93c2e2a597 | ||
|
|
c45d13d834 | ||
|
|
330276cdf7 | ||
|
|
22c7015c69 | ||
|
|
cc67d4a1e2 | ||
|
|
eeb9da696f | ||
|
|
4979e1ac9a | ||
|
|
545353dabf | ||
|
|
545376740c | ||
|
|
8289b02ab0 | ||
|
|
fc0060662b | ||
|
|
df9d432d29 | ||
|
|
76fd6e15cc | ||
|
|
06982efda5 | ||
|
|
3cd9a72495 | ||
|
|
0ce27f274a | ||
|
|
e60f78ac4a | ||
|
|
637d3a24a1 | ||
|
|
24c8b24b1f | ||
|
|
5ad34e2216 | ||
|
|
64c42f0ddf | ||
|
|
0a31ddaae6 | ||
|
|
38476cfeb8 | ||
|
|
decc31f1f0 | ||
|
|
ea0aa64330 | ||
|
|
e9a6044645 | ||
|
|
474d700df2 | ||
|
|
c50ff6faa3 | ||
|
|
c8efef8f04 | ||
|
|
1d22f77568 | ||
|
|
5aa51f5f36 | ||
|
|
335c21c48a | ||
|
|
c35d1cecfe | ||
|
|
0d3e6157cd | ||
|
|
68e4cf4d14 | ||
|
|
9454150f7d | ||
|
|
0a0e16547e | ||
|
|
0aec1b9969 | ||
|
|
3e1ec23409 | ||
|
|
2f9f428a2f | ||
|
|
da15cde49c | ||
|
|
e6ed37139a | ||
|
|
377e33c148 | ||
|
|
e567d88951 | ||
|
|
89b2937b11 | ||
|
|
142ed75468 | ||
|
|
d80eeb044c | ||
|
|
7c69e99914 | ||
|
|
5e1aaf5a44 | ||
|
|
ad610d2f90 | ||
|
|
02934452d6 | ||
|
|
8b054010e1 | ||
|
|
5b77f3839b | ||
|
|
231b792452 | ||
|
|
b468e0c164 | ||
|
|
fa1f9d7009 | ||
|
|
c5a8f3abcd | ||
|
|
dfe6a8d3e3 | ||
|
|
292257770c | ||
|
|
b4c6b2b08b | ||
|
|
6cb4577e1b | ||
|
|
456784db48 | ||
|
|
dd9ea46e58 | ||
|
|
ed3af2fac0 | ||
|
|
02f8132f3a | ||
|
|
55bd90fad9 | ||
|
|
cd7bbb45c3 | ||
|
|
6c7fc0ed22 | ||
|
|
5421bc1386 | ||
|
|
051841e566 | ||
|
|
0c68815cf2 | ||
|
|
0c1138179b | ||
|
|
1f3d1cc73e | ||
|
|
707d1332de | ||
|
|
f6c88da81b | ||
|
|
a651e6e518 | ||
|
|
bea89b93eb | ||
|
|
244c9b96a2 | ||
|
|
a37bd76950 | ||
|
|
9d70032de8 | ||
|
|
e4945b41e9 | ||
|
|
493dc8689c | ||
|
|
bdac2ffa27 | ||
|
|
b1235f3ce0 | ||
|
|
ba4bb63a1f | ||
|
|
3227b0e69c | ||
|
|
29c899627e | ||
|
|
5923781484 | ||
|
|
8bb263a2ec | ||
|
|
94c7bba168 | ||
|
|
f9ad4c068a | ||
|
|
19d68252cd | ||
|
|
72bbe3b1ce | ||
|
|
856824316b | ||
|
|
95e189d1d8 | ||
|
|
c629460acb | ||
|
|
f235a94986 | ||
|
|
632cba86e9 | ||
|
|
6b92c7eccc | ||
|
|
ab0da1abac | ||
|
|
7f31ac7bcb | ||
|
|
57a6fb31b2 | ||
|
|
fd2b6c111c | ||
|
|
302458b505 | ||
|
|
8978a4cf2d | ||
|
|
c70be12bfd | ||
|
|
4241307990 | ||
|
|
727a8ef13d | ||
|
|
7c92558ad1 | ||
|
|
45083d29a6 | ||
|
|
5089d86095 | ||
|
|
80e55ef385 | ||
|
|
b5ed98445f | ||
|
|
82d377abf5 | ||
|
|
2dbea5d1b2 | ||
|
|
4ba35d6189 | ||
|
|
cec3f987f2 | ||
|
|
55050a9f58 | ||
|
|
502dc9ec52 | ||
|
|
9c8999a3ae | ||
|
|
90db42ce3a | ||
|
|
551130f0e1 | ||
|
|
2940a60b3c | ||
|
|
76b9bc0d56 | ||
|
|
42422ccdcd | ||
|
|
e9702ae2de | ||
|
|
5c54852ebe | ||
|
|
718a86ecda | ||
|
|
1223fd2149 | ||
|
|
b09386d102 | ||
|
|
6464698b6d | ||
|
|
9230fd3bd6 | ||
|
|
7771609ea0 | ||
|
|
561a125c92 | ||
|
|
7149461d8e | ||
|
|
02c8bd06f5 | ||
|
|
9f17eb1d28 |
1
.gitignore
vendored
@@ -113,6 +113,7 @@ venv.bak/
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
.jwt_secret_key
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
21
README.md
@@ -48,10 +48,13 @@
|
||||
- [x] Add tools (Jan 2025)
|
||||
- [x] Manually updating chunks in the app UI (Feb 2025)
|
||||
- [x] Devcontainer for easy development (Feb 2025)
|
||||
- [ ] Anthropic Tool compatibility
|
||||
- [ ] Add triggerable actions / tools (webhook)
|
||||
- [x] ReACT agent (March 2025)
|
||||
- [x] Chatbots menu re-design to handle tools, agent types, and more (April 2025)
|
||||
- [x] New input box in the conversation menu (April 2025)
|
||||
- [x] Add triggerable actions / tools (webhook) (April 2025)
|
||||
- [ ] Anthropic Tool compatibility (May 2025)
|
||||
- [ ] Add OAuth 2.0 authentication for tools and sources
|
||||
- [ ] Chatbots menu re-design to handle tools, scheduling, 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!
|
||||
|
||||
@@ -92,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/**
|
||||
|
||||
@@ -107,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).
|
||||
|
||||
@@ -84,4 +84,4 @@ EXPOSE 7091
|
||||
USER appuser
|
||||
|
||||
# Start Gunicorn
|
||||
CMD ["gunicorn", "-w", "2", "--timeout", "120", "--bind", "0.0.0.0:7091", "application.wsgi:app"]
|
||||
CMD ["gunicorn", "-w", "1", "--timeout", "120", "--bind", "0.0.0.0:7091", "--preload", "application.wsgi:app"]
|
||||
|
||||
0
application/agents/__init__.py
Normal file
@@ -1,9 +1,11 @@
|
||||
from application.agents.classic_agent import ClassicAgent
|
||||
from application.agents.react_agent import ReActAgent
|
||||
|
||||
|
||||
class AgentCreator:
|
||||
agents = {
|
||||
"classic": ClassicAgent,
|
||||
"react": ReActAgent,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -11,7 +13,4 @@ class AgentCreator:
|
||||
agent_class = cls.agents.get(type.lower())
|
||||
if not agent_class:
|
||||
raise ValueError(f"No agent class found for type {type}")
|
||||
config = kwargs.pop('config', None)
|
||||
if isinstance(config, dict) and 'proxy_id' in config and 'proxy_id' not in kwargs:
|
||||
kwargs['proxy_id'] = config['proxy_id']
|
||||
return agent_class(*args, **kwargs)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from typing import Dict, Generator
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Generator, List, Optional
|
||||
|
||||
from application.agents.llm_handler import get_llm_handler
|
||||
from application.agents.tools.tool_action_parser import ToolActionParser
|
||||
@@ -6,20 +8,37 @@ from application.agents.tools.tool_manager import ToolManager
|
||||
|
||||
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 application.core.settings import settings
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
class BaseAgent(ABC):
|
||||
def __init__(
|
||||
self,
|
||||
endpoint,
|
||||
llm_name,
|
||||
gpt_model,
|
||||
api_key,
|
||||
user_api_key=None,
|
||||
decoded_token=None,
|
||||
proxy_id=None,
|
||||
endpoint: str,
|
||||
llm_name: str,
|
||||
gpt_model: str,
|
||||
api_key: str,
|
||||
user_api_key: Optional[str] = None,
|
||||
prompt: str = "",
|
||||
chat_history: Optional[List[Dict]] = None,
|
||||
decoded_token: Optional[Dict] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
):
|
||||
self.endpoint = endpoint
|
||||
self.llm_name = llm_name
|
||||
self.gpt_model = gpt_model
|
||||
self.api_key = api_key
|
||||
self.user_api_key = user_api_key
|
||||
self.prompt = prompt
|
||||
self.decoded_token = decoded_token or {}
|
||||
self.user: str = decoded_token.get("sub")
|
||||
self.tool_config: Dict = {}
|
||||
self.tools: List[Dict] = []
|
||||
self.tool_calls: List[Dict] = []
|
||||
self.chat_history: List[Dict] = chat_history if chat_history is not None else []
|
||||
self.llm = LLMCreator.create_llm(
|
||||
llm_name,
|
||||
api_key=api_key,
|
||||
@@ -27,27 +46,48 @@ class BaseAgent:
|
||||
decoded_token=decoded_token,
|
||||
)
|
||||
self.llm_handler = get_llm_handler(llm_name)
|
||||
self.gpt_model = gpt_model
|
||||
self.tools = []
|
||||
self.tool_config = {}
|
||||
self.tool_calls = []
|
||||
self.proxy_id = proxy_id
|
||||
self.attachments = attachments or []
|
||||
|
||||
def gen(self, *args, **kwargs) -> Generator[Dict, None, None]:
|
||||
raise NotImplementedError('Method "gen" must be implemented in the child class')
|
||||
@log_activity()
|
||||
def gen(
|
||||
self, query: str, retriever: BaseRetriever, log_context: LogContext = None
|
||||
) -> Generator[Dict, None, None]:
|
||||
yield from self._gen_inner(query, retriever, log_context)
|
||||
|
||||
@abstractmethod
|
||||
def _gen_inner(
|
||||
self, query: str, retriever: BaseRetriever, log_context: LogContext
|
||||
) -> Generator[Dict, None, None]:
|
||||
pass
|
||||
|
||||
def _get_tools(self, api_key: str = None) -> Dict[str, Dict]:
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
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"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
user_tools_collection = db["user_tools"]
|
||||
user_tools = user_tools_collection.find({"user": user, "status": True})
|
||||
user_tools = list(user_tools)
|
||||
tools_by_id = {str(tool["_id"]): tool for tool in user_tools}
|
||||
if hasattr(self, 'proxy_id') and self.proxy_id:
|
||||
for tool_id, tool in tools_by_id.items():
|
||||
if 'config' not in tool:
|
||||
tool['config'] = {}
|
||||
tool['config']['proxy_id'] = self.proxy_id
|
||||
return tools_by_id
|
||||
|
||||
def _build_tool_parameters(self, action):
|
||||
@@ -116,14 +156,12 @@ class BaseAgent:
|
||||
for param, details in action_data[param_type]["properties"].items():
|
||||
if param not in call_args and "value" in details:
|
||||
target_dict[param] = details["value"]
|
||||
|
||||
for param, value in call_args.items():
|
||||
for param_type, target_dict in param_types.items():
|
||||
if param_type in action_data and param in action_data[param_type].get(
|
||||
"properties", {}
|
||||
):
|
||||
target_dict[param] = value
|
||||
|
||||
tm = ToolManager(config={})
|
||||
tool = tm.load_tool(
|
||||
tool_data["name"],
|
||||
@@ -133,7 +171,6 @@ class BaseAgent:
|
||||
"method": tool_data["config"]["actions"][action_name]["method"],
|
||||
"headers": headers,
|
||||
"query_params": query_params,
|
||||
"proxy_id": self.proxy_id,
|
||||
}
|
||||
if tool_data["name"] == "api_tool"
|
||||
else tool_data["config"]
|
||||
@@ -159,3 +196,82 @@ class BaseAgent:
|
||||
self.tool_calls.append(tool_call_data)
|
||||
|
||||
return result, call_id
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
system_prompt: str,
|
||||
query: str,
|
||||
retrieved_data: List[Dict],
|
||||
) -> List[Dict]:
|
||||
docs_together = "\n".join([doc["text"] for doc in retrieved_data])
|
||||
p_chat_combine = system_prompt.replace("{summaries}", docs_together)
|
||||
messages_combine = [{"role": "system", "content": p_chat_combine}]
|
||||
|
||||
for i in self.chat_history:
|
||||
if "prompt" in i and "response" in i:
|
||||
messages_combine.append({"role": "user", "content": i["prompt"]})
|
||||
messages_combine.append({"role": "assistant", "content": i["response"]})
|
||||
if "tool_calls" in i:
|
||||
for tool_call in i["tool_calls"]:
|
||||
call_id = tool_call.get("call_id") or str(uuid.uuid4())
|
||||
|
||||
function_call_dict = {
|
||||
"function_call": {
|
||||
"name": tool_call.get("action_name"),
|
||||
"args": tool_call.get("arguments"),
|
||||
"call_id": call_id,
|
||||
}
|
||||
}
|
||||
function_response_dict = {
|
||||
"function_response": {
|
||||
"name": tool_call.get("action_name"),
|
||||
"response": {"result": tool_call.get("result")},
|
||||
"call_id": call_id,
|
||||
}
|
||||
}
|
||||
|
||||
messages_combine.append(
|
||||
{"role": "assistant", "content": [function_call_dict]}
|
||||
)
|
||||
messages_combine.append(
|
||||
{"role": "tool", "content": [function_response_dict]}
|
||||
)
|
||||
messages_combine.append({"role": "user", "content": query})
|
||||
return messages_combine
|
||||
|
||||
def _retriever_search(
|
||||
self,
|
||||
retriever: BaseRetriever,
|
||||
query: str,
|
||||
log_context: Optional[LogContext] = None,
|
||||
) -> List[Dict]:
|
||||
retrieved_data = retriever.search(query)
|
||||
if log_context:
|
||||
data = build_stack_data(retriever, exclude_attributes=["llm"])
|
||||
log_context.stacks.append({"component": "retriever", "data": data})
|
||||
return retrieved_data
|
||||
|
||||
def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None):
|
||||
resp = self.llm.gen_stream(
|
||||
model=self.gpt_model, messages=messages, tools=self.tools
|
||||
)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm, exclude_attributes=["client"])
|
||||
log_context.stacks.append({"component": "llm", "data": data})
|
||||
return resp
|
||||
|
||||
def _llm_handler(
|
||||
self,
|
||||
resp,
|
||||
tools_dict: Dict,
|
||||
messages: List[Dict],
|
||||
log_context: Optional[LogContext] = None,
|
||||
attachments: Optional[List[Dict]] = None,
|
||||
):
|
||||
resp = self.llm_handler.handle_response(
|
||||
self, resp, tools_dict, messages, attachments
|
||||
)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm_handler, exclude_attributes=["tool_calls"])
|
||||
log_context.stacks.append({"component": "llm_handler", "data": data})
|
||||
return resp
|
||||
|
||||
@@ -1,87 +1,30 @@
|
||||
import uuid
|
||||
from typing import Dict, Generator
|
||||
|
||||
from application.agents.base import BaseAgent
|
||||
from application.logging import build_stack_data, log_activity, LogContext
|
||||
from application.logging import LogContext
|
||||
|
||||
from application.retriever.base import BaseRetriever
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClassicAgent(BaseAgent):
|
||||
def __init__(
|
||||
self,
|
||||
endpoint,
|
||||
llm_name,
|
||||
gpt_model,
|
||||
api_key,
|
||||
user_api_key=None,
|
||||
prompt="",
|
||||
chat_history=None,
|
||||
decoded_token=None,
|
||||
proxy_id=None,
|
||||
):
|
||||
super().__init__(
|
||||
endpoint, llm_name, gpt_model, api_key, user_api_key, decoded_token, proxy_id
|
||||
)
|
||||
self.user = decoded_token.get("sub")
|
||||
self.prompt = prompt
|
||||
self.chat_history = chat_history if chat_history is not None else []
|
||||
|
||||
@log_activity()
|
||||
def gen(
|
||||
self, query: str, retriever: BaseRetriever, log_context: LogContext = None
|
||||
) -> Generator[Dict, None, None]:
|
||||
yield from self._gen_inner(query, retriever, log_context)
|
||||
|
||||
def _gen_inner(
|
||||
self, query: str, retriever: BaseRetriever, log_context: LogContext
|
||||
) -> Generator[Dict, None, None]:
|
||||
retrieved_data = self._retriever_search(retriever, query, log_context)
|
||||
|
||||
docs_together = "\n".join([doc["text"] for doc in retrieved_data])
|
||||
p_chat_combine = self.prompt.replace("{summaries}", docs_together)
|
||||
messages_combine = [{"role": "system", "content": p_chat_combine}]
|
||||
|
||||
if len(self.chat_history) > 0:
|
||||
for i in self.chat_history:
|
||||
if "prompt" in i and "response" in i:
|
||||
messages_combine.append({"role": "user", "content": i["prompt"]})
|
||||
messages_combine.append(
|
||||
{"role": "assistant", "content": i["response"]}
|
||||
)
|
||||
if "tool_calls" in i:
|
||||
for tool_call in i["tool_calls"]:
|
||||
call_id = tool_call.get("call_id")
|
||||
if call_id is None or call_id == "None":
|
||||
call_id = str(uuid.uuid4())
|
||||
|
||||
function_call_dict = {
|
||||
"function_call": {
|
||||
"name": tool_call.get("action_name"),
|
||||
"args": tool_call.get("arguments"),
|
||||
"call_id": call_id,
|
||||
}
|
||||
}
|
||||
function_response_dict = {
|
||||
"function_response": {
|
||||
"name": tool_call.get("action_name"),
|
||||
"response": {"result": tool_call.get("result")},
|
||||
"call_id": call_id,
|
||||
}
|
||||
}
|
||||
|
||||
messages_combine.append(
|
||||
{"role": "assistant", "content": [function_call_dict]}
|
||||
)
|
||||
messages_combine.append(
|
||||
{"role": "tool", "content": [function_response_dict]}
|
||||
)
|
||||
messages_combine.append({"role": "user", "content": query})
|
||||
|
||||
tools_dict = self._get_user_tools(self.user)
|
||||
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)
|
||||
|
||||
resp = self._llm_gen(messages_combine, log_context)
|
||||
messages = self._build_messages(self.prompt, query, retrieved_data)
|
||||
|
||||
resp = self._llm_gen(messages, log_context)
|
||||
|
||||
attachments = self.attachments
|
||||
|
||||
if isinstance(resp, str):
|
||||
yield {"answer": resp}
|
||||
@@ -94,7 +37,7 @@ class ClassicAgent(BaseAgent):
|
||||
yield {"answer": resp.message.content}
|
||||
return
|
||||
|
||||
resp = self._llm_handler(resp, tools_dict, messages_combine, log_context)
|
||||
resp = self._llm_handler(resp, tools_dict, messages, log_context, attachments)
|
||||
|
||||
if isinstance(resp, str):
|
||||
yield {"answer": resp}
|
||||
@@ -105,37 +48,17 @@ class ClassicAgent(BaseAgent):
|
||||
):
|
||||
yield {"answer": resp.message.content}
|
||||
else:
|
||||
completion = self.llm.gen_stream(
|
||||
model=self.gpt_model, messages=messages_combine, tools=self.tools
|
||||
)
|
||||
for line in completion:
|
||||
for line in resp:
|
||||
if isinstance(line, str):
|
||||
yield {"answer": line}
|
||||
|
||||
log_context.stacks.append(
|
||||
{"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}}
|
||||
)
|
||||
|
||||
yield {"sources": retrieved_data}
|
||||
# clean tool_call_data only send first 50 characters of tool_call['result']
|
||||
for tool_call in self.tool_calls:
|
||||
if len(str(tool_call["result"])) > 50:
|
||||
tool_call["result"] = str(tool_call["result"])[:50] + "..."
|
||||
yield {"tool_calls": self.tool_calls.copy()}
|
||||
|
||||
def _retriever_search(self, retriever, query, log_context):
|
||||
retrieved_data = retriever.search(query)
|
||||
if log_context:
|
||||
data = build_stack_data(retriever, exclude_attributes=["llm"])
|
||||
log_context.stacks.append({"component": "retriever", "data": data})
|
||||
return retrieved_data
|
||||
|
||||
def _llm_gen(self, messages_combine, log_context):
|
||||
resp = self.llm.gen_stream(
|
||||
model=self.gpt_model, messages=messages_combine, tools=self.tools
|
||||
)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm)
|
||||
log_context.stacks.append({"component": "llm", "data": data})
|
||||
return resp
|
||||
|
||||
def _llm_handler(self, resp, tools_dict, messages_combine, log_context):
|
||||
resp = self.llm_handler.handle_response(
|
||||
self, resp, tools_dict, messages_combine
|
||||
)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm_handler)
|
||||
log_context.stacks.append({"component": "llm_handler", "data": data})
|
||||
return resp
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from application.logging import build_stack_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMHandler(ABC):
|
||||
def __init__(self):
|
||||
@@ -10,12 +13,90 @@ class LLMHandler(ABC):
|
||||
self.tool_calls = []
|
||||
|
||||
@abstractmethod
|
||||
def handle_response(self, agent, resp, tools_dict, messages, **kwargs):
|
||||
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, **kwargs):
|
||||
pass
|
||||
|
||||
def prepare_messages_with_attachments(self, agent, messages, attachments=None):
|
||||
"""
|
||||
Prepare messages with attachment content if available.
|
||||
|
||||
Args:
|
||||
agent: The current agent instance.
|
||||
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.
|
||||
"""
|
||||
if not attachments:
|
||||
return messages
|
||||
|
||||
logger.info(f"Preparing messages with {len(attachments)} attachments")
|
||||
|
||||
supported_types = agent.llm.get_supported_attachment_types()
|
||||
|
||||
supported_attachments = []
|
||||
unsupported_attachments = []
|
||||
|
||||
for attachment in attachments:
|
||||
mime_type = attachment.get('mime_type')
|
||||
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()
|
||||
|
||||
attachment_texts = []
|
||||
for attachment in attachments:
|
||||
logger.info(f"Adding attachment {attachment.get('id')} to context")
|
||||
if 'content' in attachment:
|
||||
attachment_texts.append(f"Attached file content:\n\n{attachment['content']}")
|
||||
|
||||
if attachment_texts:
|
||||
combined_attachment_text = "\n\n".join(attachment_texts)
|
||||
|
||||
system_found = False
|
||||
for i in range(len(prepared_messages)):
|
||||
if prepared_messages[i].get("role") == "system":
|
||||
prepared_messages[i]["content"] += f"\n\n{combined_attachment_text}"
|
||||
system_found = True
|
||||
break
|
||||
|
||||
if not system_found:
|
||||
prepared_messages.insert(0, {"role": "system", "content": combined_attachment_text})
|
||||
|
||||
return prepared_messages
|
||||
|
||||
class OpenAILLMHandler(LLMHandler):
|
||||
def handle_response(self, agent, resp, tools_dict, messages, stream: bool = True):
|
||||
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
|
||||
|
||||
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
|
||||
logger.info(f"Messages with attachments: {messages}")
|
||||
if not stream:
|
||||
while hasattr(resp, "finish_reason") and resp.finish_reason == "tool_calls":
|
||||
message = json.loads(resp.model_dump_json())["message"]
|
||||
@@ -54,7 +135,9 @@ class OpenAILLMHandler(LLMHandler):
|
||||
{"role": "tool", "content": [function_response_dict]}
|
||||
)
|
||||
|
||||
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing tool: {str(e)}", exc_info=True)
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
@@ -69,12 +152,14 @@ 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
|
||||
elif hasattr(chunk, "delta"):
|
||||
yield chunk
|
||||
continue
|
||||
elif hasattr(chunk, "delta"):
|
||||
chunk_delta = chunk.delta
|
||||
|
||||
if (
|
||||
@@ -145,6 +230,7 @@ class OpenAILLMHandler(LLMHandler):
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error executing tool: {str(e)}", exc_info=True)
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -152,15 +238,21 @@ 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
|
||||
|
||||
logger.info(f"Regenerating with messages: {messages}")
|
||||
resp = agent.llm.gen_stream(
|
||||
model=agent.gpt_model, messages=messages, tools=agent.tools
|
||||
)
|
||||
@@ -168,9 +260,11 @@ class OpenAILLMHandler(LLMHandler):
|
||||
|
||||
|
||||
class GoogleLLMHandler(LLMHandler):
|
||||
def handle_response(self, agent, resp, tools_dict, messages, stream: bool = True):
|
||||
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
|
||||
from google.genai import types
|
||||
|
||||
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
|
||||
|
||||
while True:
|
||||
if not stream:
|
||||
response = agent.llm.gen(
|
||||
@@ -241,6 +335,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
|
||||
|
||||
132
application/agents/react_agent.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import os
|
||||
from typing import Dict, Generator, List
|
||||
|
||||
from application.agents.base import BaseAgent
|
||||
from application.logging import build_stack_data, LogContext
|
||||
from application.retriever.base import BaseRetriever
|
||||
|
||||
current_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
with open(
|
||||
os.path.join(current_dir, "application/prompts", "react_planning_prompt.txt"), "r"
|
||||
) as f:
|
||||
planning_prompt = f.read()
|
||||
with open(
|
||||
os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"),
|
||||
"r",
|
||||
) as f:
|
||||
final_prompt = f.read()
|
||||
|
||||
|
||||
class ReActAgent(BaseAgent):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.plan = ""
|
||||
self.observations: List[str] = []
|
||||
|
||||
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)
|
||||
|
||||
docs_together = "\n".join([doc["text"] for doc in retrieved_data])
|
||||
plan = self._create_plan(query, docs_together, log_context)
|
||||
for line in plan:
|
||||
if isinstance(line, str):
|
||||
self.plan += line
|
||||
yield {"thought": line}
|
||||
|
||||
prompt = self.prompt + f"\nFollow this plan: {self.plan}"
|
||||
messages = self._build_messages(prompt, query, retrieved_data)
|
||||
|
||||
resp = self._llm_gen(messages, log_context)
|
||||
|
||||
if isinstance(resp, str):
|
||||
self.observations.append(resp)
|
||||
if (
|
||||
hasattr(resp, "message")
|
||||
and hasattr(resp.message, "content")
|
||||
and resp.message.content is not None
|
||||
):
|
||||
self.observations.append(resp.message.content)
|
||||
|
||||
resp = self._llm_handler(resp, tools_dict, messages, log_context)
|
||||
|
||||
for tool_call in self.tool_calls:
|
||||
observation = (
|
||||
f"Action '{tool_call['action_name']}' of tool '{tool_call['tool_name']}' "
|
||||
f"with arguments '{tool_call['arguments']}' returned: '{tool_call['result']}'"
|
||||
)
|
||||
self.observations.append(observation)
|
||||
|
||||
if isinstance(resp, str):
|
||||
self.observations.append(resp)
|
||||
elif (
|
||||
hasattr(resp, "message")
|
||||
and hasattr(resp.message, "content")
|
||||
and resp.message.content is not None
|
||||
):
|
||||
self.observations.append(resp.message.content)
|
||||
else:
|
||||
completion = self.llm.gen_stream(
|
||||
model=self.gpt_model, messages=messages, tools=self.tools
|
||||
)
|
||||
for line in completion:
|
||||
if isinstance(line, str):
|
||||
self.observations.append(line)
|
||||
|
||||
log_context.stacks.append(
|
||||
{"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}}
|
||||
)
|
||||
|
||||
yield {"sources": retrieved_data}
|
||||
# clean tool_call_data only send first 50 characters of tool_call['result']
|
||||
for tool_call in self.tool_calls:
|
||||
if len(str(tool_call["result"])) > 50:
|
||||
tool_call["result"] = str(tool_call["result"])[:50] + "..."
|
||||
yield {"tool_calls": self.tool_calls.copy()}
|
||||
|
||||
final_answer = self._create_final_answer(query, self.observations, log_context)
|
||||
for line in final_answer:
|
||||
if isinstance(line, str):
|
||||
yield {"answer": line}
|
||||
|
||||
def _create_plan(
|
||||
self, query: str, docs_data: str, log_context: LogContext = None
|
||||
) -> Generator[str, None, None]:
|
||||
plan_prompt = planning_prompt.replace("{query}", query)
|
||||
if "{summaries}" in planning_prompt:
|
||||
summaries = docs_data
|
||||
plan_prompt = plan_prompt.replace("{summaries}", summaries)
|
||||
|
||||
messages = [{"role": "user", "content": plan_prompt}]
|
||||
print(self.tools)
|
||||
plan = self.llm.gen_stream(
|
||||
model=self.gpt_model, messages=messages, tools=self.tools
|
||||
)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm)
|
||||
log_context.stacks.append({"component": "planning_llm", "data": data})
|
||||
return plan
|
||||
|
||||
def _create_final_answer(
|
||||
self, query: str, observations: List[str], log_context: LogContext = None
|
||||
) -> str:
|
||||
observation_string = "\n".join(observations)
|
||||
final_answer_prompt = final_prompt.format(
|
||||
query=query, observations=observation_string
|
||||
)
|
||||
|
||||
messages = [{"role": "user", "content": final_answer_prompt}]
|
||||
final_answer = self.llm.gen_stream(model=self.gpt_model, messages=messages)
|
||||
if log_context:
|
||||
data = build_stack_data(self.llm)
|
||||
log_context.stacks.append({"component": "final_answer_llm", "data": data})
|
||||
return final_answer
|
||||
@@ -23,43 +23,15 @@ class APITool(Tool):
|
||||
)
|
||||
|
||||
def _make_api_call(self, url, method, headers, query_params, body):
|
||||
sanitized_headers = {}
|
||||
for key, value in headers.items():
|
||||
if isinstance(value, str):
|
||||
sanitized_value = value.encode('latin-1', errors='ignore').decode('latin-1')
|
||||
sanitized_headers[key] = sanitized_value
|
||||
else:
|
||||
sanitized_headers[key] = value
|
||||
|
||||
if query_params:
|
||||
url = f"{url}?{requests.compat.urlencode(query_params)}"
|
||||
if isinstance(body, dict):
|
||||
body = json.dumps(body)
|
||||
response = None
|
||||
# if isinstance(body, dict):
|
||||
# body = json.dumps(body)
|
||||
try:
|
||||
print(f"Making API call: {method} {url} with body: {body}")
|
||||
if body == "{}":
|
||||
body = None
|
||||
|
||||
proxy_id = self.config.get("proxy_id", None)
|
||||
request_kwargs = {
|
||||
'method': method,
|
||||
'url': url,
|
||||
'headers': sanitized_headers,
|
||||
'data': body
|
||||
}
|
||||
try:
|
||||
if proxy_id:
|
||||
from application.agents.tools.proxy_handler import apply_proxy_to_request
|
||||
response = apply_proxy_to_request(
|
||||
requests.request,
|
||||
proxy_id=proxy_id,
|
||||
**request_kwargs
|
||||
)
|
||||
else:
|
||||
response = requests.request(**request_kwargs)
|
||||
except ImportError:
|
||||
response = requests.request(**request_kwargs)
|
||||
response = requests.request(method, url, headers=headers, data=body)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get(
|
||||
"Content-Type", "application/json"
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get MongoDB connection
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
proxies_collection = db["proxies"]
|
||||
|
||||
def get_proxy_config(proxy_id: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Retrieve proxy configuration from the database.
|
||||
|
||||
Args:
|
||||
proxy_id: The ID of the proxy configuration
|
||||
|
||||
Returns:
|
||||
A dictionary with proxy configuration or None if not found
|
||||
"""
|
||||
if not proxy_id or proxy_id == "none":
|
||||
return None
|
||||
|
||||
try:
|
||||
if ObjectId.is_valid(proxy_id):
|
||||
proxy_config = proxies_collection.find_one({"_id": ObjectId(proxy_id)})
|
||||
if proxy_config and "connection" in proxy_config:
|
||||
connection_str = proxy_config["connection"].strip()
|
||||
if connection_str:
|
||||
# Format proxy for requests library
|
||||
return {
|
||||
"http": connection_str,
|
||||
"https": connection_str
|
||||
}
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving proxy configuration: {e}")
|
||||
return None
|
||||
|
||||
def apply_proxy_to_request(request_func, proxy_id=None, **kwargs):
|
||||
"""
|
||||
Apply proxy configuration to a requests function if available.
|
||||
This is a minimal wrapper that doesn't change the function signature.
|
||||
|
||||
Args:
|
||||
request_func: The requests function to call (e.g., requests.get, requests.post)
|
||||
proxy_id: Optional proxy ID to use
|
||||
**kwargs: Arguments to pass to the request function
|
||||
|
||||
Returns:
|
||||
The response from the request
|
||||
"""
|
||||
if proxy_id:
|
||||
proxy_config = get_proxy_config(proxy_id)
|
||||
if proxy_config:
|
||||
kwargs['proxies'] = proxy_config
|
||||
logger.info(f"Using proxy for request")
|
||||
|
||||
return request_func(**kwargs)
|
||||
83
application/agents/tools/read_webpage.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import requests
|
||||
from markdownify import markdownify
|
||||
from application.agents.tools.base import Tool
|
||||
from urllib.parse import urlparse
|
||||
|
||||
class ReadWebpageTool(Tool):
|
||||
"""
|
||||
Read Webpage (browser)
|
||||
A tool to fetch the HTML content of a URL and convert it to Markdown.
|
||||
"""
|
||||
|
||||
def __init__(self, config=None):
|
||||
"""
|
||||
Initializes the tool.
|
||||
:param config: Optional configuration dictionary. Not used by this tool.
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def execute_action(self, action_name: str, **kwargs) -> str:
|
||||
"""
|
||||
Executes the specified action. For this tool, the only action is 'read_webpage'.
|
||||
|
||||
:param action_name: The name of the action to execute. Should be 'read_webpage'.
|
||||
:param kwargs: Keyword arguments, must include 'url'.
|
||||
:return: The Markdown content of the webpage or an error message.
|
||||
"""
|
||||
if action_name != "read_webpage":
|
||||
return f"Error: Unknown action '{action_name}'. This tool only supports 'read_webpage'."
|
||||
|
||||
url = kwargs.get("url")
|
||||
if not url:
|
||||
return "Error: URL parameter is missing."
|
||||
|
||||
# Ensure the URL has a scheme (if not, default to http)
|
||||
parsed_url = urlparse(url)
|
||||
if not parsed_url.scheme:
|
||||
url = "http://" + url
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10, headers={'User-Agent': 'DocsGPT-Agent/1.0'})
|
||||
response.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
|
||||
|
||||
html_content = response.text
|
||||
#soup = BeautifulSoup(html_content, 'html.parser')
|
||||
|
||||
|
||||
markdown_content = markdownify(html_content, heading_style="ATX", newline_style="BACKSLASH")
|
||||
|
||||
return markdown_content
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return f"Error fetching URL {url}: {e}"
|
||||
except Exception as e:
|
||||
return f"Error processing URL {url}: {e}"
|
||||
|
||||
def get_actions_metadata(self):
|
||||
"""
|
||||
Returns metadata for the actions supported by this tool.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"name": "read_webpage",
|
||||
"description": "Fetches the HTML content of a given URL and returns it as clean Markdown text. Input must be a valid URL.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The fully qualified URL of the webpage to read (e.g., 'https://www.example.com').",
|
||||
}
|
||||
},
|
||||
"required": ["url"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def get_config_requirements(self):
|
||||
"""
|
||||
Returns a dictionary describing the configuration requirements for the tool.
|
||||
This tool does not require any specific configuration.
|
||||
"""
|
||||
return {}
|
||||
@@ -23,12 +23,13 @@ from application.utils import check_required_fields, limit_chat_history
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
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"]
|
||||
|
||||
answer = Blueprint("answer", __name__)
|
||||
answer_ns = Namespace("answer", description="Answer related operations", path="/")
|
||||
@@ -85,19 +86,51 @@ 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, False, 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)
|
||||
|
||||
is_owner = agent.get("user") == user_id
|
||||
|
||||
if is_owner:
|
||||
agents_collection.update_one(
|
||||
{"_id": ObjectId(agent_id)},
|
||||
{"$set": {"lastUsedAt": datetime.datetime.now(datetime.timezone.utc)}},
|
||||
)
|
||||
return str(agent["key"]), False, None
|
||||
|
||||
is_shared_with_user = agent.get(
|
||||
"shared_publicly", False
|
||||
) or user_id in agent.get("shared_with", [])
|
||||
|
||||
if is_shared_with_user:
|
||||
return str(agent["key"]), True, agent.get("shared_token")
|
||||
|
||||
raise Exception("Unauthorized access to the agent", 403)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_agent_key: {str(e)}", exc_info=True)
|
||||
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
|
||||
|
||||
|
||||
@@ -121,12 +154,16 @@ def save_conversation(
|
||||
conversation_id,
|
||||
question,
|
||||
response,
|
||||
thought,
|
||||
source_log_docs,
|
||||
tool_calls,
|
||||
llm,
|
||||
decoded_token,
|
||||
index=None,
|
||||
api_key=None,
|
||||
agent_id=None,
|
||||
is_shared_usage=False,
|
||||
shared_token=None,
|
||||
):
|
||||
current_time = datetime.datetime.now(datetime.timezone.utc)
|
||||
if conversation_id is not None and index is not None:
|
||||
@@ -136,6 +173,7 @@ def save_conversation(
|
||||
"$set": {
|
||||
f"queries.{index}.prompt": question,
|
||||
f"queries.{index}.response": response,
|
||||
f"queries.{index}.thought": thought,
|
||||
f"queries.{index}.sources": source_log_docs,
|
||||
f"queries.{index}.tool_calls": tool_calls,
|
||||
f"queries.{index}.timestamp": current_time,
|
||||
@@ -155,6 +193,7 @@ def save_conversation(
|
||||
"queries": {
|
||||
"prompt": question,
|
||||
"response": response,
|
||||
"thought": thought,
|
||||
"sources": source_log_docs,
|
||||
"tool_calls": tool_calls,
|
||||
"timestamp": current_time,
|
||||
@@ -190,6 +229,7 @@ def save_conversation(
|
||||
{
|
||||
"prompt": question,
|
||||
"response": response,
|
||||
"thought": thought,
|
||||
"sources": source_log_docs,
|
||||
"tool_calls": tool_calls,
|
||||
"timestamp": current_time,
|
||||
@@ -197,7 +237,12 @@ 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
|
||||
if is_shared_usage:
|
||||
conversation_data["is_shared_usage"] = is_shared_usage
|
||||
conversation_data["shared_token"] = shared_token
|
||||
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(
|
||||
@@ -228,11 +273,20 @@ def complete_stream(
|
||||
isNoneDoc=False,
|
||||
index=None,
|
||||
should_save_conversation=True,
|
||||
attachments=None,
|
||||
agent_id=None,
|
||||
is_shared_usage=False,
|
||||
shared_token=None,
|
||||
):
|
||||
try:
|
||||
response_full = ""
|
||||
source_log_docs = []
|
||||
tool_calls = []
|
||||
response_full, thought, source_log_docs, tool_calls = "", "", [], []
|
||||
attachment_ids = []
|
||||
|
||||
if attachments:
|
||||
attachment_ids = [attachment["id"] for attachment in attachments]
|
||||
logger.info(
|
||||
f"Processing request with {len(attachments)} attachments: {attachment_ids}"
|
||||
)
|
||||
|
||||
answer = agent.gen(query=question, retriever=retriever)
|
||||
|
||||
@@ -258,6 +312,10 @@ def complete_stream(
|
||||
tool_calls = line["tool_calls"]
|
||||
data = json.dumps({"type": "tool_calls", "tool_calls": tool_calls})
|
||||
yield f"data: {data}\n\n"
|
||||
elif "thought" in line:
|
||||
thought += line["thought"]
|
||||
data = json.dumps({"type": "thought", "thought": line["thought"]})
|
||||
yield f"data: {data}\n\n"
|
||||
|
||||
if isNoneDoc:
|
||||
for doc in source_log_docs:
|
||||
@@ -275,12 +333,16 @@ def complete_stream(
|
||||
conversation_id,
|
||||
question,
|
||||
response_full,
|
||||
thought,
|
||||
source_log_docs,
|
||||
tool_calls,
|
||||
llm,
|
||||
decoded_token,
|
||||
index,
|
||||
api_key=user_api_key,
|
||||
agent_id=agent_id,
|
||||
is_shared_usage=is_shared_usage,
|
||||
shared_token=shared_token,
|
||||
)
|
||||
else:
|
||||
conversation_id = None
|
||||
@@ -300,14 +362,14 @@ def complete_stream(
|
||||
"response": response_full,
|
||||
"sources": source_log_docs,
|
||||
"retriever_params": retriever_params,
|
||||
"attachments": attachment_ids,
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc),
|
||||
}
|
||||
)
|
||||
data = json.dumps({"type": "end"})
|
||||
yield f"data: {data}\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Error in stream: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"Error in stream: {str(e)}", exc_info=True)
|
||||
data = json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
@@ -335,9 +397,6 @@ class Stream(Resource):
|
||||
"prompt_id": fields.String(
|
||||
required=False, default="default", description="Prompt ID"
|
||||
),
|
||||
"proxy_id": fields.String(
|
||||
required=False, description="Proxy ID to use for API calls"
|
||||
),
|
||||
"chunks": fields.Integer(
|
||||
required=False, default=2, description="Number of chunks"
|
||||
),
|
||||
@@ -351,10 +410,15 @@ class Stream(Resource):
|
||||
required=False, description="Flag indicating if no document is used"
|
||||
),
|
||||
"index": fields.Integer(
|
||||
required=False, description="The position where query is to be updated"
|
||||
required=False, description="Index of the query to update"
|
||||
),
|
||||
"save_conversation": fields.Boolean(
|
||||
required=False, default=True, description="Flag to save conversation"
|
||||
required=False,
|
||||
default=True,
|
||||
description="Whether to save the conversation",
|
||||
),
|
||||
"attachments": fields.List(
|
||||
fields.String, required=False, description="List of attachment IDs"
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -379,22 +443,36 @@ class Stream(Resource):
|
||||
)
|
||||
conversation_id = data.get("conversation_id")
|
||||
prompt_id = data.get("prompt_id", "default")
|
||||
proxy_id = data.get("proxy_id", None)
|
||||
attachment_ids = data.get("attachments", [])
|
||||
|
||||
index = data.get("index", None)
|
||||
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, is_shared_usage, shared_token = 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"])
|
||||
chunks = int(data_key.get("chunks", 2))
|
||||
prompt_id = data_key.get("prompt_id", "default")
|
||||
proxy_id = data_key.get("proxy_id", None)
|
||||
source = {"active_docs": data_key.get("source")}
|
||||
retriever_name = data_key.get("retriever", retriever_name)
|
||||
user_api_key = data["api_key"]
|
||||
decoded_token = {"sub": data_key.get("user")}
|
||||
agent_type = data_key.get("agent_type", agent_type)
|
||||
if is_shared_usage:
|
||||
decoded_token = request.decoded_token
|
||||
else:
|
||||
decoded_token = {"sub": data_key.get("user")}
|
||||
is_shared_usage = False
|
||||
|
||||
elif "active_docs" in data:
|
||||
source = {"active_docs": data["active_docs"]}
|
||||
@@ -410,8 +488,12 @@ class Stream(Resource):
|
||||
if not decoded_token:
|
||||
return make_response({"error": "Unauthorized"}, 401)
|
||||
|
||||
attachments = get_attachments_content(
|
||||
attachment_ids, decoded_token.get("sub")
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"/stream - request_data: {data}, source: {source}",
|
||||
f"/stream - request_data: {data}, source: {source}, attachments: {len(attachments)}",
|
||||
extra={"data": json.dumps({"request_data": data, "source": source})},
|
||||
)
|
||||
|
||||
@@ -420,16 +502,16 @@ 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,
|
||||
api_key=settings.API_KEY,
|
||||
user_api_key=user_api_key,
|
||||
prompt=prompt,
|
||||
proxy_id=proxy_id,
|
||||
chat_history=history,
|
||||
decoded_token=decoded_token,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
retriever = RetrieverCreator.create_retriever(
|
||||
@@ -443,7 +525,8 @@ class Stream(Resource):
|
||||
user_api_key=user_api_key,
|
||||
decoded_token=decoded_token,
|
||||
)
|
||||
|
||||
is_shared_usage_val = data.get("is_shared_usage", False)
|
||||
is_shared_token_val = data.get("shared_token", None)
|
||||
return Response(
|
||||
complete_stream(
|
||||
question=question,
|
||||
@@ -455,6 +538,9 @@ class Stream(Resource):
|
||||
isNoneDoc=data.get("isNoneDoc"),
|
||||
index=index,
|
||||
should_save_conversation=save_conv,
|
||||
agent_id=agent_id,
|
||||
is_shared_usage=is_shared_usage_val,
|
||||
shared_token=is_shared_token_val,
|
||||
),
|
||||
mimetype="text/event-stream",
|
||||
)
|
||||
@@ -502,9 +588,6 @@ class Answer(Resource):
|
||||
"prompt_id": fields.String(
|
||||
required=False, default="default", description="Prompt ID"
|
||||
),
|
||||
"proxy_id": fields.String(
|
||||
required=False, description="Proxy ID to use for API calls"
|
||||
),
|
||||
"chunks": fields.Integer(
|
||||
required=False, default=2, description="Number of chunks"
|
||||
),
|
||||
@@ -536,19 +619,19 @@ class Answer(Resource):
|
||||
)
|
||||
conversation_id = data.get("conversation_id")
|
||||
prompt_id = data.get("prompt_id", "default")
|
||||
proxy_id = data.get("proxy_id", None)
|
||||
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"])
|
||||
chunks = int(data_key.get("chunks", 2))
|
||||
prompt_id = data_key.get("prompt_id", "default")
|
||||
proxy_id = data_key.get("proxy_id", None)
|
||||
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:
|
||||
@@ -573,14 +656,13 @@ class Answer(Resource):
|
||||
)
|
||||
|
||||
agent = AgentCreator.create_agent(
|
||||
settings.AGENT_NAME,
|
||||
agent_type,
|
||||
endpoint="api/answer",
|
||||
llm_name=settings.LLM_NAME,
|
||||
gpt_model=gpt_model,
|
||||
api_key=settings.API_KEY,
|
||||
user_api_key=user_api_key,
|
||||
prompt=prompt,
|
||||
proxy_id=proxy_id,
|
||||
chat_history=history,
|
||||
decoded_token=decoded_token,
|
||||
)
|
||||
@@ -601,6 +683,7 @@ class Answer(Resource):
|
||||
source_log_docs = []
|
||||
tool_calls = []
|
||||
stream_ended = False
|
||||
thought = ""
|
||||
|
||||
for line in complete_stream(
|
||||
question=question,
|
||||
@@ -623,6 +706,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"])
|
||||
@@ -654,6 +739,7 @@ class Answer(Resource):
|
||||
conversation_id,
|
||||
question,
|
||||
response_full,
|
||||
thought,
|
||||
source_log_docs,
|
||||
tool_calls,
|
||||
llm,
|
||||
@@ -796,3 +882,34 @@ class Search(Resource):
|
||||
return bad_request(500, str(e))
|
||||
|
||||
return make_response(docs, 200)
|
||||
|
||||
|
||||
def get_attachments_content(attachment_ids, user):
|
||||
"""
|
||||
Retrieve content from attachment documents based on their IDs.
|
||||
|
||||
Args:
|
||||
attachment_ids (list): List of attachment document IDs
|
||||
user (str): User identifier to verify ownership
|
||||
|
||||
Returns:
|
||||
list: List of dictionaries containing attachment content and metadata
|
||||
"""
|
||||
if not attachment_ids:
|
||||
return []
|
||||
|
||||
attachments = []
|
||||
for attachment_id in attachment_ids:
|
||||
try:
|
||||
attachment_doc = attachments_collection.find_one(
|
||||
{"_id": ObjectId(attachment_id), "user": user}
|
||||
)
|
||||
|
||||
if attachment_doc:
|
||||
attachments.append(attachment_doc)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error retrieving attachment {attachment_id}: {e}", exc_info=True
|
||||
)
|
||||
|
||||
return attachments
|
||||
|
||||
@@ -3,12 +3,15 @@ import datetime
|
||||
from flask import Blueprint, request, send_from_directory
|
||||
from werkzeug.utils import secure_filename
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
import logging
|
||||
from application.core.mongo_db import MongoDB
|
||||
from application.core.settings import settings
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
conversations_collection = db["conversations"]
|
||||
sources_collection = db["sources"]
|
||||
|
||||
@@ -45,26 +48,26 @@ def upload_index_files():
|
||||
remote_data = request.form["remote_data"] if "remote_data" in request.form else None
|
||||
sync_frequency = secure_filename(request.form["sync_frequency"]) if "sync_frequency" in request.form else None
|
||||
|
||||
save_dir = os.path.join(current_dir, "indexes", str(id))
|
||||
storage = StorageCreator.get_storage()
|
||||
index_base_path = f"indexes/{id}"
|
||||
|
||||
if settings.VECTOR_STORE == "faiss":
|
||||
if "file_faiss" not in request.files:
|
||||
print("No file part")
|
||||
logger.error("No file_faiss part")
|
||||
return {"status": "no file"}
|
||||
file_faiss = request.files["file_faiss"]
|
||||
if file_faiss.filename == "":
|
||||
return {"status": "no file name"}
|
||||
if "file_pkl" not in request.files:
|
||||
print("No file part")
|
||||
logger.error("No file_pkl part")
|
||||
return {"status": "no file"}
|
||||
file_pkl = request.files["file_pkl"]
|
||||
if file_pkl.filename == "":
|
||||
return {"status": "no file name"}
|
||||
# saves index files
|
||||
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
file_faiss.save(os.path.join(save_dir, "index.faiss"))
|
||||
file_pkl.save(os.path.join(save_dir, "index.pkl"))
|
||||
|
||||
# Save index files to storage
|
||||
storage.save_file(file_faiss, f"{index_base_path}/index.faiss")
|
||||
storage.save_file(file_pkl, f"{index_base_path}/index.pkl")
|
||||
|
||||
existing_entry = sources_collection.find_one({"_id": ObjectId(id)})
|
||||
if existing_entry:
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from application.celery_init import celery
|
||||
from application.worker import ingest_worker, remote_worker, sync_worker
|
||||
from application.worker import (
|
||||
agent_webhook_worker,
|
||||
attachment_worker,
|
||||
ingest_worker,
|
||||
remote_worker,
|
||||
sync_worker,
|
||||
)
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
@@ -22,6 +28,18 @@ def schedule_syncs(self, frequency):
|
||||
return resp
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def store_attachment(self, file_info, user):
|
||||
resp = attachment_worker(self, file_info, user)
|
||||
return resp
|
||||
|
||||
|
||||
@celery.task(bind=True)
|
||||
def process_agent_webhook(self, agent_id, payload):
|
||||
resp = agent_webhook_worker(self, agent_id, payload)
|
||||
return resp
|
||||
|
||||
|
||||
@celery.on_after_configure.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
sender.add_periodic_task(
|
||||
|
||||
@@ -61,14 +61,14 @@ def gen_cache(func):
|
||||
if cached_response:
|
||||
return cached_response.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cached response: {e}")
|
||||
logger.error(f"Error getting cached response: {e}", exc_info=True)
|
||||
|
||||
result = func(self, model, messages, stream, tools, *args, **kwargs)
|
||||
if redis_client and isinstance(result, str):
|
||||
try:
|
||||
redis_client.set(cache_key, result, ex=1800)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting cache: {e}")
|
||||
logger.error(f"Error setting cache: {e}", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
@@ -100,7 +100,7 @@ def stream_cache(func):
|
||||
time.sleep(0.03) # Simulate streaming delay
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cached stream: {e}")
|
||||
logger.error(f"Error getting cached stream: {e}", exc_info=True)
|
||||
|
||||
stream_cache_data = []
|
||||
for chunk in func(self, model, messages, stream, tools, *args, **kwargs):
|
||||
@@ -112,6 +112,6 @@ def stream_cache(func):
|
||||
redis_client.set(cache_key, json.dumps(stream_cache_data), ex=1800)
|
||||
logger.info(f"Stream cache saved for key: {cache_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting stream cache: {e}")
|
||||
logger.error(f"Error setting stream cache: {e}", exc_info=True)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||
MONGO_URI: str = "mongodb://localhost:27017/docsgpt"
|
||||
MONGO_DB_NAME: str = "docsgpt"
|
||||
MODEL_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
|
||||
DEFAULT_MAX_HISTORY: int = 150
|
||||
MODEL_TOKEN_LIMITS: dict = {
|
||||
@@ -98,6 +99,8 @@ class Settings(BaseSettings):
|
||||
BRAVE_SEARCH_API_KEY: Optional[str] = None
|
||||
|
||||
FLASK_DEBUG_MODE: bool = False
|
||||
STORAGE_TYPE: str = "local" # local or s3
|
||||
|
||||
|
||||
JWT_SECRET_KEY: str = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
import logging
|
||||
import json
|
||||
|
||||
from application.llm.base import BaseLLM
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
class GoogleLLM(BaseLLM):
|
||||
@@ -9,6 +13,126 @@ 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)
|
||||
self.storage = StorageCreator.get_storage()
|
||||
|
||||
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 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}", exc_info=True)
|
||||
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 self.storage.file_exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
try:
|
||||
file_uri = self.storage.process_file(
|
||||
file_path,
|
||||
lambda local_path, **kwargs: self.client.files.upload(file=local_path).uri
|
||||
)
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
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}", exc_info=True)
|
||||
raise
|
||||
|
||||
def _clean_messages_google(self, messages):
|
||||
cleaned_messages = []
|
||||
@@ -26,7 +150,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 +165,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 +278,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:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import json
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.llm.base import BaseLLM
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
|
||||
|
||||
class OpenAILLM(BaseLLM):
|
||||
@@ -10,12 +13,14 @@ class OpenAILLM(BaseLLM):
|
||||
from openai import OpenAI
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
if settings.OPENAI_BASE_URL:
|
||||
if isinstance(settings.OPENAI_BASE_URL, str) and settings.OPENAI_BASE_URL.strip():
|
||||
self.client = OpenAI(api_key=api_key, base_url=settings.OPENAI_BASE_URL)
|
||||
else:
|
||||
self.client = OpenAI(api_key=api_key)
|
||||
DEFAULT_OPENAI_API_BASE = "https://api.openai.com/v1"
|
||||
self.client = OpenAI(api_key=api_key, base_url=DEFAULT_OPENAI_API_BASE)
|
||||
self.api_key = api_key
|
||||
self.user_api_key = user_api_key
|
||||
self.storage = StorageCreator.get_storage()
|
||||
|
||||
def _clean_messages_openai(self, messages):
|
||||
cleaned_messages = []
|
||||
@@ -65,6 +70,17 @@ 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)
|
||||
elif "type" in item and item["type"] == "image_url" and "image_url" in item:
|
||||
content_parts.append(item)
|
||||
cleaned_messages.append({"role": role, "content": content_parts})
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unexpected content dictionary format: {item}"
|
||||
@@ -133,11 +149,167 @@ 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 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}", exc_info=True)
|
||||
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}", exc_info=True)
|
||||
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")
|
||||
|
||||
try:
|
||||
with self.storage.get_file(file_path) as image_file:
|
||||
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
def _upload_file_to_openai(self, attachment):
|
||||
"""
|
||||
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 logging
|
||||
|
||||
if 'openai_file_id' in attachment:
|
||||
return attachment['openai_file_id']
|
||||
|
||||
file_path = attachment.get('path')
|
||||
|
||||
if not self.storage.file_exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
try:
|
||||
file_id = self.storage.process_file(
|
||||
file_path,
|
||||
lambda local_path, **kwargs: self.client.files.create(
|
||||
file=open(local_path, 'rb'),
|
||||
purpose="assistants"
|
||||
).id
|
||||
)
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
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}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
class AzureOpenAILLM(OpenAILLM):
|
||||
|
||||
def __init__(
|
||||
self, api_key, user_api_key, *args, **kwargs
|
||||
self, api_key, user_api_key, *args, **kwargs
|
||||
):
|
||||
|
||||
super().__init__(api_key)
|
||||
|
||||
@@ -7,6 +7,7 @@ import uuid
|
||||
from typing import Any, Callable, Dict, Generator, List
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
from application.core.settings import settings
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
@@ -29,6 +30,8 @@ def build_stack_data(
|
||||
exclude_attributes: List[str] = None,
|
||||
custom_data: Dict = None,
|
||||
) -> Dict:
|
||||
if obj is None:
|
||||
raise ValueError("The 'obj' parameter cannot be None")
|
||||
data = {}
|
||||
if include_attributes is None:
|
||||
include_attributes = []
|
||||
@@ -56,8 +59,8 @@ def build_stack_data(
|
||||
data[attr_name] = [str(item) for item in attr_value]
|
||||
elif isinstance(attr_value, dict):
|
||||
data[attr_name] = {k: str(v) for k, v in attr_value.items()}
|
||||
else:
|
||||
data[attr_name] = str(attr_value)
|
||||
except AttributeError as e:
|
||||
logging.warning(f"AttributeError while accessing {attr_name}: {e}")
|
||||
except AttributeError:
|
||||
pass
|
||||
if custom_data:
|
||||
@@ -131,7 +134,7 @@ def _log_to_mongodb(
|
||||
) -> None:
|
||||
try:
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
user_logs_collection = db["stack_logs"]
|
||||
|
||||
log_entry = {
|
||||
@@ -148,4 +151,4 @@ def _log_to_mongodb(
|
||||
logging.debug(f"Logged activity to MongoDB: {activity_id}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to log to MongoDB: {e}")
|
||||
logging.error(f"Failed to log to MongoDB: {e}", exc_info=True)
|
||||
|
||||
@@ -19,7 +19,7 @@ def add_text_to_store_with_retry(store, doc, source_id):
|
||||
doc.metadata["source_id"] = str(source_id)
|
||||
store.add_texts([doc.page_content], metadatas=[doc.metadata])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to add document with retry: {e}")
|
||||
logging.error(f"Failed to add document with retry: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def embed_and_store_documents(docs, folder_name, source_id, task_status):
|
||||
# Add document to vector store
|
||||
add_text_to_store_with_retry(store, doc, source_id)
|
||||
except Exception as e:
|
||||
logging.error(f"Error embedding document {idx}: {e}")
|
||||
logging.error(f"Error embedding document {idx}: {e}", exc_info=True)
|
||||
logging.info(f"Saving progress at document {idx} out of {total_docs}")
|
||||
store.save_local(folder_name)
|
||||
break
|
||||
|
||||
@@ -158,7 +158,7 @@ class SimpleDirectoryReader(BaseReader):
|
||||
data = f.read()
|
||||
# Prepare metadata for this file
|
||||
if self.file_metadata is not None:
|
||||
file_metadata = self.file_metadata(str(input_file))
|
||||
file_metadata = self.file_metadata(input_file.name)
|
||||
else:
|
||||
# Provide a default empty metadata
|
||||
file_metadata = {'title': '', 'store': ''}
|
||||
|
||||
@@ -73,7 +73,13 @@ class PandasCSVParser(BaseParser):
|
||||
for more information.
|
||||
Set to empty dict by default, this means pandas will try to figure
|
||||
out the separators, table head, etc. on its own.
|
||||
|
||||
|
||||
header_period (int): Controls how headers are included in output:
|
||||
- 0: Headers only at the beginning
|
||||
- 1: Headers in every row
|
||||
- N > 1: Headers every N rows
|
||||
|
||||
header_prefix (str): Prefix for header rows. Default is "HEADERS: ".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -83,6 +89,8 @@ class PandasCSVParser(BaseParser):
|
||||
col_joiner: str = ", ",
|
||||
row_joiner: str = "\n",
|
||||
pandas_config: dict = {},
|
||||
header_period: int = 20,
|
||||
header_prefix: str = "HEADERS: ",
|
||||
**kwargs: Any
|
||||
) -> None:
|
||||
"""Init params."""
|
||||
@@ -91,6 +99,8 @@ class PandasCSVParser(BaseParser):
|
||||
self._col_joiner = col_joiner
|
||||
self._row_joiner = row_joiner
|
||||
self._pandas_config = pandas_config
|
||||
self._header_period = header_period
|
||||
self._header_prefix = header_prefix
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
"""Init parser."""
|
||||
@@ -104,15 +114,26 @@ class PandasCSVParser(BaseParser):
|
||||
raise ValueError("pandas module is required to read CSV files.")
|
||||
|
||||
df = pd.read_csv(file, **self._pandas_config)
|
||||
headers = df.columns.tolist()
|
||||
header_row = f"{self._header_prefix}{self._col_joiner.join(headers)}"
|
||||
|
||||
text_list = df.apply(
|
||||
lambda row: (self._col_joiner).join(row.astype(str).tolist()), axis=1
|
||||
).tolist()
|
||||
if not self._concat_rows:
|
||||
return df.apply(
|
||||
lambda row: (self._col_joiner).join(row.astype(str).tolist()), axis=1
|
||||
).tolist()
|
||||
|
||||
text_list = []
|
||||
if self._header_period != 1:
|
||||
text_list.append(header_row)
|
||||
|
||||
for i, row in df.iterrows():
|
||||
if (self._header_period > 1 and i > 0 and i % self._header_period == 0):
|
||||
text_list.append(header_row)
|
||||
text_list.append(self._col_joiner.join(row.astype(str).tolist()))
|
||||
if self._header_period == 1 and i < len(df) - 1:
|
||||
text_list.append(header_row)
|
||||
|
||||
if self._concat_rows:
|
||||
return (self._row_joiner).join(text_list)
|
||||
else:
|
||||
return text_list
|
||||
return self._row_joiner.join(text_list)
|
||||
|
||||
|
||||
class ExcelParser(BaseParser):
|
||||
@@ -138,7 +159,13 @@ class ExcelParser(BaseParser):
|
||||
for more information.
|
||||
Set to empty dict by default, this means pandas will try to figure
|
||||
out the table structure on its own.
|
||||
|
||||
|
||||
header_period (int): Controls how headers are included in output:
|
||||
- 0: Headers only at the beginning (default)
|
||||
- 1: Headers in every row
|
||||
- N > 1: Headers every N rows
|
||||
|
||||
header_prefix (str): Prefix for header rows. Default is "HEADERS: ".
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -148,6 +175,8 @@ class ExcelParser(BaseParser):
|
||||
col_joiner: str = ", ",
|
||||
row_joiner: str = "\n",
|
||||
pandas_config: dict = {},
|
||||
header_period: int = 20,
|
||||
header_prefix: str = "HEADERS: ",
|
||||
**kwargs: Any
|
||||
) -> None:
|
||||
"""Init params."""
|
||||
@@ -156,6 +185,8 @@ class ExcelParser(BaseParser):
|
||||
self._col_joiner = col_joiner
|
||||
self._row_joiner = row_joiner
|
||||
self._pandas_config = pandas_config
|
||||
self._header_period = header_period
|
||||
self._header_prefix = header_prefix
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
"""Init parser."""
|
||||
@@ -169,12 +200,22 @@ class ExcelParser(BaseParser):
|
||||
raise ValueError("pandas module is required to read Excel files.")
|
||||
|
||||
df = pd.read_excel(file, **self._pandas_config)
|
||||
headers = df.columns.tolist()
|
||||
header_row = f"{self._header_prefix}{self._col_joiner.join(headers)}"
|
||||
|
||||
if not self._concat_rows:
|
||||
return df.apply(
|
||||
lambda row: (self._col_joiner).join(row.astype(str).tolist()), axis=1
|
||||
).tolist()
|
||||
|
||||
text_list = []
|
||||
if self._header_period != 1:
|
||||
text_list.append(header_row)
|
||||
|
||||
text_list = df.apply(
|
||||
lambda row: (self._col_joiner).join(row.astype(str).tolist()), axis=1
|
||||
).tolist()
|
||||
|
||||
if self._concat_rows:
|
||||
return (self._row_joiner).join(text_list)
|
||||
else:
|
||||
return text_list
|
||||
for i, row in df.iterrows():
|
||||
if (self._header_period > 1 and i > 0 and i % self._header_period == 0):
|
||||
text_list.append(header_row)
|
||||
text_list.append(self._col_joiner.join(row.astype(str).tolist()))
|
||||
if self._header_period == 1 and i < len(df) - 1:
|
||||
text_list.append(header_row)
|
||||
return self._row_joiner.join(text_list)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import urlparse, urljoin
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -42,7 +43,7 @@ class CrawlerLoader(BaseRemote):
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error processing URL {current_url}: {e}")
|
||||
logging.error(f"Error processing URL {current_url}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
# Parse the HTML content to extract all links
|
||||
@@ -61,4 +62,4 @@ class CrawlerLoader(BaseRemote):
|
||||
if self.limit is not None and len(visited_urls) >= self.limit:
|
||||
break
|
||||
|
||||
return loaded_content
|
||||
return loaded_content
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import requests
|
||||
import re # Import regular expression library
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -32,7 +33,7 @@ class SitemapLoader(BaseRemote):
|
||||
documents.extend(loader.load())
|
||||
processed_urls += 1 # Increment the counter after processing each URL
|
||||
except Exception as e:
|
||||
print(f"Error processing URL {url}: {e}")
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
return documents
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from application.parser.remote.base import BaseRemote
|
||||
from application.parser.schema.base import Document
|
||||
from langchain_community.document_loaders import WebBaseLoader
|
||||
@@ -39,6 +40,6 @@ class WebLoader(BaseRemote):
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error processing URL {url}: {e}")
|
||||
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
|
||||
continue
|
||||
return documents
|
||||
return documents
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
You are a DocsGPT, friendly and helpful AI assistant by Arc53 that provides help with documents. You give thorough answers with code examples if possible.
|
||||
Use the following pieces of context to help answer the users question. If its not relevant to the question, provide friendly responses.
|
||||
You have access to chat history, and can use it to help answer the question.
|
||||
When using code examples, use the following format:
|
||||
You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you,
|
||||
be proactive and fill in missing information.
|
||||
Users can Upload documents for your context as attachments or sources via UI using the Conversation input box.
|
||||
If appropriate, your answers can include code examples, formatted as follows:
|
||||
```(language)
|
||||
(code)
|
||||
```
|
||||
Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses.
|
||||
Try to respond with mermaid charts if visualization helps with users queries.
|
||||
You effectively utilize chat history, ensuring relevant and tailored responses.
|
||||
Try to use additional provided context if it's available, otherwise use your knowledge and tool capabilities.
|
||||
Allow yourself to be very creative and use your imagination.
|
||||
----------------
|
||||
Possible additional context from uploaded sources:
|
||||
{summaries}
|
||||
@@ -1,9 +1,14 @@
|
||||
You are a helpful AI assistant, DocsGPT, specializing in document assistance, designed to offer detailed and informative responses.
|
||||
You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you,
|
||||
be proactive and fill in missing information.
|
||||
Users can Upload documents for your context as attachments or sources via UI using the Conversation input box.
|
||||
If appropriate, your answers can include code examples, formatted as follows:
|
||||
```(language)
|
||||
(code)
|
||||
```
|
||||
Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses.
|
||||
Try to respond with mermaid charts if visualization helps with users queries.
|
||||
You effectively utilize chat history, ensuring relevant and tailored responses.
|
||||
If a question doesn't align with your context, you provide friendly and helpful replies.
|
||||
Try to use additional provided context if it's available, otherwise use your knowledge and tool capabilities.
|
||||
----------------
|
||||
Possible additional context from uploaded sources:
|
||||
{summaries}
|
||||
@@ -1,13 +1,17 @@
|
||||
You are an AI Assistant, DocsGPT, adept at offering document assistance.
|
||||
Your expertise lies in providing answer on top of provided context.
|
||||
You can leverage the chat history if needed.
|
||||
Answer the question based on the context below.
|
||||
Keep the answer concise. Respond "Irrelevant context" if not sure about the answer.
|
||||
If question is not related to the context, respond "Irrelevant context".
|
||||
When using code examples, use the following format:
|
||||
You are a helpful AI assistant, DocsGPT. You are proactive and helpful. Try to use tools, if they are available to you,
|
||||
be proactive and fill in missing information.
|
||||
Users can Upload documents for your context as attachments or sources via UI using the Conversation input box.
|
||||
If appropriate, your answers can include code examples, formatted as follows:
|
||||
```(language)
|
||||
(code)
|
||||
```
|
||||
----------------
|
||||
Context:
|
||||
{summaries}
|
||||
Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses.
|
||||
Try to respond with mermaid charts if visualization helps with users queries.
|
||||
You effectively utilize chat history, ensuring relevant and tailored responses.
|
||||
Use context provided below or use available tools tool capabilities to answer user queries.
|
||||
If you dont have enough information from the context or tools, answer "I don't know" or "I don't have enough information".
|
||||
Never make up information or provide false information!
|
||||
Allow yourself to be very creative and use your imagination.
|
||||
----------------
|
||||
Context from uploaded sources:
|
||||
{summaries}
|
||||
3
application/prompts/react_final_prompt.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Query: {query}
|
||||
Observations: {observations}
|
||||
Now, using the insights from the observations, formulate a well-structured and precise final answer.
|
||||
10
application/prompts/react_planning_prompt.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
You are an AI assistant and talk like you're thinking out loud. Given the following query, outline a concise thought process that includes key steps and considerations necessary for effective analysis and response. Avoid pointwise formatting. The goal is to break down the query into manageable components without excessive detail, focusing on clarity and logical progression.
|
||||
|
||||
Include the following elements in your thought process:
|
||||
1. Identify the main objective of the query.
|
||||
2. Determine any relevant context or background information needed to understand the query.
|
||||
3. List potential approaches or methods to address the query.
|
||||
4. Highlight any critical factors or constraints that may influence the outcome.
|
||||
|
||||
Query: {query}
|
||||
Summaries: {summaries}
|
||||
@@ -1,25 +1,20 @@
|
||||
anthropic==0.49.0
|
||||
boto3==1.35.97
|
||||
beautifulsoup4==4.12.3
|
||||
boto3==1.38.18
|
||||
beautifulsoup4==4.13.4
|
||||
celery==5.4.0
|
||||
dataclasses-json==0.6.7
|
||||
docx2txt==0.8
|
||||
duckduckgo-search==7.5.2
|
||||
ebooklib==0.18
|
||||
elastic-transport==8.17.0
|
||||
elasticsearch==8.17.1
|
||||
escodegen==1.0.11
|
||||
esprima==4.0.1
|
||||
esutils==1.0.1
|
||||
Flask==3.1.0
|
||||
Flask==3.1.1
|
||||
faiss-cpu==1.9.0.post1
|
||||
flask-restx==1.3.0
|
||||
gevent==24.11.1
|
||||
google-genai==1.3.0
|
||||
google-generativeai==0.8.3
|
||||
gTTS==2.5.4
|
||||
gunicorn==23.0.0
|
||||
html2text==2024.2.26
|
||||
javalang==0.13.0
|
||||
jinja2==3.1.6
|
||||
jiter==0.8.2
|
||||
@@ -27,63 +22,56 @@ jmespath==1.0.1
|
||||
joblib==1.4.2
|
||||
jsonpatch==1.33
|
||||
jsonpointer==3.0.0
|
||||
jsonschema==4.23.0
|
||||
jsonschema-spec==0.2.4
|
||||
jsonschema-specifications==2023.7.1
|
||||
kombu==5.4.2
|
||||
langchain==0.3.20
|
||||
langchain-community==0.3.19
|
||||
langchain-core==0.3.45
|
||||
langchain-openai==0.3.8
|
||||
langchain-text-splitters==0.3.6
|
||||
langsmith==0.3.15
|
||||
langchain-core==0.3.59
|
||||
langchain-openai==0.3.16
|
||||
langchain-text-splitters==0.3.8
|
||||
langsmith==0.3.42
|
||||
lazy-object-proxy==1.10.0
|
||||
lxml==5.3.1
|
||||
markupsafe==3.0.2
|
||||
marshmallow==3.26.1
|
||||
mpmath==1.3.0
|
||||
multidict==6.1.0
|
||||
multidict==6.4.3
|
||||
mypy-extensions==1.0.0
|
||||
networkx==3.4.2
|
||||
numpy==2.2.1
|
||||
openai==1.66.3
|
||||
openapi-schema-validator==0.6.3
|
||||
openapi-spec-validator==0.6.0
|
||||
openapi3-parser==1.1.19
|
||||
openai==1.78.1
|
||||
openapi3-parser==1.1.21
|
||||
orjson==3.10.14
|
||||
packaging==24.1
|
||||
packaging==25.0
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.5
|
||||
pathable==0.4.4
|
||||
pillow==11.1.0
|
||||
portalocker==2.10.1
|
||||
portalocker==3.1.1
|
||||
prance==23.6.21.0
|
||||
primp==0.14.0
|
||||
prompt-toolkit==3.0.50
|
||||
prompt-toolkit==3.0.51
|
||||
protobuf==5.29.3
|
||||
psycopg2-binary==2.9.10
|
||||
py==1.11.0
|
||||
pydantic==2.10.6
|
||||
pydantic-core==2.27.2
|
||||
pydantic-settings==2.7.1
|
||||
pymongo==4.10.1
|
||||
pypdf==5.2.0
|
||||
pymongo==4.11.3
|
||||
pypdf==5.5.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.0.1
|
||||
python-jose==3.4.0
|
||||
python-pptx==1.0.2
|
||||
qdrant-client==1.13.2
|
||||
redis==5.2.1
|
||||
referencing==0.30.2
|
||||
referencing==0.36.2
|
||||
regex==2024.11.6
|
||||
requests==2.32.3
|
||||
retry==0.9.2
|
||||
sentence-transformers==3.3.1
|
||||
tiktoken==0.8.0
|
||||
tokenizers==0.21.0
|
||||
torch==2.5.1
|
||||
torch==2.7.0
|
||||
tqdm==4.67.1
|
||||
transformers==4.49.0
|
||||
transformers==4.51.3
|
||||
typing-extensions==4.12.2
|
||||
typing-inspect==0.9.0
|
||||
tzdata==2024.2
|
||||
@@ -91,7 +79,7 @@ urllib3==2.3.0
|
||||
vine==5.1.0
|
||||
wcwidth==0.2.13
|
||||
werkzeug==3.1.3
|
||||
yarl==1.18.3
|
||||
markdownify==0.14.1
|
||||
yarl==1.20.0
|
||||
markdownify==1.1.0
|
||||
tldextract==5.1.3
|
||||
websockets==14.1
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from application.core.settings import settings
|
||||
from application.llm.llm_creator import LLMCreator
|
||||
from application.retriever.base import BaseRetriever
|
||||
@@ -72,7 +73,7 @@ class ClassicRAG(BaseRetriever):
|
||||
print(f"Rephrased query: {rephrased_query}")
|
||||
return rephrased_query if rephrased_query else self.original_question
|
||||
except Exception as e:
|
||||
print(f"Error rephrasing query: {e}")
|
||||
logging.error(f"Error rephrasing query: {e}", exc_info=True)
|
||||
return self.original_question
|
||||
|
||||
def _get_data(self):
|
||||
|
||||
94
application/storage/base.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Base storage class for file system abstraction."""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import BinaryIO, List, Callable
|
||||
|
||||
|
||||
class BaseStorage(ABC):
|
||||
"""Abstract base class for storage implementations."""
|
||||
|
||||
@abstractmethod
|
||||
def save_file(self, file_data: BinaryIO, path: str) -> dict:
|
||||
"""
|
||||
Save a file to storage.
|
||||
|
||||
Args:
|
||||
file_data: File-like object containing the data
|
||||
path: Path where the file should be stored
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing metadata about the saved file, including:
|
||||
- 'path': The path where the file was saved
|
||||
- 'storage_type': The type of storage (e.g., 'local', 's3')
|
||||
- Other storage-specific metadata (e.g., 'uri', 'bucket_name', etc.)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""
|
||||
Retrieve a file from storage.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
BinaryIO: File-like object containing the file data
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
This method handles the details of retrieving the file and providing
|
||||
it to the processor function in an appropriate way based on the storage type.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""
|
||||
Delete a file from storage.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
bool: True if deletion was successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
bool: True if the file exists
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""
|
||||
List all files in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory path to list
|
||||
|
||||
Returns:
|
||||
List[str]: List of file paths
|
||||
"""
|
||||
pass
|
||||
103
application/storage/local.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Local file system implementation."""
|
||||
import os
|
||||
import shutil
|
||||
from typing import BinaryIO, List, Callable
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
|
||||
|
||||
class LocalStorage(BaseStorage):
|
||||
"""Local file system storage implementation."""
|
||||
|
||||
def __init__(self, base_dir: str = None):
|
||||
"""
|
||||
Initialize local storage.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for all operations. If None, uses current directory.
|
||||
"""
|
||||
self.base_dir = base_dir or os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
def _get_full_path(self, path: str) -> str:
|
||||
"""Get absolute path by combining base_dir and path."""
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.join(self.base_dir, path)
|
||||
|
||||
def save_file(self, file_data: BinaryIO, path: str) -> dict:
|
||||
"""Save a file to local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
if hasattr(file_data, 'save'):
|
||||
file_data.save(full_path)
|
||||
else:
|
||||
with open(full_path, 'wb') as f:
|
||||
shutil.copyfileobj(file_data, f)
|
||||
|
||||
return {
|
||||
'storage_type': 'local'
|
||||
}
|
||||
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""Get a file from local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"File not found: {full_path}")
|
||||
|
||||
return open(full_path, 'rb')
|
||||
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""Delete a file from local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return False
|
||||
|
||||
os.remove(full_path)
|
||||
return True
|
||||
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in local storage."""
|
||||
full_path = self._get_full_path(path)
|
||||
return os.path.exists(full_path)
|
||||
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""List all files in a directory in local storage."""
|
||||
full_path = self._get_full_path(directory)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for root, _, files in os.walk(full_path):
|
||||
for file in files:
|
||||
rel_path = os.path.relpath(os.path.join(root, file), self.base_dir)
|
||||
result.append(rel_path)
|
||||
|
||||
return result
|
||||
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
For local storage, we can directly pass the full path to the processor.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
full_path = self._get_full_path(path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
raise FileNotFoundError(f"File not found: {full_path}")
|
||||
|
||||
return processor_func(local_path=full_path, **kwargs)
|
||||
120
application/storage/s3.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""S3 storage implementation."""
|
||||
import io
|
||||
from typing import BinaryIO, List, Callable
|
||||
import os
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
class S3Storage(BaseStorage):
|
||||
"""AWS S3 storage implementation."""
|
||||
|
||||
def __init__(self, bucket_name=None):
|
||||
"""
|
||||
Initialize S3 storage.
|
||||
|
||||
Args:
|
||||
bucket_name: S3 bucket name (optional, defaults to settings)
|
||||
"""
|
||||
self.bucket_name = bucket_name or getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket")
|
||||
|
||||
# Get credentials from settings
|
||||
aws_access_key_id = getattr(settings, "SAGEMAKER_ACCESS_KEY", None)
|
||||
aws_secret_access_key = getattr(settings, "SAGEMAKER_SECRET_KEY", None)
|
||||
region_name = getattr(settings, "SAGEMAKER_REGION", None)
|
||||
|
||||
self.s3 = boto3.client(
|
||||
's3',
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
region_name=region_name
|
||||
)
|
||||
|
||||
def save_file(self, file_data: BinaryIO, path: str) -> dict:
|
||||
"""Save a file to S3 storage."""
|
||||
self.s3.upload_fileobj(file_data, self.bucket_name, path)
|
||||
|
||||
region = getattr(settings, "SAGEMAKER_REGION", None)
|
||||
|
||||
return {
|
||||
'storage_type': 's3',
|
||||
'bucket_name': self.bucket_name,
|
||||
'uri': f's3://{self.bucket_name}/{path}',
|
||||
'region': region
|
||||
}
|
||||
|
||||
def get_file(self, path: str) -> BinaryIO:
|
||||
"""Get a file from S3 storage."""
|
||||
if not self.file_exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
file_obj = io.BytesIO()
|
||||
self.s3.download_fileobj(self.bucket_name, path, file_obj)
|
||||
file_obj.seek(0)
|
||||
return file_obj
|
||||
|
||||
def delete_file(self, path: str) -> bool:
|
||||
"""Delete a file from S3 storage."""
|
||||
try:
|
||||
self.s3.delete_object(Bucket=self.bucket_name, Key=path)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def file_exists(self, path: str) -> bool:
|
||||
"""Check if a file exists in S3 storage."""
|
||||
try:
|
||||
self.s3.head_object(Bucket=self.bucket_name, Key=path)
|
||||
return True
|
||||
except ClientError:
|
||||
return False
|
||||
|
||||
def list_files(self, directory: str) -> List[str]:
|
||||
"""List all files in a directory in S3 storage."""
|
||||
# Ensure directory ends with a slash if it's not empty
|
||||
if directory and not directory.endswith('/'):
|
||||
directory += '/'
|
||||
|
||||
result = []
|
||||
paginator = self.s3.get_paginator('list_objects_v2')
|
||||
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory)
|
||||
|
||||
for page in pages:
|
||||
if 'Contents' in page:
|
||||
for obj in page['Contents']:
|
||||
result.append(obj['Key'])
|
||||
|
||||
return result
|
||||
|
||||
def process_file(self, path: str, processor_func: Callable, **kwargs):
|
||||
"""
|
||||
Process a file using the provided processor function.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
processor_func: Function that processes the file
|
||||
**kwargs: Additional arguments to pass to the processor function
|
||||
|
||||
Returns:
|
||||
The result of the processor function
|
||||
"""
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
if not self.file_exists(path):
|
||||
raise FileNotFoundError(f"File not found in S3: {path}")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=os.path.splitext(path)[1], delete=True) as temp_file:
|
||||
try:
|
||||
# Download the file from S3 to the temporary file
|
||||
self.s3.download_fileobj(self.bucket_name, path, temp_file)
|
||||
temp_file.flush()
|
||||
|
||||
return processor_func(local_path=temp_file.name, **kwargs)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing S3 file {path}: {e}", exc_info=True)
|
||||
raise
|
||||
32
application/storage/storage_creator.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Storage factory for creating different storage implementations."""
|
||||
from typing import Dict, Type
|
||||
|
||||
from application.storage.base import BaseStorage
|
||||
from application.storage.local import LocalStorage
|
||||
from application.storage.s3 import S3Storage
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
class StorageCreator:
|
||||
storages: Dict[str, Type[BaseStorage]] = {
|
||||
"local": LocalStorage,
|
||||
"s3": S3Storage,
|
||||
}
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_storage(cls) -> BaseStorage:
|
||||
if cls._instance is None:
|
||||
storage_type = getattr(settings, "STORAGE_TYPE", "local")
|
||||
cls._instance = cls.create_storage(storage_type)
|
||||
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def create_storage(cls, type_name: str, *args, **kwargs) -> BaseStorage:
|
||||
storage_class = cls.storages.get(type_name.lower())
|
||||
if not storage_class:
|
||||
raise ValueError(f"No storage implementation found for type {type_name}")
|
||||
|
||||
return storage_class(*args, **kwargs)
|
||||
@@ -2,10 +2,11 @@ import sys
|
||||
from datetime import datetime
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
from application.core.settings import settings
|
||||
from application.utils import num_tokens_from_object_or_list, num_tokens_from_string
|
||||
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
usage_collection = db["token_usage"]
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
from application.core.settings import settings
|
||||
from application.vectorstore.document_class import Document
|
||||
import elasticsearch
|
||||
|
||||
|
||||
|
||||
|
||||
class ElasticsearchStore(BaseVectorStore):
|
||||
@@ -26,8 +23,7 @@ class ElasticsearchStore(BaseVectorStore):
|
||||
else:
|
||||
raise ValueError("Please provide either elasticsearch_url or cloud_id.")
|
||||
|
||||
|
||||
|
||||
import elasticsearch
|
||||
ElasticsearchStore._es_connection = elasticsearch.Elasticsearch(**connection_params)
|
||||
|
||||
self.docsearch = ElasticsearchStore._es_connection
|
||||
@@ -155,8 +151,6 @@ class ElasticsearchStore(BaseVectorStore):
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
from elasticsearch.helpers import BulkIndexError, bulk
|
||||
|
||||
bulk_kwargs = bulk_kwargs or {}
|
||||
import uuid
|
||||
embeddings = []
|
||||
@@ -189,6 +183,7 @@ class ElasticsearchStore(BaseVectorStore):
|
||||
|
||||
|
||||
if len(requests) > 0:
|
||||
from elasticsearch.helpers import BulkIndexError, bulk
|
||||
try:
|
||||
success, failed = bulk(
|
||||
self._es_connection,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from langchain_community.vectorstores import FAISS
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.parser.schema.base import Document
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
|
||||
|
||||
def get_vectorstore(path: str) -> str:
|
||||
if path:
|
||||
vectorstore = os.path.join("application", "indexes", path)
|
||||
vectorstore = f"indexes/{path}"
|
||||
else:
|
||||
vectorstore = os.path.join("application")
|
||||
vectorstore = "indexes"
|
||||
return vectorstore
|
||||
|
||||
|
||||
@@ -21,16 +23,36 @@ class FaissStore(BaseVectorStore):
|
||||
self.source_id = source_id
|
||||
self.path = get_vectorstore(source_id)
|
||||
self.embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, embeddings_key)
|
||||
self.storage = StorageCreator.get_storage()
|
||||
|
||||
try:
|
||||
if docs_init:
|
||||
self.docsearch = FAISS.from_documents(docs_init, self.embeddings)
|
||||
else:
|
||||
self.docsearch = FAISS.load_local(
|
||||
self.path, self.embeddings, allow_dangerous_deserialization=True
|
||||
)
|
||||
except Exception:
|
||||
raise
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
faiss_path = f"{self.path}/index.faiss"
|
||||
pkl_path = f"{self.path}/index.pkl"
|
||||
|
||||
if not self.storage.file_exists(faiss_path) or not self.storage.file_exists(pkl_path):
|
||||
raise FileNotFoundError(f"Index files not found in storage at {self.path}")
|
||||
|
||||
faiss_file = self.storage.get_file(faiss_path)
|
||||
pkl_file = self.storage.get_file(pkl_path)
|
||||
|
||||
local_faiss_path = os.path.join(temp_dir, "index.faiss")
|
||||
local_pkl_path = os.path.join(temp_dir, "index.pkl")
|
||||
|
||||
with open(local_faiss_path, 'wb') as f:
|
||||
f.write(faiss_file.read())
|
||||
|
||||
with open(local_pkl_path, 'wb') as f:
|
||||
f.write(pkl_file.read())
|
||||
|
||||
self.docsearch = FAISS.load_local(
|
||||
temp_dir, self.embeddings, allow_dangerous_deserialization=True
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error loading FAISS index: {str(e)}")
|
||||
|
||||
self.assert_embedding_dimensions(self.embeddings)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from application.core.settings import settings
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
from application.vectorstore.document_class import Document
|
||||
@@ -146,7 +147,7 @@ class MongoDBVectorStore(BaseVectorStore):
|
||||
|
||||
return chunks
|
||||
except Exception as e:
|
||||
print(f"Error getting chunks: {e}")
|
||||
logging.error(f"Error getting chunks: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
def add_chunk(self, text, metadata=None):
|
||||
@@ -172,5 +173,5 @@ class MongoDBVectorStore(BaseVectorStore):
|
||||
result = self._collection.delete_one({"_id": object_id})
|
||||
return result.deleted_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error deleting chunk: {e}")
|
||||
logging.error(f"Error deleting chunk: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from langchain_community.vectorstores.qdrant import Qdrant
|
||||
from application.vectorstore.base import BaseVectorStore
|
||||
from application.core.settings import settings
|
||||
from qdrant_client import models
|
||||
|
||||
|
||||
class QdrantStore(BaseVectorStore):
|
||||
def __init__(self, source_id: str = "", embeddings_key: str = "embeddings"):
|
||||
from qdrant_client import models
|
||||
from langchain_community.vectorstores.qdrant import Qdrant
|
||||
|
||||
self._filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import string
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from collections import Counter
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bson.dbref import DBRef
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
from application.agents.agent_creator import AgentCreator
|
||||
from application.api.answer.routes import get_prompt
|
||||
|
||||
from application.core.mongo_db import MongoDB
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.bulk import SimpleDirectoryReader
|
||||
from application.parser.chunking import Chunker
|
||||
from application.parser.embedding_pipeline import embed_and_store_documents
|
||||
from application.parser.file.bulk import SimpleDirectoryReader
|
||||
from application.parser.remote.remote_creator import RemoteCreator
|
||||
from application.parser.schema.base import Document
|
||||
from application.parser.chunking import Chunker
|
||||
from application.utils import count_tokens_docs
|
||||
from application.retriever.retriever_creator import RetrieverCreator
|
||||
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
from application.utils import count_tokens_docs, num_tokens_from_string
|
||||
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
sources_collection = db["sources"]
|
||||
|
||||
# Constants
|
||||
@@ -27,18 +39,22 @@ MIN_TOKENS = 150
|
||||
MAX_TOKENS = 1250
|
||||
RECURSION_DEPTH = 2
|
||||
|
||||
|
||||
# Define a function to extract metadata from a given filename.
|
||||
def metadata_from_filename(title):
|
||||
return {"title": title}
|
||||
|
||||
|
||||
# Define a function to generate a random string of a given length.
|
||||
def generate_random_string(length):
|
||||
return "".join([string.ascii_letters[i % 52] for i in range(length)])
|
||||
|
||||
|
||||
current_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
|
||||
def extract_zip_recursive(zip_path, extract_to, current_depth=0, max_depth=5):
|
||||
"""
|
||||
Recursively extract zip files with a limit on recursion depth.
|
||||
@@ -58,7 +74,7 @@ def extract_zip_recursive(zip_path, extract_to, current_depth=0, max_depth=5):
|
||||
zip_ref.extractall(extract_to)
|
||||
os.remove(zip_path) # Remove the zip file after extracting
|
||||
except Exception as e:
|
||||
logging.error(f"Error extracting zip file {zip_path}: {e}")
|
||||
logging.error(f"Error extracting zip file {zip_path}: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
# Check for nested zip files and extract them
|
||||
@@ -69,6 +85,7 @@ def extract_zip_recursive(zip_path, extract_to, current_depth=0, max_depth=5):
|
||||
file_path = os.path.join(root, file)
|
||||
extract_zip_recursive(file_path, root, current_depth + 1, max_depth)
|
||||
|
||||
|
||||
def download_file(url, params, dest_path):
|
||||
try:
|
||||
response = requests.get(url, params=params)
|
||||
@@ -79,6 +96,7 @@ def download_file(url, params, dest_path):
|
||||
logging.error(f"Error downloading file: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def upload_index(full_path, file_data):
|
||||
try:
|
||||
if settings.VECTOR_STORE == "faiss":
|
||||
@@ -87,7 +105,9 @@ def upload_index(full_path, file_data):
|
||||
"file_pkl": open(full_path + "/index.pkl", "rb"),
|
||||
}
|
||||
response = requests.post(
|
||||
urljoin(settings.API_URL, "/api/upload_index"), files=files, data=file_data
|
||||
urljoin(settings.API_URL, "/api/upload_index"),
|
||||
files=files,
|
||||
data=file_data,
|
||||
)
|
||||
else:
|
||||
response = requests.post(
|
||||
@@ -102,6 +122,76 @@ def upload_index(full_path, file_data):
|
||||
for file in files.values():
|
||||
file.close()
|
||||
|
||||
|
||||
def run_agent_logic(agent_config, input_data):
|
||||
try:
|
||||
source = agent_config.get("source")
|
||||
retriever = agent_config.get("retriever", "classic")
|
||||
if isinstance(source, DBRef):
|
||||
source_doc = db.dereference(source)
|
||||
source = str(source_doc["_id"])
|
||||
retriever = source_doc.get("retriever", agent_config.get("retriever"))
|
||||
else:
|
||||
source = {}
|
||||
source = {"active_docs": source}
|
||||
chunks = int(agent_config.get("chunks", 2))
|
||||
prompt_id = agent_config.get("prompt_id", "default")
|
||||
user_api_key = agent_config["key"]
|
||||
agent_type = agent_config.get("agent_type", "classic")
|
||||
decoded_token = {"sub": agent_config.get("user")}
|
||||
prompt = get_prompt(prompt_id)
|
||||
agent = AgentCreator.create_agent(
|
||||
agent_type,
|
||||
endpoint="webhook",
|
||||
llm_name=settings.LLM_NAME,
|
||||
gpt_model=settings.MODEL_NAME,
|
||||
api_key=settings.API_KEY,
|
||||
user_api_key=user_api_key,
|
||||
prompt=prompt,
|
||||
chat_history=[],
|
||||
decoded_token=decoded_token,
|
||||
attachments=[],
|
||||
)
|
||||
retriever = RetrieverCreator.create_retriever(
|
||||
retriever,
|
||||
source=source,
|
||||
chat_history=[],
|
||||
prompt=prompt,
|
||||
chunks=chunks,
|
||||
token_limit=settings.DEFAULT_MAX_HISTORY,
|
||||
gpt_model=settings.MODEL_NAME,
|
||||
user_api_key=user_api_key,
|
||||
decoded_token=decoded_token,
|
||||
)
|
||||
answer = agent.gen(query=input_data, retriever=retriever)
|
||||
response_full = ""
|
||||
thought = ""
|
||||
source_log_docs = []
|
||||
tool_calls = []
|
||||
|
||||
for line in answer:
|
||||
if "answer" in line:
|
||||
response_full += str(line["answer"])
|
||||
elif "sources" in line:
|
||||
source_log_docs.extend(line["sources"])
|
||||
elif "tool_calls" in line:
|
||||
tool_calls.extend(line["tool_calls"])
|
||||
elif "thought" in line:
|
||||
thought += line["thought"]
|
||||
|
||||
result = {
|
||||
"answer": response_full,
|
||||
"sources": source_log_docs,
|
||||
"tool_calls": tool_calls,
|
||||
"thought": thought,
|
||||
}
|
||||
logging.info(f"Agent response: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"Error in run_agent_logic: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
# Define the main function for ingesting and processing documents.
|
||||
def ingest_worker(
|
||||
self, directory, formats, name_job, filename, user, retriever="classic"
|
||||
@@ -126,62 +216,87 @@ def ingest_worker(
|
||||
limit = None
|
||||
exclude = True
|
||||
sample = False
|
||||
|
||||
storage = StorageCreator.get_storage()
|
||||
|
||||
full_path = os.path.join(directory, user, name_job)
|
||||
source_file_path = os.path.join(full_path, filename)
|
||||
|
||||
logging.info(f"Ingest file: {full_path}", extra={"user": user, "job": name_job})
|
||||
file_data = {"name": name_job, "file": filename, "user": user}
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
os.makedirs(full_path)
|
||||
download_file(urljoin(settings.API_URL, "/api/download"), file_data, os.path.join(full_path, filename))
|
||||
# Create temporary working directory
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
try:
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
# check if file is .zip and extract it
|
||||
if filename.endswith(".zip"):
|
||||
extract_zip_recursive(
|
||||
os.path.join(full_path, filename), full_path, 0, RECURSION_DEPTH
|
||||
)
|
||||
# Download file from storage to temp directory
|
||||
temp_file_path = os.path.join(temp_dir, filename)
|
||||
file_data = storage.get_file(source_file_path)
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"current": 1})
|
||||
with open(temp_file_path, "wb") as f:
|
||||
f.write(file_data.read())
|
||||
|
||||
raw_docs = SimpleDirectoryReader(
|
||||
input_dir=full_path,
|
||||
input_files=input_files,
|
||||
recursive=recursive,
|
||||
required_exts=formats,
|
||||
num_files_limit=limit,
|
||||
exclude_hidden=exclude,
|
||||
file_metadata=metadata_from_filename,
|
||||
).load_data()
|
||||
self.update_state(state="PROGRESS", meta={"current": 1})
|
||||
|
||||
chunker = Chunker(
|
||||
chunking_strategy="classic_chunk",
|
||||
max_tokens=MAX_TOKENS,
|
||||
min_tokens=MIN_TOKENS,
|
||||
duplicate_headers=False
|
||||
)
|
||||
raw_docs = chunker.chunk(documents=raw_docs)
|
||||
# Handle zip files
|
||||
if filename.endswith(".zip"):
|
||||
logging.info(f"Extracting zip file: {filename}")
|
||||
extract_zip_recursive(
|
||||
temp_file_path, temp_dir, current_depth=0, max_depth=RECURSION_DEPTH
|
||||
)
|
||||
|
||||
docs = [Document.to_langchain_format(raw_doc) for raw_doc in raw_docs]
|
||||
id = ObjectId()
|
||||
if sample:
|
||||
logging.info(f"Sample mode enabled. Using {limit} documents.")
|
||||
|
||||
embed_and_store_documents(docs, full_path, id, self)
|
||||
tokens = count_tokens_docs(docs)
|
||||
self.update_state(state="PROGRESS", meta={"current": 100})
|
||||
reader = SimpleDirectoryReader(
|
||||
input_dir=temp_dir,
|
||||
input_files=input_files,
|
||||
recursive=recursive,
|
||||
required_exts=formats,
|
||||
exclude_hidden=exclude,
|
||||
file_metadata=metadata_from_filename,
|
||||
)
|
||||
raw_docs = reader.load_data()
|
||||
|
||||
if sample:
|
||||
for i in range(min(5, len(raw_docs))):
|
||||
logging.info(f"Sample document {i}: {raw_docs[i]}")
|
||||
chunker = Chunker(
|
||||
chunking_strategy="classic_chunk",
|
||||
max_tokens=MAX_TOKENS,
|
||||
min_tokens=MIN_TOKENS,
|
||||
duplicate_headers=False,
|
||||
)
|
||||
raw_docs = chunker.chunk(documents=raw_docs)
|
||||
|
||||
file_data.update({
|
||||
"tokens": tokens,
|
||||
"retriever": retriever,
|
||||
"id": str(id),
|
||||
"type": "local",
|
||||
})
|
||||
upload_index(full_path, file_data)
|
||||
docs = [Document.to_langchain_format(raw_doc) for raw_doc in raw_docs]
|
||||
|
||||
# delete local
|
||||
shutil.rmtree(full_path)
|
||||
id = ObjectId()
|
||||
|
||||
vector_store_path = os.path.join(temp_dir, "vector_store")
|
||||
os.makedirs(vector_store_path, exist_ok=True)
|
||||
|
||||
embed_and_store_documents(docs, vector_store_path, id, self)
|
||||
|
||||
tokens = count_tokens_docs(docs)
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"current": 100})
|
||||
|
||||
if sample:
|
||||
for i in range(min(5, len(raw_docs))):
|
||||
logging.info(f"Sample document {i}: {raw_docs[i]}")
|
||||
file_data = {
|
||||
"name": name_job,
|
||||
"file": filename,
|
||||
"user": user,
|
||||
"tokens": tokens,
|
||||
"retriever": retriever,
|
||||
"id": str(id),
|
||||
"type": "local",
|
||||
}
|
||||
|
||||
upload_index(vector_store_path, file_data)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in ingest_worker: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
return {
|
||||
"directory": directory,
|
||||
@@ -192,6 +307,7 @@ def ingest_worker(
|
||||
"limited": False,
|
||||
}
|
||||
|
||||
|
||||
def remote_worker(
|
||||
self,
|
||||
source_data,
|
||||
@@ -203,7 +319,7 @@ def remote_worker(
|
||||
sync_frequency="never",
|
||||
operation_mode="upload",
|
||||
doc_id=None,
|
||||
):
|
||||
):
|
||||
full_path = os.path.join(directory, user, name_job)
|
||||
if not os.path.exists(full_path):
|
||||
os.makedirs(full_path)
|
||||
@@ -218,7 +334,7 @@ def remote_worker(
|
||||
chunking_strategy="classic_chunk",
|
||||
max_tokens=MAX_TOKENS,
|
||||
min_tokens=MIN_TOKENS,
|
||||
duplicate_headers=False
|
||||
duplicate_headers=False,
|
||||
)
|
||||
docs = chunker.chunk(documents=raw_docs)
|
||||
docs = [Document.to_langchain_format(raw_doc) for raw_doc in raw_docs]
|
||||
@@ -260,6 +376,7 @@ def remote_worker(
|
||||
logging.info("remote_worker task completed successfully")
|
||||
return {"urls": source_data, "name_job": name_job, "user": user, "limited": False}
|
||||
|
||||
|
||||
def sync(
|
||||
self,
|
||||
source_data,
|
||||
@@ -285,10 +402,11 @@ def sync(
|
||||
doc_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error during sync: {e}")
|
||||
logging.error(f"Error during sync: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
def sync_worker(self, frequency):
|
||||
sync_counts = Counter()
|
||||
sources = sources_collection.find()
|
||||
@@ -312,3 +430,121 @@ def sync_worker(self, frequency):
|
||||
key: sync_counts[key]
|
||||
for key in ["total_sync_count", "sync_success", "sync_failure"]
|
||||
}
|
||||
|
||||
|
||||
def attachment_worker(self, file_info, user):
|
||||
"""
|
||||
Process and store a single attachment without vectorization.
|
||||
"""
|
||||
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo[settings.MONGO_DB_NAME]
|
||||
attachments_collection = db["attachments"]
|
||||
|
||||
filename = file_info["filename"]
|
||||
attachment_id = file_info["attachment_id"]
|
||||
relative_path = file_info["path"]
|
||||
metadata = file_info.get("metadata", {})
|
||||
|
||||
try:
|
||||
self.update_state(state="PROGRESS", meta={"current": 10})
|
||||
storage = StorageCreator.get_storage()
|
||||
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"current": 30, "status": "Processing content"}
|
||||
)
|
||||
|
||||
content = storage.process_file(
|
||||
relative_path,
|
||||
lambda local_path, **kwargs: SimpleDirectoryReader(
|
||||
input_files=[local_path], exclude_hidden=True, errors="ignore"
|
||||
).load_data()[0].text
|
||||
)
|
||||
|
||||
token_count = num_tokens_from_string(content)
|
||||
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"current": 80, "status": "Storing in database"}
|
||||
)
|
||||
|
||||
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
doc_id = ObjectId(attachment_id)
|
||||
attachments_collection.insert_one(
|
||||
{
|
||||
"_id": doc_id,
|
||||
"user": user,
|
||||
"path": relative_path,
|
||||
"content": content,
|
||||
"token_count": token_count,
|
||||
"mime_type": mime_type,
|
||||
"date": datetime.datetime.now(),
|
||||
"metadata": metadata,
|
||||
}
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"Stored attachment with ID: {attachment_id}", extra={"user": user}
|
||||
)
|
||||
|
||||
self.update_state(
|
||||
state="PROGRESS", meta={"current": 100, "status": "Complete"}
|
||||
)
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"path": relative_path,
|
||||
"token_count": token_count,
|
||||
"attachment_id": attachment_id,
|
||||
"mime_type": mime_type,
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Error processing file {filename}: {e}",
|
||||
extra={"user": user},
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def agent_webhook_worker(self, agent_id, payload):
|
||||
"""
|
||||
Process the webhook payload for an agent.
|
||||
|
||||
Args:
|
||||
self: Reference to the instance of the task.
|
||||
agent_id (str): Unique identifier for the agent.
|
||||
payload (dict): The payload data from the webhook.
|
||||
|
||||
Returns:
|
||||
dict: Information about the processed webhook.
|
||||
"""
|
||||
mongo = MongoDB.get_client()
|
||||
db = mongo["docsgpt"]
|
||||
agents_collection = db["agents"]
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"current": 1})
|
||||
try:
|
||||
agent_oid = ObjectId(agent_id)
|
||||
agent_config = agents_collection.find_one({"_id": agent_oid})
|
||||
if not agent_config:
|
||||
raise ValueError(f"Agent with ID {agent_id} not found.")
|
||||
input_data = json.dumps(payload)
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing agent webhook: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
self.update_state(state="PROGRESS", meta={"current": 50})
|
||||
try:
|
||||
result = run_agent_logic(agent_config, input_data)
|
||||
except Exception as e:
|
||||
logging.error(f"Error running agent logic: {e}", exc_info=True)
|
||||
return {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
self.update_state(state="PROGRESS", meta={"current": 100})
|
||||
logging.info(
|
||||
f"Webhook processed for agent {agent_id}", extra={"agent_id": agent_id}
|
||||
)
|
||||
return {"status": "success", "result": result}
|
||||
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
user: root
|
||||
build: ../application
|
||||
environment:
|
||||
- API_KEY=$API_KEY
|
||||
@@ -27,15 +28,16 @@ services:
|
||||
- "7091:7091"
|
||||
volumes:
|
||||
- ../application/indexes:/app/application/indexes
|
||||
- ../application/inputs:/app/application/inputs
|
||||
- ../application/inputs:/app/inputs
|
||||
- ../application/vectors:/app/application/vectors
|
||||
depends_on:
|
||||
- redis
|
||||
- mongo
|
||||
|
||||
worker:
|
||||
user: root
|
||||
build: ../application
|
||||
command: celery -A application.app.celery worker -l INFO --pool=gevent -B
|
||||
command: celery -A application.app.celery worker -l INFO -B
|
||||
environment:
|
||||
- API_KEY=$API_KEY
|
||||
- EMBEDDINGS_KEY=$API_KEY
|
||||
@@ -45,6 +47,10 @@ services:
|
||||
- MONGO_URI=mongodb://mongo:27017/docsgpt
|
||||
- API_URL=http://backend:7091
|
||||
- CACHE_REDIS_URL=redis://redis:6379/2
|
||||
volumes:
|
||||
- ../application/indexes:/app/application/indexes
|
||||
- ../application/inputs:/app/inputs
|
||||
- ../application/vectors:/app/application/vectors
|
||||
depends_on:
|
||||
- redis
|
||||
- mongo
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Setting up the DocsGPT Widget in Your React Project
|
||||
|
||||
## Introduction:
|
||||
The DocsGPT Widget is a powerful tool that allows you to integrate AI-powered documentation assistance into your web applications. This guide will walk you through the installation and usage of the DocsGPT Widget in your React project. Whether you're building a web app or a knowledge base, this widget can enhance your user experience.
|
||||
|
||||
## Installation
|
||||
First, make sure you have Node.js and npm installed in your project. Then go to your project and install a new dependency: `npm install docsgpt`.
|
||||
|
||||
## Usage
|
||||
In the file where you want to use the widget, import it and include the CSS file:
|
||||
```js
|
||||
import { DocsGPTWidget } from "docsgpt";
|
||||
```
|
||||
|
||||
|
||||
Now, you can use the widget in your component like this :
|
||||
```jsx
|
||||
<DocsGPTWidget
|
||||
apiHost="https://your-docsgpt-api.com"
|
||||
apiKey=""
|
||||
avatar = "https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"
|
||||
title = "Get AI assistance"
|
||||
description = "DocsGPT's AI Chatbot is here to help"
|
||||
heroTitle = "Welcome to DocsGPT !"
|
||||
heroDescription="This chatbot is built with DocsGPT and utilises GenAI,
|
||||
please review important information using sources."
|
||||
theme = "dark"
|
||||
buttonIcon = "https://your-icon"
|
||||
buttonBg = "#222327"
|
||||
/>
|
||||
```
|
||||
## Props Table for DocsGPT Widget
|
||||
|
||||
| **Prop** | **Type** | **Default Value** | **Description** |
|
||||
|--------------------|------------------|-------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
||||
| **`apiHost`** | `string` | `"https://gptcloud.arc53.com"` | The URL of your DocsGPT API for vector search and chatbot queries. |
|
||||
| **`apiKey`** | `string` | `""` | Your API key for authentication. Can be left empty if authentication is not required. |
|
||||
| **`avatar`** | `string` | `"https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png"` | Specifies the URL of the avatar or image representing the chatbot. |
|
||||
| **`title`** | `string` | `"Get AI assistance"` | Sets the title text displayed in the chatbot interface. |
|
||||
| **`description`** | `string` | `"DocsGPT's AI Chatbot is here to help"` | Provides a brief description of the chatbot's purpose or functionality. |
|
||||
| **`heroTitle`** | `string` | `"Welcome to DocsGPT !"` | Displays a welcome title when users interact with the chatbot. |
|
||||
| **`heroDescription`** | `string` | `"This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources."` | Provides additional introductory text or information about the chatbot's capabilities. |
|
||||
| **`theme`** | `"dark" \| "light"` | `"dark"` | Allows you to select the theme for the chatbot interface. Accepts `"dark"` or `"light"`. |
|
||||
| **`buttonIcon`** | `string` | `"https://your-icon"` | Specifies the URL of the icon image for the widget's launch button. |
|
||||
| **`buttonBg`** | `string` | `"#222327"` | Sets the background color of the widget's launch button. |
|
||||
| **`size`** | `"small" \| "medium"` | `"medium"` | Sets the size of the widget. Options are `"small"` or `"medium"`. |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- **Customizing Props:** All properties can be overridden when embedding the widget. For example, you can provide a unique avatar, title, or color scheme to better align with your brand.
|
||||
- **Default Theme:** The widget defaults to the dark theme unless explicitly set to `"light"`.
|
||||
- **API Key:** If the `apiKey` is not required for your application, leave it empty.
|
||||
|
||||
This table provides a clear overview of the customization options available for tailoring the DocsGPT widget to fit your application.
|
||||
|
||||
|
||||
## How to use DocsGPTWidget with [Nextra](https://nextra.site/) (Next.js + MDX)
|
||||
Install your widget as described above and then go to your `pages/` folder and create a new file `_app.js` with the following content:
|
||||
```js
|
||||
import { DocsGPTWidget } from "docsgpt";
|
||||
|
||||
export default function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
<DocsGPTWidget selectDocs="local/docsgpt-sep.zip/"/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
## How to use DocsGPTWidget with HTML
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<title>HTML + CSS</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>This is a simple HTML + CSS template!</h1>
|
||||
<div id="app"></div>
|
||||
<!-- Include the widget script from dist/modern or dist/legacy -->
|
||||
<script
|
||||
src="https://unpkg.com/docsgpt/dist/modern/main.js"
|
||||
type="module"
|
||||
></script>
|
||||
<script type="module">
|
||||
window.onload = function () {
|
||||
renderDocsGPTWidget("app", {
|
||||
apiKey: "",
|
||||
size: "medium",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
To link the widget to your api and your documents you can pass parameters to the renderDocsGPTWidget('div id', { parameters }).
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DocsGPT Widget</title>
|
||||
<script src="https://unpkg.com/docsgpt/dist/modern/main.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Include the widget script from dist/modern or dist/legacy -->
|
||||
<script type="module">
|
||||
window.onload = function() {
|
||||
renderDocsGPTWidget('app', {
|
||||
apiHost: 'http://localhost:7001',
|
||||
apiKey:"",
|
||||
avatar: 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png',
|
||||
title: 'Get AI assistance',
|
||||
description: "DocsGPT's AI Chatbot is here to help",
|
||||
heroTitle: 'Welcome to DocsGPT!',
|
||||
heroDescription: 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.',
|
||||
theme:"dark",
|
||||
buttonIcon:"https://your-icon",
|
||||
buttonBg:"#222327"
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
# SearchBar
|
||||
|
||||
The `SearchBar` component is an interactive search bar designed to provide search results based on **vector similarity search**. It also includes the capability to open the AI Chatbot, enabling users to query.
|
||||
|
||||
---
|
||||
|
||||
### Importing the Component
|
||||
```tsx
|
||||
import { SearchBar } from "docsgpt-react";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Usage Example
|
||||
```tsx
|
||||
<SearchBar
|
||||
apiKey="your-api-key"
|
||||
apiHost="https://gptcloud.arc53.com"
|
||||
theme="light"
|
||||
placeholder="Search or Ask AI..."
|
||||
width="300px"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTML embedding for Search bar
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SearchBar Embedding</title>
|
||||
<script src="https://unpkg.com/docsgpt/dist/modern/main.js"></script> <!-- The bundled JavaScript file -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- Element where the SearchBar will render -->
|
||||
<div id="search-bar-container"></div>
|
||||
|
||||
<script>
|
||||
// Render the SearchBar into the specified element
|
||||
renderSearchBar('search-bar-container', {
|
||||
apiKey: 'your-api-key-here',
|
||||
apiHost: 'https://your-api-host.com',
|
||||
theme: 'light',
|
||||
placeholder: 'Search here...',
|
||||
width: '300px'
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| **Prop** | **Type** | **Default Value** | **Description** |
|
||||
|-----------------|-----------|-------------------------------------|--------------------------------------------------------------------------------------------------|
|
||||
| **`apiKey`** | `string` | `"74039c6d-bff7-44ce-ae55-2973cbf13837"` | Your API key generated from the app. Used for authenticating requests. |
|
||||
| **`apiHost`** | `string` | `"https://gptcloud.arc53.com"` | The base URL of the server hosting the vector similarity search and chatbot services. |
|
||||
| **`theme`** | `"dark" \| "light"` | `"dark"` | The theme of the search bar. Accepts `"dark"` or `"light"`. |
|
||||
| **`placeholder`** | `string` | `"Search or Ask AI..."` | Placeholder text displayed in the search input field. |
|
||||
| **`width`** | `string` | `"256px"` | Width of the search bar. Accepts any valid CSS width value (e.g., `"300px"`, `"100%"`, `"20rem"`). |
|
||||
|
||||
|
||||
Feel free to reach out if you need help customizing or extending the `SearchBar`!
|
||||
|
||||
## Our github
|
||||
|
||||
[DocsGPT](https://github.com/arc53/DocsGPT)
|
||||
|
||||
You can find the source code in the extensions/react-widget folder.
|
||||
|
||||
For more information about React, refer to this [link here](https://react.dev/learn)
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function MyApp({ Component, pageProps }) {
|
||||
return (
|
||||
<>
|
||||
<Component {...pageProps} />
|
||||
<DocsGPTWidget showSources={true} apiKey="6dd66edf-d374-4904-93af-ab0c6d41ee56" theme="dark" size="medium" />
|
||||
<DocsGPTWidget showSources={true} apiKey="5d8270cb-735f-484e-9dc9-5b407f24e652" theme="dark" size="medium" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Please put appropriate value
|
||||
VITE_API_HOST=http://0.0.0.0:7091
|
||||
VITE_BASE_URL=http://localhost:5173
|
||||
VITE_API_HOST=http://127.0.0.1:7091
|
||||
VITE_API_STREAMING=true
|
||||
1343
frontend/package-lock.json
generated
@@ -21,15 +21,17 @@
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"chart.js": "^4.4.4",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"mermaid": "^11.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"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",
|
||||
@@ -40,6 +42,7 @@
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
@@ -60,8 +63,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",
|
||||
|
||||
@@ -4,4 +4,5 @@ module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
printWidth: 80,
|
||||
}
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
};
|
||||
|
||||
1
frontend/public/toolIcons/tool_read_webpage.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -29,7 +29,7 @@ export default function About() {
|
||||
If you want to add your own documentation, please follow the
|
||||
instruction below:
|
||||
</p>
|
||||
<p className="mt-4 ml-2">
|
||||
<p className="ml-2 mt-4">
|
||||
1. Navigate to{' '}
|
||||
<span className="bg-gray-200 italic dark:bg-outer-space">
|
||||
{' '}
|
||||
@@ -37,13 +37,13 @@ export default function About() {
|
||||
</span>{' '}
|
||||
folder
|
||||
</p>
|
||||
<p className="mt-4 ml-2">
|
||||
<p className="ml-2 mt-4">
|
||||
2. Install dependencies from{' '}
|
||||
<span className="bg-gray-200 italic dark:bg-outer-space">
|
||||
pip install -r requirements.txt
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-4 ml-2">
|
||||
<p className="ml-2 mt-4">
|
||||
3. Prepare a{' '}
|
||||
<span className="bg-gray-200 italic dark:bg-outer-space">.env</span>{' '}
|
||||
file. Copy{' '}
|
||||
@@ -54,7 +54,7 @@ export default function About() {
|
||||
<span className="bg-gray-200 italic dark:bg-outer-space">.env</span>{' '}
|
||||
with your OpenAI API token
|
||||
</p>
|
||||
<p className="mt-4 ml-2">
|
||||
<p className="ml-2 mt-4">
|
||||
4. Run the app with{' '}
|
||||
<span className="bg-gray-200 italic dark:bg-outer-space">
|
||||
python app.py
|
||||
@@ -64,9 +64,9 @@ export default function About() {
|
||||
|
||||
<p>
|
||||
Currently It uses{' '}
|
||||
<span className="text-blue-950 font-medium">DocsGPT</span>{' '}
|
||||
<span className="font-medium text-blue-950">DocsGPT</span>{' '}
|
||||
documentation, so it will respond to information relevant to{' '}
|
||||
<span className="text-blue-950 font-medium">DocsGPT</span>. If you
|
||||
<span className="font-medium text-blue-950">DocsGPT</span>. If you
|
||||
want to train it on different documentation - please follow
|
||||
<a
|
||||
className="text-blue-500"
|
||||
|
||||
@@ -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={
|
||||
@@ -63,7 +64,8 @@ export default function App() {
|
||||
>
|
||||
<Route index element={<Conversation />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/settings" element={<Setting />} />
|
||||
<Route path="/settings/*" element={<Setting />} />
|
||||
<Route path="/agents/*" element={<Agents />} />
|
||||
</Route>
|
||||
<Route path="/share/:identifier" element={<SharedConversation />} />
|
||||
<Route path="/*" element={<PageNotFound />} />
|
||||
|
||||
@@ -19,18 +19,18 @@ export default function Hero({
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col text-black-1000 dark:text-bright-gray items-center justify-between">
|
||||
<div className="flex h-full w-full flex-col items-center justify-between text-black-1000 dark:text-bright-gray">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col items-center justify-center flex-grow pt-8 md:pt-0">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex flex-grow flex-col items-center justify-center pt-8 md:pt-0">
|
||||
<div className="mb-4 flex items-center">
|
||||
<span className="text-4xl font-semibold">DocsGPT</span>
|
||||
<img className="mb-1 inline w-14" src={DocsGPT3} alt="docsgpt" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo Buttons Section */}
|
||||
<div className="w-full max-w-full mb-8 md:mb-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4 text-xs">
|
||||
<div className="mb-8 w-full max-w-full md:mb-16">
|
||||
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-1 md:gap-4 lg:grid-cols-2">
|
||||
{demos?.map(
|
||||
(demo: { header: string; query: string }, key: number) =>
|
||||
demo.header &&
|
||||
@@ -38,14 +38,12 @@ export default function Hero({
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleQuestion({ question: demo.query })}
|
||||
className="w-full rounded-[66px] border bg-transparent px-6 py-[14px] text-left transition-colors
|
||||
border-dark-gray text-just-black hover:bg-cultured
|
||||
dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green"
|
||||
className={`w-full rounded-[66px] border border-dark-gray bg-transparent px-6 py-[14px] text-left text-just-black transition-colors hover:bg-cultured dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green ${key >= 2 ? 'hidden md:block' : ''} // Show only 2 buttons on mobile`}
|
||||
>
|
||||
<p className="mb-2 font-semibold text-black-1000 dark:text-bright-gray">
|
||||
{demo.header}
|
||||
</p>
|
||||
<span className="text-gray-700 dark:text-gray-300 opacity-60 line-clamp-2">
|
||||
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">
|
||||
{demo.query}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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,13 +13,15 @@ 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 Pin from './assets/pin.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 UnPin from './assets/unpin.svg';
|
||||
import Help from './components/Help';
|
||||
import SourceDropdown from './components/SourceDropdown';
|
||||
import {
|
||||
handleAbort,
|
||||
selectQueries,
|
||||
@@ -31,22 +34,19 @@ import useDefaultDocument from './hooks/useDefaultDocument';
|
||||
import useTokenAuth from './hooks/useTokenAuth';
|
||||
import DeleteConvModal from './modals/DeleteConvModal';
|
||||
import JWTModal from './modals/JWTModal';
|
||||
import { ActiveState, Doc } from './models/misc';
|
||||
import { getConversations, getDocs } from './preferences/preferenceApi';
|
||||
import { ActiveState } from './models/misc';
|
||||
import { getConversations } from './preferences/preferenceApi';
|
||||
import {
|
||||
selectApiKeyStatus,
|
||||
selectAgents,
|
||||
selectConversationId,
|
||||
selectConversations,
|
||||
selectModalStateDeleteConv,
|
||||
selectPaginatedDocuments,
|
||||
selectSelectedDocs,
|
||||
selectSourceDocs,
|
||||
selectSelectedAgent,
|
||||
selectToken,
|
||||
setAgents,
|
||||
setConversations,
|
||||
setModalStateDeleteConv,
|
||||
setPaginatedDocuments,
|
||||
setSelectedDocs,
|
||||
setSourceDocs,
|
||||
setSelectedAgent,
|
||||
} from './preferences/preferenceSlice';
|
||||
import Upload from './upload/Upload';
|
||||
|
||||
@@ -57,39 +57,63 @@ 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 docs = useSelector(selectSourceDocs);
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
const conversations = useSelector(selectConversations);
|
||||
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
|
||||
const conversationId = useSelector(selectConversationId);
|
||||
const paginatedDocuments = useSelector(selectPaginatedDocuments);
|
||||
const [isDeletingConversation, setIsDeletingConversation] = useState(false);
|
||||
const modalStateDeleteConv = useSelector(selectModalStateDeleteConv);
|
||||
const agents = useSelector(selectAgents);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const { isMobile } = useMediaQuery();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const [isDocsListOpen, setIsDocsListOpen] = useState(false);
|
||||
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();
|
||||
async function fetchRecentAgents() {
|
||||
try {
|
||||
const response = await userService.getPinnedAgents(token);
|
||||
if (!response.ok) throw new Error('Failed to fetch pinned agents');
|
||||
const pinnedAgents: Agent[] = await response.json();
|
||||
if (pinnedAgents.length >= 3) {
|
||||
setRecentAgents(pinnedAgents);
|
||||
return;
|
||||
}
|
||||
let tempAgents: Agent[] = [];
|
||||
if (!agents) {
|
||||
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));
|
||||
tempAgents = data;
|
||||
} else tempAgents = agents;
|
||||
const additionalAgents = tempAgents
|
||||
.filter(
|
||||
(agent: Agent) =>
|
||||
agent.status === 'published' &&
|
||||
!pinnedAgents.some((pinned) => pinned.id === agent.id),
|
||||
)
|
||||
.sort(
|
||||
(a: Agent, b: Agent) =>
|
||||
new Date(b.last_used_at ?? 0).getTime() -
|
||||
new Date(a.last_used_at ?? 0).getTime(),
|
||||
)
|
||||
.slice(0, 3 - pinnedAgents.length);
|
||||
setRecentAgents([...pinnedAgents, ...additionalAgents]);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent agents: ', error);
|
||||
}
|
||||
if (queries.length === 0) {
|
||||
resetConversation();
|
||||
}
|
||||
}, [conversations?.data, dispatch]);
|
||||
}
|
||||
|
||||
async function fetchConversations() {
|
||||
dispatch(setConversations({ ...conversations, loading: true }));
|
||||
@@ -103,6 +127,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecentAgents();
|
||||
}, [agents, token, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversations?.data) fetchConversations();
|
||||
if (queries.length === 0) resetConversation();
|
||||
}, [conversations?.data, dispatch]);
|
||||
|
||||
const handleDeleteAllConversations = () => {
|
||||
setIsDeletingConversation(true);
|
||||
conversationService
|
||||
@@ -124,44 +157,61 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
.catch((error) => console.error(error));
|
||||
};
|
||||
|
||||
const handleDeleteClick = (doc: Doc) => {
|
||||
userService
|
||||
.deletePath(doc.id ?? '', token)
|
||||
.then(() => {
|
||||
return getDocs(token);
|
||||
})
|
||||
.then((updatedDocs) => {
|
||||
dispatch(setSourceDocs(updatedDocs));
|
||||
const updatedPaginatedDocs = paginatedDocuments?.filter(
|
||||
(document) => document.id !== doc.id,
|
||||
const handleAgentClick = (agent: Agent) => {
|
||||
resetConversation();
|
||||
dispatch(setSelectedAgent(agent));
|
||||
if (isMobile) setNavOpen(!navOpen);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleTogglePin = (agent: Agent) => {
|
||||
userService.togglePinAgent(agent.id ?? '', token).then((response) => {
|
||||
if (response.ok) {
|
||||
const updatedAgents = agents?.map((a) =>
|
||||
a.id === agent.id ? { ...a, pinned: !a.pinned } : a,
|
||||
);
|
||||
dispatch(
|
||||
setPaginatedDocuments(updatedPaginatedDocs || paginatedDocuments),
|
||||
);
|
||||
dispatch(
|
||||
setSelectedDocs(
|
||||
Array.isArray(updatedDocs) &&
|
||||
updatedDocs?.find(
|
||||
(doc: Doc) => doc.name.toLowerCase() === 'default',
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
dispatch(setAgents(updatedAgents));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleConversationClick = (index: string) => {
|
||||
dispatch(setSelectedAgent(null));
|
||||
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) {
|
||||
if (data.is_shared_usage) {
|
||||
userService
|
||||
.getSharedAgent(data.shared_token, token)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
response.json().then((agent: Agent) => {
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
userService.getAgent(data.agent_id, token).then((response) => {
|
||||
if (response.ok) {
|
||||
response.json().then((agent: Agent) => {
|
||||
navigate('/');
|
||||
dispatch(setSelectedAgent(agent));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
navigate('/');
|
||||
dispatch(setSelectedAgent(null));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -173,12 +223,15 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
query: { conversationId: null },
|
||||
}),
|
||||
);
|
||||
dispatch(setSelectedAgent(null));
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
if (queries && queries?.length > 0) {
|
||||
resetConversation();
|
||||
}
|
||||
};
|
||||
|
||||
async function updateConversationName(updatedConversation: {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -197,10 +250,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
Needed to fix bug where if mobile nav was closed and then window was resized to desktop, nav would still be closed but the button to open would be gone, as per #1 on issue #146
|
||||
*/
|
||||
|
||||
useEffect(() => {
|
||||
setNavOpen(!isMobile);
|
||||
}, [isMobile]);
|
||||
@@ -209,8 +258,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);
|
||||
@@ -237,7 +286,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>
|
||||
@@ -247,13 +296,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);
|
||||
@@ -291,7 +340,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
|
||||
@@ -299,7 +348,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
alt="Create new chat"
|
||||
className="opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
<p className=" text-sm text-dove-gray group-hover:text-neutral-600 dark:text-chinese-silver dark:group-hover:text-bright-gray">
|
||||
<p className="text-sm text-dove-gray group-hover:text-neutral-600 dark:text-chinese-silver dark:group-hover:text-bright-gray">
|
||||
{t('newChat')}
|
||||
</p>
|
||||
</NavLink>
|
||||
@@ -308,7 +357,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"
|
||||
@@ -316,10 +365,95 @@ 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={`group mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between 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 items-center gap-2">
|
||||
<div className="flex w-6 justify-center">
|
||||
<img
|
||||
src={agent.image ?? Robot}
|
||||
alt="agent-logo"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</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
|
||||
className={`${isMobile ? 'flex' : 'invisible flex group-hover:visible'} items-center px-3`}
|
||||
>
|
||||
<button
|
||||
className="rounded-full hover:opacity-75"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTogglePin(agent);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={agent.pinned ? UnPin : Pin}
|
||||
className="h-4 w-4"
|
||||
></img>
|
||||
</button>
|
||||
</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={() => {
|
||||
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) => (
|
||||
@@ -345,37 +479,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-auto flex-col justify-end text-eerie-black dark:text-white">
|
||||
<div className="flex flex-col-reverse border-b-[1px] dark:border-b-purple-taupe">
|
||||
<div className="relative my-4 mx-4 flex gap-4 items-center">
|
||||
<SourceDropdown
|
||||
options={docs}
|
||||
selectedDocs={selectedDocs}
|
||||
setSelectedDocs={setSelectedDocs}
|
||||
isDocsListOpen={isDocsListOpen}
|
||||
setIsDocsListOpen={setIsDocsListOpen}
|
||||
handleDeleteClick={handleDeleteClick}
|
||||
handlePostDocumentSelect={(option?: string) => {
|
||||
if (isMobile) {
|
||||
setNavOpen(!navOpen);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
className="hover:cursor-pointer"
|
||||
src={UploadIcon}
|
||||
width={28}
|
||||
height={25}
|
||||
alt="Upload document"
|
||||
onClick={() => {
|
||||
setUploadModalState('ACTIVE');
|
||||
if (isMobile) {
|
||||
setNavOpen(!navOpen);
|
||||
}
|
||||
}}
|
||||
></img>
|
||||
</div>
|
||||
<p className="ml-5 mt-3 text-sm font-semibold">{t('sourceDocs')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-b-[1px] py-2 dark:border-b-purple-taupe">
|
||||
<NavLink
|
||||
onClick={() => {
|
||||
@@ -386,7 +489,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' : ''
|
||||
}`
|
||||
}
|
||||
@@ -394,15 +497,15 @@ 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">
|
||||
<p className="my-auto text-sm text-eerie-black dark:text-white">
|
||||
{t('settings.label')}
|
||||
</p>
|
||||
</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">
|
||||
@@ -451,9 +554,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
|
||||
@@ -462,7 +565,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
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function PageNotFound() {
|
||||
<p className="mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 text-jet dark:bg-outer-space dark:text-gray-100 lg:p-10 xl:p-16">
|
||||
<h1>404</h1>
|
||||
<p>The page you are looking for does not exist.</p>
|
||||
<button className="pointer-cursor mr-4 flex cursor-pointer items-center justify-center rounded-full bg-blue-1000 py-2 px-4 text-white transition-colors duration-100 hover:bg-blue-3000">
|
||||
<button className="pointer-cursor mr-4 flex cursor-pointer items-center justify-center rounded-full bg-blue-1000 px-4 py-2 text-white transition-colors duration-100 hover:bg-blue-3000">
|
||||
<Link to="/">Go Back Home</Link>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
123
frontend/src/agents/AgentCard.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
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,
|
||||
setAgents,
|
||||
setSelectedAgent,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { Agent } from './types';
|
||||
|
||||
type AgentCardProps = {
|
||||
agent: Agent;
|
||||
agents: Agent[];
|
||||
menuOptions?: MenuOption[];
|
||||
onDelete?: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export default function AgentCard({
|
||||
agent,
|
||||
agents,
|
||||
menuOptions,
|
||||
onDelete,
|
||||
}: AgentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (agent.status === 'published') {
|
||||
dispatch(setSelectedAgent(agent));
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
const defaultDelete = 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();
|
||||
dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] hover:dark:bg-[#383838]/80 ${
|
||||
agent.status === 'published' ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 cursor-pointer"
|
||||
>
|
||||
<img src={ThreeDots} alt="options" className="h-[19px] w-[19px]" />
|
||||
{menuOptions && (
|
||||
<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={() => {
|
||||
onDelete ? onDelete(agent.id || '') : defaultDelete(agent.id || '');
|
||||
setDeleteConfirmation('INACTIVE');
|
||||
}}
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/agents/AgentLogs.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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 Spinner from '../components/Spinner';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import Analytics from '../settings/Analytics';
|
||||
import Logs from '../settings/Logs';
|
||||
import { Agent } from './types';
|
||||
|
||||
export default function AgentLogs() {
|
||||
const navigate = useNavigate();
|
||||
const { agentId } = useParams();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
const [agent, setAgent] = useState<Agent>();
|
||||
const [loadingAgent, setLoadingAgent] = useState<boolean>(true);
|
||||
|
||||
const fetchAgent = async (agentId: string) => {
|
||||
setLoadingAgent(true);
|
||||
try {
|
||||
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 {
|
||||
setLoadingAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (agentId) fetchAgent(agentId);
|
||||
}, [agentId, token]);
|
||||
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>
|
||||
<div className="mt-6 flex flex-col gap-3 px-4">
|
||||
{agent && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[#28292E] dark:text-[#E0E0E0]">{agent.name}</p>
|
||||
<p className="text-xs text-[#28292E] dark:text-[#E0E0E0]/40">
|
||||
{agent.last_used_at
|
||||
? 'Last used at ' +
|
||||
new Date(agent.last_used_at).toLocaleString()
|
||||
: 'No usage history'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loadingAgent ? (
|
||||
<div className="flex h-[345px] w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
agent && <Analytics agentId={agent.id} />
|
||||
)}
|
||||
{loadingAgent ? (
|
||||
<div className="flex h-[55vh] w-full items-center justify-center">
|
||||
{' '}
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
agent && <Logs agentId={agent.id} tableHeader="Agent endpoint logs" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
frontend/src/agents/AgentPreview.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
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}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
611
frontend/src/agents/NewAgent.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
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.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' || effectiveMode === 'draft') {
|
||||
setEffectiveMode('edit');
|
||||
setAgent((prev) => ({ ...prev, status: 'published' }));
|
||||
setAgentDetails('ACTIVE');
|
||||
}
|
||||
};
|
||||
|
||||
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="group flex items-center gap-2 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}`)}
|
||||
>
|
||||
<span className="block h-5 w-5 bg-[url('/src/assets/monitoring-purple.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/monitoring-white.svg')]" />
|
||||
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: <img src={SourceIcon} alt="" />,
|
||||
})) || []
|
||||
}
|
||||
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?.status === 'published' ? (
|
||||
<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 previewed 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={() => undefined}
|
||||
editPromptContent={''}
|
||||
setEditPromptContent={() => undefined}
|
||||
currentPromptEdit={{ id: '', name: '', type: '' }}
|
||||
handleAddPrompt={handleAddPrompt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
263
frontend/src/agents/SharedAgent.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import Robot from '../assets/robot.svg';
|
||||
import MessageInput from '../components/MessageInput';
|
||||
import Spinner from '../components/Spinner';
|
||||
import ConversationMessages from '../conversation/ConversationMessages';
|
||||
import { Query } from '../conversation/conversationModels';
|
||||
import {
|
||||
addQuery,
|
||||
fetchAnswer,
|
||||
resendQuery,
|
||||
selectQueries,
|
||||
selectStatus,
|
||||
} from '../conversation/conversationSlice';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import { selectToken, setSelectedAgent } from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
import { Agent } from './types';
|
||||
|
||||
export default function SharedAgent() {
|
||||
const { t } = useTranslation();
|
||||
const { agentId } = useParams();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
|
||||
const token = useSelector(selectToken);
|
||||
const queries = useSelector(selectQueries);
|
||||
const status = useSelector(selectStatus);
|
||||
|
||||
const [sharedAgent, setSharedAgent] = useState<Agent>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [input, setInput] = useState('');
|
||||
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
|
||||
|
||||
const fetchStream = useRef<any>(null);
|
||||
|
||||
const getSharedAgent = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await userService.getSharedAgent(agentId ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to fetch Shared Agent');
|
||||
const agent: Agent = await response.json();
|
||||
setSharedAgent(agent);
|
||||
} catch (error) {
|
||||
console.error('Error: ', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchAnswer = useCallback(
|
||||
({ question, index }: { question: string; index?: number }) => {
|
||||
fetchStream.current = dispatch(
|
||||
fetchAnswer({ question, indx: index, isPreview: false }),
|
||||
);
|
||||
},
|
||||
[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('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (agentId) getSharedAgent();
|
||||
}, [agentId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedAgent) dispatch(setSelectedAgent(sharedAgent));
|
||||
}, [sharedAgent, dispatch]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
if (!sharedAgent)
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
<img
|
||||
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
|
||||
alt="No agent found"
|
||||
className="mx-auto mb-6 h-32 w-32"
|
||||
/>
|
||||
<p className="text-center text-lg text-[#71717A] dark:text-[#949494]">
|
||||
No agent found. Please ensure the agent is shared.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute left-4 top-5 hidden items-center gap-3 sm:flex">
|
||||
<img
|
||||
src={sharedAgent.image ?? Robot}
|
||||
alt="agent-logo"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-[#212121] dark:text-[#E0E0E0]">
|
||||
{sharedAgent.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col items-center justify-between sm:pt-12">
|
||||
<div className="flex w-full flex-col items-center overflow-y-auto">
|
||||
<ConversationMessages
|
||||
handleQuestion={handleQuestion}
|
||||
handleQuestionSubmission={handleQuestionSubmission}
|
||||
queries={queries}
|
||||
status={status}
|
||||
showHeroOnEmpty={false}
|
||||
headerContent={
|
||||
<div className="flex w-full items-center justify-center py-4">
|
||||
<SharedAgentCard agent={sharedAgent} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</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={sharedAgent ? false : true}
|
||||
showToolButton={sharedAgent ? false : true}
|
||||
autoFocus={false}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SharedAgentCard({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<div className="flex w-full max-w-[720px] flex-col rounded-3xl border border-dark-gray p-6 shadow-sm dark:border-grey sm:w-fit sm:min-w-[480px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
|
||||
<img src={Robot} className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="flex max-h-[92px] w-[80%] flex-col gap-px">
|
||||
<h2 className="text-base font-semibold text-[#212121] dark:text-[#E0E0E0] sm:text-lg">
|
||||
{agent.name}
|
||||
</h2>
|
||||
<p className="overflow-y-auto text-wrap break-all text-xs text-[#71717A] dark:text-[#949494] sm:text-sm">
|
||||
{agent.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-8">
|
||||
{agent.shared_metadata?.shared_by && (
|
||||
<p className="text-xs font-light text-[#212121] dark:text-[#E0E0E0] sm:text-sm">
|
||||
by {agent.shared_metadata.shared_by}
|
||||
</p>
|
||||
)}
|
||||
{agent.shared_metadata?.shared_at && (
|
||||
<p className="text-xs font-light text-[#71717A] dark:text-[#949494] sm:text-sm">
|
||||
Shared on{' '}
|
||||
{new Date(agent.shared_metadata.shared_at).toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{agent.tools.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<p className="text-sm font-semibold text-[#212121] dark:text-[#E0E0E0] sm:text-base">
|
||||
Connected Tools
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{agent.tools.map((tool, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center gap-1 rounded-full bg-bright-gray px-3 py-1 text-xs font-light text-[#212121] dark:bg-dark-charcoal dark:text-[#E0E0E0]"
|
||||
>
|
||||
<img
|
||||
src={`/toolIcons/tool_${tool}.svg`}
|
||||
alt={`${tool} icon`}
|
||||
className="h-3 w-3"
|
||||
/>{' '}
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
418
frontend/src/agents/index.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { SyntheticEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import Edit from '../assets/edit.svg';
|
||||
import Monitoring from '../assets/monitoring.svg';
|
||||
import Pin from '../assets/pin.svg';
|
||||
import Trash from '../assets/red-trash.svg';
|
||||
import Robot from '../assets/robot.svg';
|
||||
import Link from '../assets/link-gray.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import UnPin from '../assets/unpin.svg';
|
||||
import ContextMenu, { MenuOption } from '../components/ContextMenu';
|
||||
import Spinner from '../components/Spinner';
|
||||
import {
|
||||
setConversation,
|
||||
updateConversationId,
|
||||
} from '../conversation/conversationSlice';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import {
|
||||
selectAgents,
|
||||
selectSelectedAgent,
|
||||
selectToken,
|
||||
setAgents,
|
||||
setSelectedAgent,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import AgentLogs from './AgentLogs';
|
||||
import NewAgent from './NewAgent';
|
||||
import SharedAgent from './SharedAgent';
|
||||
import { Agent } from './types';
|
||||
|
||||
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 />} />
|
||||
<Route path="/shared/:agentId" element={<SharedAgent />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
const sectionConfig = {
|
||||
user: {
|
||||
title: 'By me',
|
||||
description: 'Agents created or published by you',
|
||||
showNewAgentButton: true,
|
||||
emptyStateDescription: 'You don’t have any created agents yet',
|
||||
},
|
||||
shared: {
|
||||
title: 'Shared with me',
|
||||
description: 'Agents imported by using a public link',
|
||||
showNewAgentButton: false,
|
||||
emptyStateDescription: 'No shared agents found',
|
||||
},
|
||||
};
|
||||
|
||||
function AgentsList() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const token = useSelector(selectToken);
|
||||
const agents = useSelector(selectAgents);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const [sharedAgents, setSharedAgents] = useState<Agent[]>([]);
|
||||
const [loadingUserAgents, setLoadingUserAgents] = useState<boolean>(true);
|
||||
const [loadingSharedAgents, setLoadingSharedAgents] = useState<boolean>(true);
|
||||
|
||||
const getAgents = async () => {
|
||||
try {
|
||||
setLoadingUserAgents(true);
|
||||
const response = await userService.getAgents(token);
|
||||
if (!response.ok) throw new Error('Failed to fetch agents');
|
||||
const data = await response.json();
|
||||
dispatch(setAgents(data));
|
||||
setLoadingUserAgents(false);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setLoadingUserAgents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSharedAgents = async () => {
|
||||
try {
|
||||
setLoadingSharedAgents(true);
|
||||
const response = await userService.getSharedAgents(token);
|
||||
if (!response.ok) throw new Error('Failed to fetch shared agents');
|
||||
const data = await response.json();
|
||||
setSharedAgents(data);
|
||||
setLoadingSharedAgents(false);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setLoadingSharedAgents(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getAgents();
|
||||
getSharedAgents();
|
||||
dispatch(setConversation([]));
|
||||
dispatch(
|
||||
updateConversationId({
|
||||
query: { conversationId: null },
|
||||
}),
|
||||
);
|
||||
if (selectedAgent) dispatch(setSelectedAgent(null));
|
||||
}, [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>
|
||||
{/* Premade agents section */}
|
||||
{/* <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> */}
|
||||
<AgentSection
|
||||
agents={agents ?? []}
|
||||
loading={loadingUserAgents}
|
||||
section="user"
|
||||
/>
|
||||
<AgentSection
|
||||
agents={sharedAgents ?? []}
|
||||
loading={loadingSharedAgents}
|
||||
section="shared"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({
|
||||
agents,
|
||||
loading,
|
||||
section,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
loading: boolean;
|
||||
section: keyof typeof sectionConfig;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
|
||||
{sectionConfig[section].title}
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#71717A]">
|
||||
{sectionConfig[section].description}
|
||||
</p>
|
||||
</div>
|
||||
{sectionConfig[section].showNewAgentButton && (
|
||||
<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="grid w-full grid-cols-2 gap-2 md:flex md:flex-wrap md:gap-4">
|
||||
{loading ? (
|
||||
<div className="flex h-72 w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : agents && agents.length > 0 ? (
|
||||
agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
agents={agents}
|
||||
section={section}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
|
||||
<p>{sectionConfig[section].emptyStateDescription}</p>
|
||||
{sectionConfig[section].showNewAgentButton && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
agent,
|
||||
agents,
|
||||
section,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agents: Agent[];
|
||||
section: keyof typeof sectionConfig;
|
||||
}) {
|
||||
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 togglePin = async () => {
|
||||
try {
|
||||
const response = await userService.togglePinAgent(agent.id ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to pin agent');
|
||||
const updatedAgents = agents.map((prevAgent) => {
|
||||
if (prevAgent.id === agent.id)
|
||||
return { ...prevAgent, pinned: !prevAgent.pinned };
|
||||
return prevAgent;
|
||||
});
|
||||
dispatch(setAgents(updatedAgents));
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const menuOptionsConfig: Record<string, MenuOption[]> = {
|
||||
user: [
|
||||
{
|
||||
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: agent.pinned ? UnPin : Pin,
|
||||
label: agent.pinned ? 'Unpin' : 'Pin agent',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin();
|
||||
},
|
||||
variant: 'primary',
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
},
|
||||
{
|
||||
icon: Trash,
|
||||
label: 'Delete',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirmation('ACTIVE');
|
||||
},
|
||||
variant: 'danger',
|
||||
iconWidth: 13,
|
||||
iconHeight: 13,
|
||||
},
|
||||
],
|
||||
shared: [
|
||||
{
|
||||
icon: Link,
|
||||
label: 'Open',
|
||||
onClick: (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
},
|
||||
variant: 'primary',
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menuOptions = menuOptionsConfig[section] || [];
|
||||
|
||||
const handleClick = () => {
|
||||
if (section === 'user') {
|
||||
if (agent.status === 'published') {
|
||||
dispatch(setSelectedAgent(agent));
|
||||
navigate(`/`);
|
||||
}
|
||||
}
|
||||
if (section === 'shared') {
|
||||
navigate(`/agents/shared/${agent.shared_token}`);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id)));
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] hover:dark:bg-[#383838]/80 md:w-48 ${agent.status === 'published' && 'cursor-pointer'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={menuRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(true);
|
||||
}}
|
||||
className="absolute right-4 top-4 z-10 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>
|
||||
);
|
||||
}
|
||||
22
frontend/src/agents/types/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
incoming_webhook_token?: string;
|
||||
pinned?: boolean;
|
||||
shared?: boolean;
|
||||
shared_token?: string;
|
||||
shared_metadata?: any;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
last_used_at?: string;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
const baseURL = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
|
||||
export const baseURL =
|
||||
import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -8,16 +8,22 @@ 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}`,
|
||||
PINNED_AGENTS: '/api/pinned_agents',
|
||||
TOGGLE_PIN_AGENT: (id: string) => `/api/pin_agent?id=${id}`,
|
||||
SHARED_AGENT: (id: string) => `/api/shared_agent?token=${id}`,
|
||||
SHARED_AGENTS: '/api/shared_agents',
|
||||
SHARE_AGENT: `/api/share_agent`,
|
||||
AGENT_WEBHOOK: (id: string) => `/api/agent_webhook?id=${id}`,
|
||||
PROMPTS: '/api/get_prompts',
|
||||
CREATE_PROMPT: '/api/create_prompt',
|
||||
DELETE_PROMPT: '/api/delete_prompt',
|
||||
UPDATE_PROMPT: '/api/update_prompt',
|
||||
SINGLE_PROMPT: (id: string) => `/api/get_single_prompt?id=${id}`,
|
||||
PROXIES: '/api/get_proxies',
|
||||
CREATE_PROXY: '/api/create_proxy',
|
||||
DELETE_PROXY: '/api/delete_proxy',
|
||||
UPDATE_PROXY: '/api/update_proxy',
|
||||
SINGLE_PROXY: (id: string) => `/api/get_single_proxy?id=${id}`,
|
||||
DELETE_PATH: (docPath: string) => `/api/delete_old?source_id=${docPath}`,
|
||||
TASK_STATUS: (task_id: string) => `/api/task_status?task_id=${task_id}`,
|
||||
MESSAGE_ANALYTICS: '/api/get_message_analytics',
|
||||
@@ -37,6 +43,7 @@ const endpoints = {
|
||||
DELETE_CHUNK: (docId: string, chunkId: string) =>
|
||||
`/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`,
|
||||
UPDATE_CHUNK: '/api/update_chunk',
|
||||
STORE_ATTACHMENT: '/api/store_attachment',
|
||||
},
|
||||
CONVERSATION: {
|
||||
ANSWER: '/api/answer',
|
||||
|
||||
@@ -17,6 +17,32 @@ 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),
|
||||
getPinnedAgents: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.PINNED_AGENTS, token),
|
||||
togglePinAgent: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token),
|
||||
getSharedAgent: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.SHARED_AGENT(id), token),
|
||||
getSharedAgents: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.SHARED_AGENTS, token),
|
||||
shareAgent: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.put(endpoints.USER.SHARE_AGENT, data, token),
|
||||
getAgentWebhook: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token),
|
||||
getPrompts: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.PROMPTS, token),
|
||||
createPrompt: (data: any, token: string | null): Promise<any> =>
|
||||
@@ -27,16 +53,6 @@ const userService = {
|
||||
apiClient.post(endpoints.USER.UPDATE_PROMPT, data, token),
|
||||
getSinglePrompt: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.SINGLE_PROMPT(id), token),
|
||||
getProxies: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.PROXIES, token),
|
||||
createProxy: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.CREATE_PROXY, data, token),
|
||||
deleteProxy: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.DELETE_PROXY, data, token),
|
||||
updateProxy: (data: any, token: string | null): Promise<any> =>
|
||||
apiClient.post(endpoints.USER.UPDATE_PROXY, data, token),
|
||||
getSingleProxy: (id: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.SINGLE_PROXY(id), token),
|
||||
deletePath: (docPath: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.DELETE_PATH(docPath), token),
|
||||
getTaskStatus: (task_id: string, token: string | null): Promise<any> =>
|
||||
|
||||
3
frontend/src/assets/clip.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.08485 4.07133L3.08485 9.20109C3.08485 9.49263 3.20066 9.77222 3.40681 9.97837C3.61295 10.1845 3.89255 10.3003 4.18408 10.3003C4.47562 10.3003 4.75521 10.1845 4.96136 9.97837C5.1675 9.77222 5.28332 9.49263 5.28332 9.20109L5.28332 3.70492C5.28332 3.12185 5.05169 2.56266 4.6394 2.15037C4.22711 1.73808 3.66792 1.50645 3.08485 1.50645C2.50178 1.50645 1.94259 1.73808 1.5303 2.15037C1.118 2.56266 0.88638 3.12185 0.88638 3.70492L0.886379 9.20109C0.886379 10.0757 1.23381 10.9145 1.85225 11.5329C2.47069 12.1514 3.30948 12.4988 4.18408 12.4988C5.05869 12.4988 5.89747 12.1514 6.51591 11.5329C7.13435 10.9145 7.48178 10.0757 7.48178 9.20109L7.48178 4.07133" stroke="#5D5D5D" stroke-width="1.03637" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 854 B |
11
frontend/src/assets/cloud.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 16C2.53043 16 3.03914 16.2107 3.41421 16.5858C3.78929 16.9609 4 17.4696 4 18C4 18.5304 3.78929 19.0391 3.41421 19.4142C3.03914 19.7893 2.53043 20 2 20C1.46957 20 0.960859 19.7893 0.585786 19.4142C0.210714 19.0391 0 18.5304 0 18C0 17.4696 0.210714 16.9609 0.585786 16.5858C0.960859 16.2107 1.46957 16 2 16ZM7.5 13C8.16304 13 8.79893 13.2634 9.26777 13.7322C9.73661 14.2011 10 14.837 10 15.5C10 16.163 9.73661 16.7989 9.26777 17.2678C8.79893 17.7366 8.16304 18 7.5 18C6.83696 18 6.20107 17.7366 5.73223 17.2678C5.26339 16.7989 5 16.163 5 15.5C5 14.837 5.26339 14.2011 5.73223 13.7322C6.20107 13.2634 6.83696 13 7.5 13ZM10 0C11.272 0.000250351 12.5033 0.448355 13.4779 1.26572C14.4525 2.08308 15.1082 3.2175 15.33 4.47H15.412C16.4105 4.47 17.3682 4.86667 18.0743 5.57274C18.7803 6.27882 19.177 7.23646 19.177 8.235C19.177 9.23354 18.7803 10.1912 18.0743 10.8973C17.3682 11.6033 16.4105 12 15.412 12H4.588C3.58946 12 2.63182 11.6033 1.92574 10.8973C1.21967 10.1912 0.823 9.23354 0.823 8.235C0.823 7.23646 1.21967 6.27882 1.92574 5.57274C2.63182 4.86667 3.58946 4.47 4.588 4.47H4.67C4.89179 3.2175 5.54749 2.08308 6.52211 1.26572C7.49673 0.448355 8.72801 0.000250351 10 0Z" fill="url(#paint0_linear_6161_11984)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_6161_11984" x1="0" y1="10" x2="19.177" y2="10" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#70FDF7"/>
|
||||
<stop offset="0.325" stop-color="#747696"/>
|
||||
<stop offset="0.68" stop-color="#BD5372"/>
|
||||
<stop offset="1" stop-color="#F5A06C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
4
frontend/src/assets/copy-linear.svg
Normal 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 |
3
frontend/src/assets/link-gray.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7 1.5H3C2.46957 1.5 1.96086 1.71071 1.58579 2.08579C1.21071 2.46086 1 2.96957 1 3.5V15.5C1 16.0304 1.21071 16.5391 1.58579 16.9142C1.96086 17.2893 2.46957 17.5 3 17.5H15C15.5304 17.5 16.0391 17.2893 16.4142 16.9142C16.7893 16.5391 17 16.0304 17 15.5V11.5M9 9.5L17 1.5M17 1.5V6.5M17 1.5H12" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 486 B |
3
frontend/src/assets/monitoring-purple.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.50195 17.1494C3.68072 17.1503 3.83797 17.2119 3.96289 17.3359C4.08842 17.4607 4.15029 17.6188 4.15039 17.7988V18.5869C4.15039 18.7666 4.08775 18.9248 3.96191 19.0498C3.83677 19.1741 3.68038 19.2364 3.50098 19.2373H3.5C3.36498 19.2373 3.24235 19.2024 3.13672 19.1318L3.03613 19.0488C2.91173 18.9234 2.84961 18.7657 2.84961 18.5869V17.7988C2.84971 17.6193 2.91265 17.4614 3.03809 17.3359C3.16377 17.2104 3.32208 17.1486 3.50195 17.1494ZM7.50195 12.6494C7.68072 12.6503 7.83797 12.7119 7.96289 12.8359C8.08842 12.9607 8.15029 13.1188 8.15039 13.2988V18.5869C8.15039 18.7666 8.08775 18.9248 7.96191 19.0498C7.83656 19.1743 7.67907 19.2364 7.5 19.2373H7.49902C7.31988 19.2373 7.16245 19.175 7.03711 19.0498C6.91161 18.9243 6.84884 18.7663 6.84961 18.5869V13.2988C6.84971 13.1193 6.91265 12.9614 7.03809 12.8359C7.16377 12.7104 7.32208 12.6486 7.50195 12.6494ZM11.502 14.6494C11.6807 14.6503 11.838 14.7119 11.9629 14.8359C12.0884 14.9607 12.1503 15.1188 12.1504 15.2988V18.5869C12.1504 18.7666 12.0878 18.9248 11.9619 19.0498C11.8366 19.1743 11.6791 19.2364 11.5 19.2373H11.499C11.3193 19.2373 11.1611 19.1747 11.0361 19.0488C10.9117 18.9234 10.8496 18.7657 10.8496 18.5869V15.2988C10.8497 15.1193 10.9127 14.9614 11.0381 14.8359C11.1638 14.7104 11.3221 14.6486 11.502 14.6494ZM15.502 13.1494C15.6807 13.1503 15.838 13.2119 15.9629 13.3359C16.0884 13.4607 16.1503 13.6188 16.1504 13.7988V18.5869C16.1504 18.7666 16.0878 18.9248 15.9619 19.0498C15.8366 19.1743 15.6791 19.2364 15.5 19.2373H15.499C15.3199 19.2373 15.1625 19.175 15.0371 19.0498C14.9116 18.9243 14.8488 18.7663 14.8496 18.5869V13.7988C14.8497 13.6193 14.9127 13.4614 15.0381 13.3359C15.1638 13.2104 15.3221 13.1486 15.502 13.1494ZM19.502 9.14941C19.6807 9.15031 19.838 9.2119 19.9629 9.33594C20.0884 9.46066 20.1503 9.61875 20.1504 9.79883V18.5869C20.1504 18.7666 20.0878 18.9248 19.9619 19.0498C19.8366 19.1743 19.6791 19.2364 19.5 19.2373H19.499C19.3199 19.2373 19.1625 19.175 19.0371 19.0498C18.9116 18.9243 18.8488 18.7663 18.8496 18.5869V9.79883C18.8497 9.61927 18.9127 9.46137 19.0381 9.33594C19.1638 9.21036 19.3221 9.14857 19.502 9.14941ZM19.499 3.35156C19.6838 3.34567 19.8427 3.41642 19.9678 3.55469H19.9688C20.0596 3.64961 20.1156 3.761 20.1357 3.88477L20.1436 4.0127C20.1385 4.18363 20.079 4.33405 19.9609 4.45312H19.96L14.7422 9.6709C14.5779 9.83222 14.3846 9.95688 14.1641 10.0439C13.9451 10.1304 13.7235 10.1738 13.5 10.1738C13.3327 10.1738 13.1678 10.1497 13.0059 10.1006L12.8457 10.043C12.6325 9.95623 12.4382 9.83371 12.2637 9.67578L12.2578 9.6709L12.3643 9.56445L12.2578 9.66992L9.83594 7.24902C9.75169 7.16486 9.64381 7.12012 9.5 7.12012C9.39199 7.12012 9.30408 7.14514 9.23145 7.19336L9.16406 7.24902L3.95996 12.4531C3.83505 12.578 3.67971 12.6445 3.50195 12.6504H3.50098C3.31619 12.6556 3.1575 12.583 3.0332 12.4443V12.4453C2.91072 12.3194 2.85137 12.1633 2.85645 11.9873L2.87012 11.8623C2.89478 11.7425 2.95119 11.6357 3.04004 11.5469L8.25879 6.32812L8.39453 6.20703C8.53435 6.09371 8.68597 6.00535 8.84961 5.94434L9.00977 5.89258C9.17067 5.84784 9.33418 5.8252 9.5 5.8252C9.72114 5.8252 9.94159 5.86551 10.1602 5.94434C10.3843 6.02524 10.5789 6.15388 10.7422 6.3291L13.1641 8.75L13.2314 8.80566C13.3041 8.854 13.3919 8.87891 13.5 8.87891C13.6439 8.87891 13.7517 8.83427 13.8359 8.75L19.04 3.54688C19.165 3.42139 19.321 3.35565 19.499 3.35059V3.35156Z" fill="#7D54D1" stroke="#7D54D1" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
3
frontend/src/assets/monitoring-white.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.50195 17.1494C3.68072 17.1503 3.83797 17.2119 3.96289 17.3359C4.08842 17.4607 4.15029 17.6188 4.15039 17.7988V18.5869C4.15039 18.7666 4.08775 18.9248 3.96191 19.0498C3.83677 19.1741 3.68038 19.2364 3.50098 19.2373H3.5C3.36498 19.2373 3.24235 19.2024 3.13672 19.1318L3.03613 19.0488C2.91173 18.9234 2.84961 18.7657 2.84961 18.5869V17.7988C2.84971 17.6193 2.91265 17.4614 3.03809 17.3359C3.16377 17.2104 3.32208 17.1486 3.50195 17.1494ZM7.50195 12.6494C7.68072 12.6503 7.83797 12.7119 7.96289 12.8359C8.08842 12.9607 8.15029 13.1188 8.15039 13.2988V18.5869C8.15039 18.7666 8.08775 18.9248 7.96191 19.0498C7.83656 19.1743 7.67907 19.2364 7.5 19.2373H7.49902C7.31988 19.2373 7.16245 19.175 7.03711 19.0498C6.91161 18.9243 6.84884 18.7663 6.84961 18.5869V13.2988C6.84971 13.1193 6.91265 12.9614 7.03809 12.8359C7.16377 12.7104 7.32208 12.6486 7.50195 12.6494ZM11.502 14.6494C11.6807 14.6503 11.838 14.7119 11.9629 14.8359C12.0884 14.9607 12.1503 15.1188 12.1504 15.2988V18.5869C12.1504 18.7666 12.0878 18.9248 11.9619 19.0498C11.8366 19.1743 11.6791 19.2364 11.5 19.2373H11.499C11.3193 19.2373 11.1611 19.1747 11.0361 19.0488C10.9117 18.9234 10.8496 18.7657 10.8496 18.5869V15.2988C10.8497 15.1193 10.9127 14.9614 11.0381 14.8359C11.1638 14.7104 11.3221 14.6486 11.502 14.6494ZM15.502 13.1494C15.6807 13.1503 15.838 13.2119 15.9629 13.3359C16.0884 13.4607 16.1503 13.6188 16.1504 13.7988V18.5869C16.1504 18.7666 16.0878 18.9248 15.9619 19.0498C15.8366 19.1743 15.6791 19.2364 15.5 19.2373H15.499C15.3199 19.2373 15.1625 19.175 15.0371 19.0498C14.9116 18.9243 14.8488 18.7663 14.8496 18.5869V13.7988C14.8497 13.6193 14.9127 13.4614 15.0381 13.3359C15.1638 13.2104 15.3221 13.1486 15.502 13.1494ZM19.502 9.14941C19.6807 9.15031 19.838 9.2119 19.9629 9.33594C20.0884 9.46066 20.1503 9.61875 20.1504 9.79883V18.5869C20.1504 18.7666 20.0878 18.9248 19.9619 19.0498C19.8366 19.1743 19.6791 19.2364 19.5 19.2373H19.499C19.3199 19.2373 19.1625 19.175 19.0371 19.0498C18.9116 18.9243 18.8488 18.7663 18.8496 18.5869V9.79883C18.8497 9.61927 18.9127 9.46137 19.0381 9.33594C19.1638 9.21036 19.3221 9.14857 19.502 9.14941ZM19.499 3.35156C19.6838 3.34567 19.8427 3.41642 19.9678 3.55469H19.9688C20.0596 3.64961 20.1156 3.761 20.1357 3.88477L20.1436 4.0127C20.1385 4.18363 20.079 4.33405 19.9609 4.45312H19.96L14.7422 9.6709C14.5779 9.83222 14.3846 9.95688 14.1641 10.0439C13.9451 10.1304 13.7235 10.1738 13.5 10.1738C13.3327 10.1738 13.1678 10.1497 13.0059 10.1006L12.8457 10.043C12.6325 9.95623 12.4382 9.83371 12.2637 9.67578L12.2578 9.6709L12.3643 9.56445L12.2578 9.66992L9.83594 7.24902C9.75169 7.16486 9.64381 7.12012 9.5 7.12012C9.39199 7.12012 9.30408 7.14514 9.23145 7.19336L9.16406 7.24902L3.95996 12.4531C3.83505 12.578 3.67971 12.6445 3.50195 12.6504H3.50098C3.31619 12.6556 3.1575 12.583 3.0332 12.4443V12.4453C2.91072 12.3194 2.85137 12.1633 2.85645 11.9873L2.87012 11.8623C2.89478 11.7425 2.95119 11.6357 3.04004 11.5469L8.25879 6.32812L8.39453 6.20703C8.53435 6.09371 8.68597 6.00535 8.84961 5.94434L9.00977 5.89258C9.17067 5.84784 9.33418 5.8252 9.5 5.8252C9.72114 5.8252 9.94159 5.86551 10.1602 5.94434C10.3843 6.02524 10.5789 6.15388 10.7422 6.3291L13.1641 8.75L13.2314 8.80566C13.3041 8.854 13.3919 8.87891 13.5 8.87891C13.6439 8.87891 13.7517 8.83427 13.8359 8.75L19.04 3.54688C19.165 3.42139 19.321 3.35565 19.499 3.35059V3.35156Z" fill="#FFFFFF" stroke="#FFFFFF" stroke-width="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
3
frontend/src/assets/monitoring.svg
Normal 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 |
3
frontend/src/assets/paper_plane.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.6294 0.371048C15.4618 0.204652 15.2516 0.0876771 15.0218 0.0329776C14.7921 -0.0217218 14.5517 -0.012028 14.3271 0.0609937L1.36688 4.38403C1.12652 4.4602 0.914122 4.60588 0.756509 4.80269C0.598897 4.9995 0.503143 5.2386 0.481341 5.48979C0.459539 5.74099 0.512667 5.99301 0.634015 6.21403C0.755364 6.43505 0.939488 6.61515 1.16313 6.73159L6.54036 9.38919L9.19796 14.7841C9.30478 14.9953 9.46824 15.1726 9.67006 15.2962C9.87187 15.4198 10.1041 15.4848 10.3407 15.484H10.4293C10.6828 15.4653 10.9247 15.3708 11.1238 15.2129C11.3228 15.0549 11.4698 14.8407 11.5455 14.5981L15.9306 1.67327C16.0089 1.44998 16.0221 1.20903 15.9688 0.978485C15.9155 0.747942 15.7978 0.537288 15.6294 0.371048ZM1.91612 5.60653L13.2287 1.83273L6.94786 8.11354L1.91612 5.60653ZM10.4027 14.0843L7.88688 9.05256L14.1677 2.77175L10.4027 14.0843Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
1
frontend/src/assets/pin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#747474" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pin-icon lucide-pin"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -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 |
3
frontend/src/assets/redirect.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.22066 0.46934C5.36129 0.328889 5.55191 0.25 5.75066 0.25C5.94941 0.25 6.14003 0.328889 6.28066 0.46934L10.5307 4.71934C10.6711 4.85997 10.75 5.05059 10.75 5.24934C10.75 5.44809 10.6711 5.63871 10.5307 5.77934L6.28066 10.0293C6.13851 10.162 5.95041 10.2342 5.75602 10.2309C5.56163 10.2275 5.37614 10.1488 5.23866 10.0113C5.10119 9.87386 5.02247 9.68837 5.01911 9.49398C5.01576 9.29959 5.08802 9.11149 5.22066 8.96934L8.19066 5.99934L0.75066 5.99934C0.551747 5.99934 0.360983 5.92032 0.22033 5.77967C0.0796773 5.63902 0.000659715 5.44825 0.000659724 5.24934C0.000659733 5.05043 0.0796774 4.85966 0.22033 4.71901C0.360983 4.57836 0.551747 4.49934 0.75066 4.49934L8.19066 4.49934L5.22066 1.52934C5.08021 1.38871 5.00132 1.19809 5.00132 0.99934C5.00132 0.800589 5.08021 0.609965 5.22066 0.46934Z" fill="#7D54D1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
22
frontend/src/assets/robot.svg
Normal 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 |
12
frontend/src/assets/science-spark-dark.svg
Normal 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 |
12
frontend/src/assets/science-spark.svg
Normal 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 |
4
frontend/src/assets/source.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 10.5994V3.39941M11.6 3.39941V10.5994" stroke="#5D5D5D" stroke-width="0.9" stroke-linecap="round"/>
|
||||
<path d="M11.6 7C11.6 8.326 9.4508 9.4 6.8 9.4C4.1492 9.4 2 8.326 2 7M11.6 10.6C11.6 11.926 9.4508 13 6.8 13C4.1492 13 2 11.926 2 10.6M6.8 5.8C9.4508 5.8 11.6 4.726 11.6 3.4C11.6 2.074 9.4508 1 6.8 1C4.1492 1 2 2.074 2 3.4C2 4.726 4.1492 5.8 6.8 5.8Z" stroke="#5D5D5D" stroke-width="0.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
3
frontend/src/assets/spark.svg
Normal 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 |
6
frontend/src/assets/tool.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.82047 15.07H3.23047C2.40047 15.07 1.73047 14.4 1.73047 13.57V10.98C1.73047 10.58 1.89047 10.2 2.17047 9.91999L4.38047 7.70999C4.58047 7.50999 4.89047 7.50999 5.09047 7.70999C5.29047 7.90999 5.29047 8.21999 5.09047 8.41999L2.88047 10.63C2.78626 10.7223 2.73235 10.8481 2.73047 10.98V13.57C2.73047 13.85 2.95047 14.07 3.23047 14.07H5.82047C5.95047 14.07 6.08047 14.02 6.17047 13.92L8.38047 11.71C8.58047 11.51 8.89047 11.51 9.09047 11.71C9.29047 11.91 9.29047 12.22 9.09047 12.42L6.88047 14.63C6.60047 14.91 6.22047 15.07 5.82047 15.07ZM12.8505 8.44999C12.7849 8.45079 12.7199 8.43786 12.6596 8.41202C12.5993 8.38619 12.5451 8.34803 12.5005 8.29999C12.3005 8.09999 12.3005 7.78999 12.5005 7.58999L14.6005 5.48999C14.7905 5.29999 14.7905 4.97999 14.6005 4.77999L12.0105 2.18999C11.915 2.09844 11.7878 2.04732 11.6555 2.04732C11.5232 2.04732 11.396 2.09844 11.3005 2.18999L9.14047 4.34999C8.94047 4.54999 8.63047 4.54999 8.43047 4.34999C8.23047 4.14999 8.23047 3.83999 8.43047 3.63999L10.5905 1.47999C11.1605 0.90999 12.1405 0.90999 12.7105 1.47999L15.3005 4.06999C15.8805 4.64999 15.8805 5.60999 15.3005 6.18999L13.2005 8.28999C13.1005 8.38999 12.9705 8.43999 12.8505 8.43999V8.44999Z" fill="#5D5D5D"/>
|
||||
<path d="M11.6305 15.0605C11.2305 15.0605 10.8505 14.9005 10.5705 14.6205L8.38047 12.4305C8.18047 12.2305 8.18047 11.9205 8.38047 11.7205C8.58047 11.5205 8.89047 11.5205 9.09047 11.7205L11.2805 13.9105C11.4705 14.1005 11.8005 14.1005 11.9905 13.9105L14.5805 11.3205C14.7705 11.1305 14.7705 10.8105 14.5805 10.6105L12.3905 8.42055C12.1905 8.22055 12.1905 7.91055 12.3905 7.71055C12.5905 7.51055 12.9005 7.51055 13.1005 7.71055L15.2905 9.90055C15.8705 10.4805 15.8705 11.4405 15.2905 12.0205L12.7005 14.6105C12.4205 14.8905 12.0405 15.0505 11.6405 15.0505L11.6305 15.0605Z" fill="#5D5D5D"/>
|
||||
<path d="M8.98125 12.8195C8.91567 12.8203 8.85066 12.8073 8.79038 12.7815C8.7301 12.7557 8.6759 12.7175 8.63125 12.6695L2.09125 6.13945C1.51125 5.55945 1.51125 4.59945 2.09125 4.01945L4.68125 1.42945C5.25125 0.859453 6.23125 0.859453 6.80125 1.42945L13.3412 7.96945C13.5412 8.16945 13.5412 8.47945 13.3412 8.67945C13.1412 8.87945 12.8312 8.87945 12.6312 8.67945L6.09125 2.13945C5.99574 2.0479 5.86855 1.99678 5.73625 1.99678C5.60395 1.99678 5.47676 2.0479 5.38125 2.13945L2.79125 4.72945C2.60125 4.91945 2.60125 5.23945 2.79125 5.43945L9.33125 11.9795C9.53125 12.1795 9.53125 12.4895 9.33125 12.6895C9.23125 12.7895 9.10125 12.8395 8.98125 12.8395V12.8195Z" fill="#5D5D5D"/>
|
||||
<path d="M6.28906 6.01055C6.22349 6.01135 6.15847 5.99841 6.09819 5.97258C6.03791 5.94675 5.98371 5.90858 5.93906 5.86055C5.73906 5.66055 5.73906 5.35055 5.93906 5.15055L7.87906 3.21055C8.07906 3.01055 8.38906 3.01055 8.58906 3.21055C8.78906 3.41055 8.78906 3.72055 8.58906 3.92055L6.64906 5.86055C6.54906 5.96055 6.41906 6.01055 6.29906 6.01055H6.28906ZM8.31906 8.04055C8.25349 8.04135 8.18847 8.02841 8.12819 8.00258C8.06791 7.97675 8.01371 7.93858 7.96906 7.89055C7.76906 7.69055 7.76906 7.38055 7.96906 7.18055L9.90906 5.24055C10.1091 5.04055 10.4191 5.04055 10.6191 5.24055C10.8191 5.44055 10.8191 5.75055 10.6191 5.95055L8.67906 7.89055C8.57906 7.99055 8.44906 8.04055 8.32906 8.04055H8.31906ZM10.3491 10.0705C10.2835 10.0713 10.2185 10.0584 10.1582 10.0326C10.0979 10.0067 10.0437 9.96858 9.99906 9.92055C9.79906 9.72055 9.79906 9.41055 9.99906 9.21055L11.9391 7.27055C12.1391 7.07055 12.4491 7.07055 12.6491 7.27055C12.8491 7.47055 12.8491 7.78055 12.6491 7.98055L10.7091 9.92055C10.6091 10.0205 10.4791 10.0705 10.3591 10.0705H10.3491Z" fill="#5D5D5D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
1
frontend/src/assets/unpin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#747474" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pin-off-icon lucide-pin-off"><path d="M12 17v5"/><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89"/><path d="m2 2 20 20"/><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11"/></svg>
|
||||
|
After Width: | Height: | Size: 416 B |
3
frontend/src/assets/white-trash.svg
Normal 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 |
@@ -32,9 +32,9 @@ export default function Accordion({
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
return (
|
||||
<div className={`shadow-sm overflow-hidden ${className}`}>
|
||||
<div className={`overflow-hidden shadow-sm ${className}`}>
|
||||
<button
|
||||
className={`flex items-center justify-between w-full focus:outline-none ${titleClassName}`}
|
||||
className={`flex w-full items-center justify-between focus:outline-none ${titleClassName}`}
|
||||
onClick={toggleAccordion}
|
||||
>
|
||||
<p className="break-words">{title}</p>
|
||||
|
||||
@@ -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,26 +102,22 @@ 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' : ''}
|
||||
${
|
||||
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'
|
||||
}
|
||||
`}
|
||||
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'
|
||||
? 'text-rosso-corsa hover:bg-bright-gray dark:text-red-2000 dark:hover:bg-charcoal-grey/20'
|
||||
: 'text-eerie-black hover:bg-bright-gray dark:text-bright-gray dark:hover:bg-charcoal-grey/20'
|
||||
} `}
|
||||
>
|
||||
{option.icon && (
|
||||
<img
|
||||
width={option.iconWidth || 16}
|
||||
height={option.iconHeight || 16}
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
|
||||
/>
|
||||
<div className="flex w-4 justify-center">
|
||||
<img
|
||||
width={option.iconWidth || 16}
|
||||
height={option.iconHeight || 16}
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className={`cursor-pointer ${option.iconClassName || ''}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,58 +1,136 @@
|
||||
import clsx from 'clsx';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CheckMark from '../assets/checkmark.svg?react';
|
||||
import Copy from '../assets/copy.svg?react';
|
||||
import CopyIcon from '../assets/copy.svg?react';
|
||||
|
||||
type CopyButtonProps = {
|
||||
textToCopy: string;
|
||||
bgColorLight?: string;
|
||||
bgColorDark?: string;
|
||||
hoverBgColorLight?: string;
|
||||
hoverBgColorDark?: string;
|
||||
iconSize?: string;
|
||||
padding?: string;
|
||||
showText?: boolean;
|
||||
copiedDuration?: number;
|
||||
className?: string;
|
||||
iconWrapperClassName?: string;
|
||||
textClassName?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_SIZE = 'w-4 h-4';
|
||||
const DEFAULT_PADDING = 'p-2';
|
||||
const DEFAULT_COPIED_DURATION = 2000;
|
||||
const DEFAULT_BG_LIGHT = '#FFFFFF';
|
||||
const DEFAULT_BG_DARK = 'transparent';
|
||||
const DEFAULT_HOVER_BG_LIGHT = '#EEEEEE';
|
||||
const DEFAULT_HOVER_BG_DARK = '#4A4A4A';
|
||||
|
||||
export default function CopyButton({
|
||||
text,
|
||||
colorLight,
|
||||
colorDark,
|
||||
textToCopy,
|
||||
bgColorLight = DEFAULT_BG_LIGHT,
|
||||
bgColorDark = DEFAULT_BG_DARK,
|
||||
hoverBgColorLight = DEFAULT_HOVER_BG_LIGHT,
|
||||
hoverBgColorDark = DEFAULT_HOVER_BG_DARK,
|
||||
iconSize = DEFAULT_ICON_SIZE,
|
||||
padding = DEFAULT_PADDING,
|
||||
showText = false,
|
||||
}: {
|
||||
text: string;
|
||||
colorLight?: string;
|
||||
colorDark?: string;
|
||||
showText?: boolean;
|
||||
}) {
|
||||
copiedDuration = DEFAULT_COPIED_DURATION,
|
||||
className,
|
||||
iconWrapperClassName,
|
||||
textClassName,
|
||||
}: CopyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isCopyHovered, setIsCopyHovered] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutIdRef = useRef<number | null>(null);
|
||||
|
||||
const handleCopyClick = (text: string) => {
|
||||
copy(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
};
|
||||
const iconWrapperClasses = clsx(
|
||||
'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out',
|
||||
padding,
|
||||
`bg-[${bgColorLight}] dark:bg-[${bgColorDark}]`,
|
||||
`hover:bg-[${hoverBgColorLight}] dark:hover:bg-[${hoverBgColorDark}]`,
|
||||
{
|
||||
'bg-green-100 dark:bg-green-900 hover:bg-green-100 dark:hover:bg-green-900':
|
||||
isCopied,
|
||||
},
|
||||
iconWrapperClassName,
|
||||
);
|
||||
|
||||
const rootButtonClasses = clsx(
|
||||
'flex items-center gap-2 group',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 rounded-full',
|
||||
className,
|
||||
);
|
||||
|
||||
const textSpanClasses = clsx(
|
||||
'text-xs text-gray-600 dark:text-gray-400 transition-opacity duration-150 ease-in-out',
|
||||
{ 'opacity-75': isCopied },
|
||||
textClassName,
|
||||
);
|
||||
|
||||
const IconComponent = isCopied ? CheckMark : CopyIcon;
|
||||
const iconClasses = clsx(iconSize, {
|
||||
'stroke-green-600 dark:stroke-green-400': isCopied,
|
||||
'fill-none text-gray-700 dark:text-gray-300': !isCopied,
|
||||
});
|
||||
|
||||
const buttonTitle = isCopied
|
||||
? t('conversation.copied')
|
||||
: t('conversation.copy');
|
||||
const displayedText = isCopied
|
||||
? t('conversation.copied')
|
||||
: t('conversation.copy');
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (isCopied) return;
|
||||
|
||||
try {
|
||||
const success = copy(textToCopy);
|
||||
if (success) {
|
||||
setIsCopied(true);
|
||||
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
}
|
||||
|
||||
timeoutIdRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timeoutIdRef.current = null;
|
||||
}, copiedDuration);
|
||||
} else {
|
||||
console.warn('Copy command failed.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error);
|
||||
}
|
||||
}, [textToCopy, copiedDuration, isCopied]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleCopyClick(text)}
|
||||
onMouseEnter={() => setIsCopyHovered(true)}
|
||||
onMouseLeave={() => setIsCopyHovered(false)}
|
||||
className="flex items-center gap-2"
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={rootButtonClasses}
|
||||
title={buttonTitle}
|
||||
aria-label={buttonTitle}
|
||||
disabled={isCopied}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full p-2 ${
|
||||
isCopyHovered
|
||||
? `bg-[#EEEEEE] dark:bg-purple-taupe`
|
||||
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
|
||||
}`}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckMark className="cursor-pointer stroke-green-2000" />
|
||||
) : (
|
||||
<Copy className="w-4 cursor-pointer fill-none" />
|
||||
)}
|
||||
<div className={iconWrapperClasses}>
|
||||
<IconComponent className={iconClasses} aria-hidden="true" />
|
||||
</div>
|
||||
{showText && (
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{copied ? t('conversation.copied') : t('conversation.copy')}
|
||||
</span>
|
||||
)}
|
||||
{showText && <span className={textSpanClasses}>{displayedText}</span>}
|
||||
<span className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{isCopied ? t('conversation.copied', 'Copied to clipboard') : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,31 +52,31 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center text-xs justify-end gap-4 mt-2 p-2 border-gray-200">
|
||||
<div className="mt-2 flex items-center justify-end gap-4 border-gray-200 p-2 text-xs">
|
||||
{/* Rows per page dropdown */}
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-gray-900 dark:text-gray-50">
|
||||
{t('pagination.rowsPerPage')}:
|
||||
</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="px-3 py-1 border rounded dark:bg-dark-charcoal dark:text-light-gray hover:bg-gray-200 dark:hover:bg-neutral-700"
|
||||
className="rounded border px-3 py-1 hover:bg-gray-200 dark:bg-dark-charcoal dark:text-light-gray dark:hover:bg-neutral-700"
|
||||
>
|
||||
{rowsPerPage}
|
||||
</button>
|
||||
<div
|
||||
className={`absolute z-50 right-0 mt-1 w-28 transform bg-white dark:bg-dark-charcoal shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${
|
||||
className={`absolute right-0 z-50 mt-1 w-28 transform bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out dark:bg-dark-charcoal ${
|
||||
isDropdownOpen
|
||||
? 'scale-100 opacity-100 block'
|
||||
: 'scale-95 opacity-0 hidden'
|
||||
? 'block scale-100 opacity-100'
|
||||
: 'hidden scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
{rowsPerPageOptions.map((option) => (
|
||||
<div
|
||||
key={option}
|
||||
onClick={() => handleSelectRowsPerPage(option)}
|
||||
className={`cursor-pointer px-4 py-2 text-xs hover:bg-gray-100 dark:hover:bg-neutral-700 ${
|
||||
className={`cursor-pointer px-4 py-2 text-xs hover:bg-gray-100 dark:hover:bg-neutral-700 ${
|
||||
rowsPerPage === option
|
||||
? 'bg-gray-100 dark:bg-neutral-700 dark:text-light-gray'
|
||||
: 'bg-white dark:bg-dark-charcoal dark:text-light-gray'
|
||||
@@ -97,45 +97,45 @@ const Pagination: React.FC<PaginationProps> = ({
|
||||
<button
|
||||
onClick={handleFirstPage}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 border rounded disabled:opacity-50"
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowLeft}
|
||||
alt={t('pagination.firstPage')}
|
||||
className="dark:invert dark:sepia dark:brightness-200"
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 border rounded disabled:opacity-50"
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowLeft}
|
||||
alt={t('pagination.previousPage')}
|
||||
className="dark:invert dark:sepia dark:brightness-200"
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 border rounded disabled:opacity-50"
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={SingleArrowRight}
|
||||
alt={t('pagination.nextPage')}
|
||||
className="dark:invert dark:sepia dark:brightness-200"
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLastPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 border rounded disabled:opacity-50"
|
||||
className="rounded border px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
<img
|
||||
src={DoubleArrowRight}
|
||||
alt={t('pagination.lastPage')}
|
||||
className="dark:invert dark:sepia dark:brightness-200"
|
||||
className="dark:brightness-200 dark:invert dark:sepia"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function DropdownMenu({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={`w-28 transform rounded-md bg-white dark:bg-dark-charcoal shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${className}`}
|
||||
className={`w-28 transform rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out dark:bg-dark-charcoal ${className}`}
|
||||
>
|
||||
<div
|
||||
role="menu"
|
||||
|
||||
@@ -36,20 +36,20 @@ const Help = () => {
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleDropdown}
|
||||
className="my-auto mx-4 w-full flex items-center h-9 gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E]"
|
||||
className="mx-4 my-auto flex h-9 w-full items-center gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E]"
|
||||
>
|
||||
<img src={Info} alt="info" className="ml-2 w-5 filter dark:invert" />
|
||||
{t('help')}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute translate-x-4 -translate-y-28 z-10 w-48 shadow-lg bg-white dark:bg-[#444654] rounded-xl`}
|
||||
className={`absolute z-10 w-48 -translate-y-28 translate-x-4 rounded-xl bg-white shadow-lg dark:bg-[#444654]`}
|
||||
>
|
||||
<a
|
||||
href="https://docs.docsgpt.cloud/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start gap-4 px-4 py-2 text-black dark:text-white hover:bg-bright-gray dark:hover:bg-[#545561] rounded-t-xl"
|
||||
className="flex items-start gap-4 rounded-t-xl px-4 py-2 text-black hover:bg-bright-gray dark:text-white dark:hover:bg-[#545561]"
|
||||
>
|
||||
<img
|
||||
src={PageIcon}
|
||||
@@ -61,12 +61,12 @@ const Help = () => {
|
||||
</a>
|
||||
<a
|
||||
href="mailto:support@docsgpt.cloud"
|
||||
className="flex items-start gap-4 px-4 py-2 text-black dark:text-white hover:bg-bright-gray dark:hover:bg-[#545561] rounded-b-xl"
|
||||
className="flex items-start gap-4 rounded-b-xl px-4 py-2 text-black hover:bg-bright-gray dark:text-white dark:hover:bg-[#545561]"
|
||||
>
|
||||
<img
|
||||
src={EmailIcon}
|
||||
alt="Email Us"
|
||||
className="filter dark:invert p-0.5"
|
||||
className="p-0.5 filter dark:invert"
|
||||
width={20}
|
||||
/>
|
||||
{t('emailUs')}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
424
frontend/src/components/MermaidRenderer.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import CopyButton from './CopyButton';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import {
|
||||
oneLight,
|
||||
vscDarkPlus,
|
||||
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
import { MermaidRendererProps } from './types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStatus } from '../conversation/conversationSlice';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
|
||||
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
code,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const diagramId = useRef(`mermaid-${crypto.randomUUID()}`);
|
||||
const status = useSelector(selectStatus);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCode, setShowCode] = useState<boolean>(false);
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
|
||||
const downloadMenuRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const [zoomFactor, setZoomFactor] = useState<number>(2);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = (event.clientX - rect.left) / rect.width;
|
||||
const y = (event.clientY - rect.top) / rect.height;
|
||||
|
||||
setHoverPosition({ x, y });
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => setIsHovering(true);
|
||||
const handleMouseLeave = () => {
|
||||
setIsHovering(false);
|
||||
setHoverPosition(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (!isHovering) return;
|
||||
|
||||
if (event.key === '+' || event.key === '=') {
|
||||
setZoomFactor((prev) => Math.min(6, prev + 0.5)); // Cap at 6x
|
||||
event.preventDefault();
|
||||
} else if (event.key === '-') {
|
||||
setZoomFactor((prev) => Math.max(1, prev - 0.5)); // Minimum 1x
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (event: React.WheelEvent) => {
|
||||
if (!isHovering) return;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.deltaY < 0) {
|
||||
setZoomFactor((prev) => Math.min(6, prev + 0.25));
|
||||
} else {
|
||||
setZoomFactor((prev) => Math.max(1, prev - 0.25));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTransformOrigin = () => {
|
||||
if (!hoverPosition) return 'center center';
|
||||
return `${hoverPosition.x * 100}% ${hoverPosition.y * 100}%`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const renderDiagram = async () => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: isDarkTheme ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
const isCurrentlyLoading =
|
||||
isLoading !== undefined ? isLoading : status === 'loading';
|
||||
if (!isCurrentlyLoading && code) {
|
||||
try {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (element) {
|
||||
element.removeAttribute('data-processed');
|
||||
await mermaid.parse(code); //syntax check
|
||||
mermaid.contentLoaded();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error rendering mermaid diagram:', err);
|
||||
setError(
|
||||
`Failed to render diagram: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [code, isDarkTheme, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
downloadMenuRef.current &&
|
||||
!downloadMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDownloadMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showDownloadMenu]);
|
||||
|
||||
const downloadSvg = (): void => {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (!element) return;
|
||||
const svgElement = element.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
|
||||
const svgClone = svgElement.cloneNode(true) as SVGElement;
|
||||
|
||||
if (!svgClone.hasAttribute('xmlns')) {
|
||||
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
}
|
||||
|
||||
if (!svgClone.hasAttribute('width') || !svgClone.hasAttribute('height')) {
|
||||
const viewBox = svgClone.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
svgClone.setAttribute('width', viewBox[2]);
|
||||
svgClone.setAttribute('height', viewBox[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgClone);
|
||||
|
||||
const svgBlob = new Blob(
|
||||
[`<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n${svgString}`],
|
||||
{ type: 'image/svg+xml' },
|
||||
);
|
||||
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.svg';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadPng = (): void => {
|
||||
const element = document.getElementById(diagramId.current);
|
||||
if (!element) return;
|
||||
|
||||
const svgElement = element.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
|
||||
const svgClone = svgElement.cloneNode(true) as SVGElement;
|
||||
|
||||
if (!svgClone.hasAttribute('xmlns')) {
|
||||
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
}
|
||||
|
||||
let width = parseInt(svgClone.getAttribute('width') || '0');
|
||||
let height = parseInt(svgClone.getAttribute('height') || '0');
|
||||
|
||||
if (!width || !height) {
|
||||
const viewBox = svgClone.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
width = parseInt(viewBox[2]);
|
||||
height = parseInt(viewBox[3]);
|
||||
svgClone.setAttribute('width', width.toString());
|
||||
svgClone.setAttribute('height', height.toString());
|
||||
} else {
|
||||
width = 800;
|
||||
height = 600;
|
||||
svgClone.setAttribute('width', width.toString());
|
||||
svgClone.setAttribute('height', height.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgClone);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
|
||||
const dataUrl = `data:image/svg+xml;base64,${svgBase64}`;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = function (): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.error('Could not get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
try {
|
||||
const pngUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = 'diagram.png';
|
||||
link.href = pngUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (e) {
|
||||
console.error('Failed to create PNG:', e);
|
||||
// Fallback to SVG download if PNG fails
|
||||
downloadSvg();
|
||||
}
|
||||
};
|
||||
|
||||
img.src = dataUrl;
|
||||
};
|
||||
|
||||
const downloadMmd = (): void => {
|
||||
const blob = new Blob([code], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.mmd';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadOptions = [
|
||||
{ label: 'Download as SVG', action: downloadSvg },
|
||||
{ label: 'Download as PNG', action: downloadPng },
|
||||
{ label: 'Download as MMD', action: downloadMmd },
|
||||
];
|
||||
|
||||
const isCurrentlyLoading =
|
||||
isLoading !== undefined ? isLoading : status === 'loading';
|
||||
const showDiagramOptions = !isCurrentlyLoading && !error;
|
||||
const errorRender = !isCurrentlyLoading && error;
|
||||
|
||||
return (
|
||||
<div className="w-inherit group relative rounded-lg border border-light-silver bg-white dark:border-raisin-black dark:bg-eerie-black">
|
||||
<div className="flex items-center justify-between bg-platinum px-2 py-1 dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
mermaid
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyButton textToCopy={String(code).replace(/\n$/, '')} />
|
||||
|
||||
{showDiagramOptions && (
|
||||
<div className="relative" ref={downloadMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="flex h-full items-center rounded bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
|
||||
title="Download options"
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</button>
|
||||
{showDownloadMenu && (
|
||||
<div className="absolute right-0 z-10 mt-1 w-40 rounded border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||
<ul>
|
||||
{downloadOptions.map((option, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={() => {
|
||||
option.action();
|
||||
setShowDownloadMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-xs hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDiagramOptions && (
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className={`flex h-full items-center rounded px-2 py-1 text-xs ${
|
||||
showCode
|
||||
? 'bg-blue-200 dark:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700'
|
||||
}`}
|
||||
title="View Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCurrentlyLoading ? (
|
||||
<div className="flex items-center justify-center bg-white p-4 dark:bg-eerie-black">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading diagram...
|
||||
</div>
|
||||
</div>
|
||||
) : errorRender ? (
|
||||
<div className="m-2 rounded border-2 border-red-400 dark:border-red-700">
|
||||
<div className="overflow-auto whitespace-normal break-words bg-red-100 px-4 py-2 text-sm text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="no-scrollbar relative block w-full bg-white p-4 dark:bg-eerie-black"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
width: '100%',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
tabIndex={0}
|
||||
>
|
||||
{isHovering && (
|
||||
<>
|
||||
<div className="absolute right-2 top-2 z-10 flex items-center gap-2 rounded bg-black/70 px-2 py-1 text-xs text-white">
|
||||
<button
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.max(1, prev - 0.5))
|
||||
}
|
||||
className="rounded px-1 hover:bg-gray-600"
|
||||
title="Decrease zoom"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span
|
||||
className="cursor-pointer hover:underline"
|
||||
onClick={() => {
|
||||
setZoomFactor(2);
|
||||
}}
|
||||
title="Reset zoom"
|
||||
>
|
||||
{zoomFactor.toFixed(1)}x
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setZoomFactor((prev) => Math.min(6, prev + 0.5))
|
||||
}
|
||||
className="rounded px-1 hover:bg-gray-600"
|
||||
title="Increase zoom"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<pre
|
||||
className="mermaid w-full select-none"
|
||||
id={diagramId.current}
|
||||
key={`mermaid-${diagramId.current}`}
|
||||
style={{
|
||||
transform: isHovering ? `scale(${zoomFactor})` : `scale(1)`,
|
||||
transformOrigin: getTransformOrigin(),
|
||||
transition: 'transform 0.2s ease',
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{showCode && (
|
||||
<div className="border-t border-light-silver dark:border-raisin-black">
|
||||
<div className="bg-platinum p-2 dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
Mermaid Code
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language="mermaid"
|
||||
style={isDarkTheme ? vscDarkPlus : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
scrollbarWidth: 'thin',
|
||||
maxHeight: '300px',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidRenderer;
|
||||
@@ -1,27 +1,220 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDarkTheme } from '../hooks';
|
||||
import Send from '../assets/send.svg';
|
||||
import SendDark from '../assets/send_dark.svg';
|
||||
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 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';
|
||||
|
||||
interface MessageInputProps {
|
||||
type MessageInputProps = {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
showSourceButton?: boolean;
|
||||
showToolButton?: boolean;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export default function MessageInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
loading,
|
||||
showSourceButton = true,
|
||||
showToolButton = true,
|
||||
autoFocus = true,
|
||||
}: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkTheme] = useDarkTheme();
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const sourceButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const toolButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
|
||||
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
|
||||
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;
|
||||
|
||||
const file = e.target.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const apiHost = import.meta.env.VITE_API_HOST;
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
const newAttachment = {
|
||||
fileName: file.name,
|
||||
progress: 0,
|
||||
status: 'uploading' as const,
|
||||
taskId: '',
|
||||
};
|
||||
|
||||
dispatch(addAttachment(newAttachment));
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const progress = Math.round((event.loaded / event.total) * 100);
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: newAttachment.taskId,
|
||||
updates: { progress },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.task_id) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: newAttachment.taskId,
|
||||
updates: {
|
||||
taskId: response.task_id,
|
||||
status: 'processing',
|
||||
progress: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: newAttachment.taskId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: newAttachment.taskId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.send(formData);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkTaskStatus = () => {
|
||||
const processingAttachments = attachments.filter(
|
||||
(att) => att.status === 'processing' && att.taskId,
|
||||
);
|
||||
|
||||
processingAttachments.forEach((attachment) => {
|
||||
userService
|
||||
.getTaskStatus(attachment.taskId!, null)
|
||||
.then((data) => data.json())
|
||||
.then((data) => {
|
||||
if (data.status === 'SUCCESS') {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: attachment.taskId!,
|
||||
updates: {
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
id: data.result?.attachment_id,
|
||||
token_count: data.result?.token_count,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (data.status === 'FAILURE') {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: attachment.taskId!,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
} else if (data.status === 'PROGRESS' && data.result?.current) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: attachment.taskId!,
|
||||
updates: { progress: data.result.current },
|
||||
}),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
taskId: attachment.taskId!,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (attachments.some((att) => att.status === 'processing')) {
|
||||
checkTaskStatus();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [attachments, dispatch]);
|
||||
|
||||
const handleInput = () => {
|
||||
if (inputRef.current) {
|
||||
@@ -34,16 +227,15 @@ export default function MessageInput({
|
||||
}
|
||||
};
|
||||
|
||||
// Focus the textarea and set initial height on mount.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (autoFocus) inputRef.current?.focus();
|
||||
handleInput();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
handleSubmit();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
handleInput();
|
||||
@@ -51,43 +243,218 @@ export default function MessageInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostDocumentSelect = (doc: any) => {
|
||||
console.log('Selected document:', doc);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit();
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full mx-2 items-center rounded-[40px] border dark:border-grey border-dark-gray bg-lotion dark:bg-charleston-green-3">
|
||||
<label htmlFor="message-input" className="sr-only">
|
||||
{t('inputPlaceholder')}
|
||||
</label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tabIndex={1}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-full bg-lotion dark:bg-charleston-green-3 py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-6"
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t('inputPlaceholder')}
|
||||
/>
|
||||
{loading ? (
|
||||
<img
|
||||
src={isDarkTheme ? SpinnerDark : Spinner}
|
||||
className="relative right-[38px] bottom-[24px] -mr-[30px] animate-spin cursor-pointer self-end bg-transparent"
|
||||
alt={t('loading')}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-1 cursor-pointer rounded-full p-3 text-center hover:bg-gray-3000 dark:hover:bg-dark-charcoal">
|
||||
<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={`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="max-w-[120px] truncate font-medium sm:max-w-[150px]">
|
||||
{attachment.fileName}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{attachment.status === 'failed' && (
|
||||
<img
|
||||
src={AlertIcon}
|
||||
alt="Upload failed"
|
||||
className="ml-2 h-3.5 w-3.5"
|
||||
title="Upload failed"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(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"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
className="text-blue-600 dark:text-blue-400"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
strokeDasharray="62.83"
|
||||
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
|
||||
transform="rotate(-90 12 12)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label htmlFor="message-input" className="sr-only">
|
||||
{t('inputPlaceholder')}
|
||||
</label>
|
||||
<textarea
|
||||
id="message-input"
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
tabIndex={1}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
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 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="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="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="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="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="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
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileAttachment}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Additional badges can be added here in the future */}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
aria-label={t('send')}
|
||||
className="flex items-center justify-center"
|
||||
onClick={loading ? undefined : handleSubmit}
|
||||
aria-label={loading ? t('loading') : t('send')}
|
||||
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}
|
||||
>
|
||||
<img
|
||||
className="ml-[4px] h-6 w-6 text-white filter dark:invert-[0.45] invert-[0.35]"
|
||||
src={isDarkTheme ? SendDark : Send}
|
||||
alt={t('send')}
|
||||
/>
|
||||
{loading ? (
|
||||
<img
|
||||
src={isDarkTheme ? SpinnerDark : Spinner}
|
||||
className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
|
||||
alt={t('loading')}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
|
||||
src={PaperPlane}
|
||||
alt={t('send')}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SourcesPopup
|
||||
isOpen={isSourcesPopupOpen}
|
||||
onClose={() => setIsSourcesPopupOpen(false)}
|
||||
anchorRef={sourceButtonRef}
|
||||
handlePostDocumentSelect={handlePostDocumentSelect}
|
||||
setUploadModalState={setUploadModalState}
|
||||
/>
|
||||
|
||||
<ToolsPopup
|
||||
isOpen={isToolsPopupOpen}
|
||||
onClose={() => setIsToolsPopupOpen(false)}
|
||||
anchorRef={toolButtonRef}
|
||||
/>
|
||||
|
||||
{uploadModalState === 'ACTIVE' && (
|
||||
<Upload
|
||||
receivedFile={[]}
|
||||
setModalState={setUploadModalState}
|
||||
isOnboarding={false}
|
||||
renderTab={null}
|
||||
close={() => setUploadModalState('INACTIVE')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
278
frontend/src/components/MultiSelectPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||