Compare commits

...

20 Commits

Author SHA1 Message Date
Alex
e6d64f71f2 Update HACKTOBERFEST.md 2025-10-02 16:58:01 +01:00
Alex
e72313ebdd Merge pull request #2002 from ManishMadan2882/main
Frontend Lint
2025-10-02 12:35:59 +01:00
ManishMadan2882
65d5bd72cd Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-10-02 16:59:13 +05:30
ManishMadan2882
dc0cbb41f0 (fix:table) remove display name 2025-10-02 16:50:59 +05:30
ManishMadan2882
c4a54a85be (lint) fe 2025-10-02 16:30:08 +05:30
Alex
c2ccf2c72c Merge pull request #2000 from ManishMadan2882/main
Test coverage:  TTS, Security and Storage layers
2025-10-02 00:11:22 +01:00
ManishMadan2882
80aaecb5f0 ruff-fix 2025-10-02 02:48:16 +05:30
ManishMadan2882
946865a335 test:TTS 2025-10-02 02:40:30 +05:30
ManishMadan2882
5de15c8413 (feat:11Labs) just functional part 2025-10-02 02:39:53 +05:30
ManishMadan2882
67268fd35a tests:security 2025-10-02 02:34:05 +05:30
ManishMadan2882
42fc771833 tests:storage 2025-10-02 02:33:12 +05:30
Alex
4d34dc4234 Merge pull request #1996 from arc53/pr/1988
Pr/1988
2025-10-01 10:29:20 +01:00
Alex
d567399f2b Merge pull request #1995 from siiddhantt/fix/agent-sources
fix: agent sources and other issues
2025-10-01 10:19:47 +01:00
Siddhant Rai
ba49eea23d Refactor agent creation and update logic to improve error handling and default values; enhance logging for better traceability 2025-10-01 13:56:31 +05:30
Alex
82beafc086 Merge pull request #1991 from ManishMadan2882/tester
Tests: coverage for application/llm/* and application/llm/handlers/* ; fix on parsers/test_markdown
2025-09-30 23:19:38 +01:00
Alex
76658d50a0 Update HACKTOBERFEST.md 2025-09-30 21:46:09 +03:00
Alex
88ba22342c Update README with Hacktoberfest details and demo
Added Hacktoberfest information and a demo GIF.
2025-09-30 21:45:55 +03:00
Alex
11a1460af9 Add Hacktoberfest participation details to HACKTOBERFEST.md
This document outlines the participation of DocsGPT in Hacktoberfest, encouraging contributors to submit meaningful pull requests for a chance to win a T-shirt. It includes guidelines for contributions and links to resources.
2025-09-30 21:42:13 +03:00
Alex
2cd4c41316 Merge pull request #1992 from arc53/fix-api-answer-tool-call
fix: api answer tool call event
2025-09-30 14:49:57 +01:00
Alex
b910f308f2 fix: api answer tool call event 2025-09-30 14:42:54 +01:00
45 changed files with 2097 additions and 797 deletions

38
HACKTOBERFEST.md Normal file
View File

@@ -0,0 +1,38 @@
# **🎉 Join the Hacktoberfest with DocsGPT and win a Free T-shirt for a meaningful PR! 🎉**
Welcome, contributors! We're excited to announce that DocsGPT is participating in Hacktoberfest. Get involved by submitting meaningful pull requests.
All Meaningful contributors with accepted PRs that were created for issues with the `hacktoberfest` label (set by our maintainer team: dartpain, siiddhantt, pabik, ManishMadan2882) will receive a cool T-shirt! 🤩.
Fill in [this form](https://forms.gle/Npaba4n9Epfyx56S8
) after your PR was merged please
If you are in doubt don't hesitate to ping us on discord, ping me - Alex (dartpain).
## 📜 Here's How to Contribute:
```text
🛠️ Code: This is the golden ticket! Make meaningful contributions through PRs.
🧩 API extension: Build an app utilising DocsGPT API. We prefer submissions that showcase original ideas and turn the API into an AI agent.
They can be a completely separate repos.
For example:
https://github.com/arc53/tg-bot-docsgpt-extenstion or
https://github.com/arc53/DocsGPT-cli
Non-Code Contributions:
📚 Wiki: Improve our documentation, create a guide.
🖥️ Design: Improve the UI/UX or design a new feature.
```
### 📝 Guidelines for Pull Requests:
- Familiarize yourself with the current contributions and our [Roadmap](https://github.com/orgs/arc53/projects/2).
- Before contributing check existing [issues](https://github.com/arc53/DocsGPT/issues) or [create](https://github.com/arc53/DocsGPT/issues/new/choose) an issue and wait to get assigned.
- Once you are finished with your contribution, please fill in this [form](https://forms.gle/Npaba4n9Epfyx56S8).
- Refer to the [Documentation](https://docs.docsgpt.cloud/).
- Feel free to join our [Discord](https://discord.gg/n5BX8dh8rU) server. We're here to help newcomers, so don't hesitate to jump in! Join us [here](https://discord.gg/n5BX8dh8rU).
Thank you very much for considering contributing to DocsGPT during Hacktoberfest! 🙏 Your contributions (not just simple typos) could earn you a stylish new t-shirt.
We will publish a t-shirt desing later into the October.

View File

@@ -25,7 +25,17 @@
<br>
</div>
<div align="center">
<br>
🎃 <a href="https://github.com/arc53/DocsGPT/blob/main/HACKTOBERFEST.md"> Hacktoberfest Prizes, Rules & Q&A </a> 🎃
<br>
<br>
</div>
<div align="center">
<br>
<img src="https://d3dg1063dc54p9.cloudfront.net/videos/demov7.gif" alt="video-example-of-docs-gpt" width="800" height="450">
</div>
<h3 align="left">

View File

@@ -110,6 +110,8 @@ class BaseAnswerResource:
yield f"data: {data}\n\n"
elif "tool_calls" in line:
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"]})

View File

@@ -1714,7 +1714,7 @@ class CreateAgent(Resource):
"key": key,
}
if new_agent["chunks"] == "":
new_agent["chunks"] = "0"
new_agent["chunks"] = "2"
if (
new_agent["source"] == ""
and new_agent["retriever"] == ""
@@ -1770,43 +1770,56 @@ class UpdateAgent(Resource):
@api.doc(description="Update an existing agent")
def put(self, agent_id):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
return make_response(
jsonify({"success": False, "message": "Unauthorized"}), 401
)
user = decoded_token.get("sub")
if request.content_type == "application/json":
data = request.get_json()
else:
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
if "sources" in data:
try:
data["sources"] = json.loads(data["sources"])
except json.JSONDecodeError:
data["sources"] = []
if "json_schema" in data:
try:
data["json_schema"] = json.loads(data["json_schema"])
except json.JSONDecodeError:
data["json_schema"] = None
if not ObjectId.is_valid(agent_id):
return make_response(
jsonify({"success": False, "message": "Invalid agent ID format"}), 400
)
oid = ObjectId(agent_id)
try:
if request.content_type and "application/json" in request.content_type:
data = request.get_json()
else:
data = request.form.to_dict()
json_fields = ["tools", "sources", "json_schema"]
for field in json_fields:
if field in data and data[field]:
try:
data[field] = json.loads(data[field])
except json.JSONDecodeError:
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid JSON format for field: {field}",
}
),
400,
)
except Exception as err:
current_app.logger.error(
f"Error parsing request data: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Invalid request data"}), 400
)
try:
existing_agent = agents_collection.find_one({"_id": oid, "user": user})
except Exception as err:
current_app.logger.error(
f"Error finding agent {agent_id}: {err}", exc_info=True
)
return make_response(
current_app.logger.error(
f"Error finding agent {agent_id}: {err}", exc_info=True
),
jsonify({"success": False, "message": "Database error finding agent"}),
500,
)
if not existing_agent:
return make_response(
jsonify(
@@ -1814,13 +1827,19 @@ class UpdateAgent(Resource):
),
404,
)
image_url, error = handle_image_upload(
request, existing_agent.get("image", ""), user, storage
)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
current_app.logger.error(
f"Image upload error for agent {agent_id}: {error}"
)
return make_response(
jsonify({"success": False, "message": f"Image upload failed: {error}"}),
400,
)
update_fields = {}
allowed_fields = [
"name",
@@ -1838,116 +1857,189 @@ class UpdateAgent(Resource):
]
for field in allowed_fields:
if field in data:
if field == "status":
new_status = data.get("status")
if new_status not in ["draft", "published"]:
return make_response(
jsonify(
{"success": False, "message": "Invalid status value"}
),
400,
)
update_fields[field] = new_status
elif field == "source":
source_id = data.get("source")
if source_id == "default":
# Handle special "default" source
if field not in data:
continue
update_fields[field] = "default"
elif source_id and ObjectId.is_valid(source_id):
update_fields[field] = DBRef("sources", ObjectId(source_id))
elif source_id:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid source ID format provided",
}
),
400,
)
else:
update_fields[field] = ""
elif field == "sources":
sources_list = data.get("sources", [])
if sources_list and isinstance(sources_list, list):
valid_sources = []
for source_id in sources_list:
if source_id == "default":
valid_sources.append("default")
elif ObjectId.is_valid(source_id):
valid_sources.append(
DBRef("sources", ObjectId(source_id))
)
else:
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid source ID format: {source_id}",
}
),
400,
)
update_fields[field] = valid_sources
else:
update_fields[field] = []
elif field == "chunks":
chunks_value = data.get("chunks")
if chunks_value == "":
update_fields[field] = "0"
else:
try:
if int(chunks_value) < 0:
return make_response(
jsonify(
{
"success": False,
"message": "Chunks value must be a positive integer",
}
),
400,
)
update_fields[field] = chunks_value
except ValueError:
if field == "status":
new_status = data.get("status")
if new_status not in ["draft", "published"]:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid status value. Must be 'draft' or 'published'",
}
),
400,
)
update_fields[field] = new_status
elif field == "source":
source_id = data.get("source")
if source_id == "default":
update_fields[field] = "default"
elif source_id and ObjectId.is_valid(source_id):
update_fields[field] = DBRef("sources", ObjectId(source_id))
elif source_id:
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid source ID format: {source_id}",
}
),
400,
)
else:
update_fields[field] = ""
elif field == "sources":
sources_list = data.get("sources", [])
if sources_list and isinstance(sources_list, list):
valid_sources = []
for source_id in sources_list:
if source_id == "default":
valid_sources.append("default")
elif ObjectId.is_valid(source_id):
valid_sources.append(DBRef("sources", ObjectId(source_id)))
else:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid chunks value provided",
"message": f"Invalid source ID in list: {source_id}",
}
),
400,
)
update_fields[field] = valid_sources
else:
update_fields[field] = data[field]
update_fields[field] = []
elif field == "chunks":
chunks_value = data.get("chunks")
if chunks_value == "" or chunks_value is None:
update_fields[field] = "2"
else:
try:
chunks_int = int(chunks_value)
if chunks_int < 0:
return make_response(
jsonify(
{
"success": False,
"message": "Chunks value must be a non-negative integer",
}
),
400,
)
update_fields[field] = str(chunks_int)
except (ValueError, TypeError):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid chunks value: {chunks_value}",
}
),
400,
)
elif field == "tools":
tools_list = data.get("tools", [])
if isinstance(tools_list, list):
update_fields[field] = tools_list
else:
return make_response(
jsonify(
{
"success": False,
"message": "Tools must be a list",
}
),
400,
)
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
if not isinstance(json_schema, dict):
return make_response(
jsonify(
{
"success": False,
"message": "JSON schema must be a valid object",
}
),
400,
)
update_fields[field] = json_schema
else:
update_fields[field] = None
else:
value = data[field]
if field in ["name", "description", "prompt_id", "agent_type"]:
if not value or not str(value).strip():
return make_response(
jsonify(
{
"success": False,
"message": f"Field '{field}' cannot be empty",
}
),
400,
)
update_fields[field] = value
if image_url:
update_fields["image"] = image_url
if not update_fields:
return make_response(
jsonify({"success": False, "message": "No update data provided"}), 400
jsonify(
{
"success": False,
"message": "No valid update data provided",
}
),
400,
)
newly_generated_key = None
final_status = update_fields.get("status", existing_agent.get("status"))
if final_status == "published":
required_published_fields = [
"name",
"description",
"source",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
required_published_fields = {
"name": "Agent name",
"description": "Agent description",
"chunks": "Chunks count",
"prompt_id": "Prompt",
"agent_type": "Agent type",
}
missing_published_fields = []
for req_field in required_published_fields:
for req_field, field_label in required_published_fields.items():
final_value = update_fields.get(
req_field, existing_agent.get(req_field)
)
if req_field == "source" and final_value:
if not isinstance(final_value, DBRef):
missing_published_fields.append(req_field)
if not final_value:
missing_published_fields.append(field_label)
source_val = update_fields.get("source", existing_agent.get("source"))
sources_val = update_fields.get(
"sources", existing_agent.get("sources", [])
)
has_valid_source = (
isinstance(source_val, DBRef)
or source_val == "default"
or (isinstance(sources_val, list) and len(sources_val) > 0)
)
if not has_valid_source:
missing_published_fields.append("Source")
if missing_published_fields:
return make_response(
jsonify(
@@ -1958,9 +2050,11 @@ class UpdateAgent(Resource):
),
400,
)
if not existing_agent.get("key"):
newly_generated_key = str(uuid.uuid4())
update_fields["key"] = newly_generated_key
update_fields["updatedAt"] = datetime.datetime.now(datetime.timezone.utc)
try:
@@ -1973,20 +2067,22 @@ class UpdateAgent(Resource):
jsonify(
{
"success": False,
"message": "Agent not found or update failed unexpectedly",
"message": "Agent not found or update failed",
}
),
404,
)
if result.modified_count == 0 and result.matched_count == 1:
return make_response(
jsonify(
{
"success": True,
"message": "Agent found, but no changes were applied",
"message": "No changes detected",
"id": agent_id,
}
),
304,
200,
)
except Exception as err:
current_app.logger.error(
@@ -1996,6 +2092,7 @@ class UpdateAgent(Resource):
jsonify({"success": False, "message": "Database error during update"}),
500,
)
response_data = {
"success": True,
"id": agent_id,
@@ -2003,10 +2100,8 @@ class UpdateAgent(Resource):
}
if newly_generated_key:
response_data["key"] = newly_generated_key
return make_response(
jsonify(response_data),
200,
)
return make_response(jsonify(response_data), 200)
@user_ns.route("/api/delete_agent")

View File

@@ -118,6 +118,7 @@ class Settings(BaseSettings):
# Encryption settings
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
ELEVENLABS_API_KEY: Optional[str] = None
path = Path(__file__).parent.parent.absolute()
settings = Settings(_env_file=path.joinpath(".env"), _env_file_encoding="utf-8")

View File

@@ -36,6 +36,11 @@ class ClassicRAG(BaseRetriever):
self.chunks = 2
else:
self.chunks = chunks
user_identifier = user_api_key if user_api_key else "default"
logging.info(
f"ClassicRAG initialized with chunks={self.chunks}, user_api_key={user_identifier}, "
f"sources={'active_docs' in source and source['active_docs'] is not None}"
)
self.gpt_model = gpt_model
self.token_limit = (
token_limit
@@ -92,17 +97,12 @@ class ClassicRAG(BaseRetriever):
or not self.vectorstores
):
return self.original_question
prompt = f"""Given the following conversation history:
{self.chat_history}
Rephrase the following user question to be a standalone search query
that captures all relevant context from the conversation:
"""
prompt = (
"Given the following conversation history:\n"
f"{self.chat_history}\n\n"
"Rephrase the following user question to be a standalone search query "
"that captures all relevant context from the conversation:\n"
)
messages = [
{"role": "system", "content": prompt},
@@ -120,10 +120,20 @@ class ClassicRAG(BaseRetriever):
def _get_data(self):
"""Retrieve relevant documents from configured vectorstores"""
if self.chunks == 0 or not self.vectorstores:
logging.info(
f"ClassicRAG._get_data: Skipping retrieval - chunks={self.chunks}, "
f"vectorstores_count={len(self.vectorstores) if self.vectorstores else 0}"
)
return []
all_docs = []
chunks_per_source = max(1, self.chunks // len(self.vectorstores))
logging.info(
f"ClassicRAG._get_data: Starting retrieval with chunks={self.chunks}, "
f"vectorstores={self.vectorstores}, chunks_per_source={chunks_per_source}, "
f"query='{self.question[:50]}...'"
)
for vectorstore_id in self.vectorstores:
if vectorstore_id:
try:
@@ -172,6 +182,10 @@ class ClassicRAG(BaseRetriever):
exc_info=True,
)
continue
logging.info(
f"ClassicRAG._get_data: Retrieval complete - retrieved {len(all_docs)} documents "
f"(requested chunks={self.chunks}, chunks_per_source={chunks_per_source})"
)
return all_docs
def search(self, query: str = ""):

View File

@@ -1,84 +1,30 @@
import asyncio
import websockets
import json
import base64
from io import BytesIO
import base64
from application.tts.base import BaseTTS
from application.core.settings import settings
class ElevenlabsTTS(BaseTTS):
def __init__(self):
self.api_key = 'ELEVENLABS_API_KEY'# here you should put your api key
self.model = "eleven_flash_v2_5"
self.voice = "VOICE_ID" # this is the hash code for the voice not the name!
self.write_audio = 1
def __init__(self):
from elevenlabs.client import ElevenLabs
self.client = ElevenLabs(
api_key=settings.ELEVENLABS_API_KEY,
)
def text_to_speech(self, text):
asyncio.run(self._text_to_speech_websocket(text))
lang = "en"
audio = self.client.generate(
text=text,
model="eleven_multilingual_v2",
voice="Brian",
)
audio_data = BytesIO()
for chunk in audio:
audio_data.write(chunk)
audio_bytes = audio_data.getvalue()
async def _text_to_speech_websocket(self, text):
uri = f"wss://api.elevenlabs.io/v1/text-to-speech/{self.voice}/stream-input?model_id={self.model}"
websocket = await websockets.connect(uri)
payload = {
"text": " ",
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.8,
},
"xi_api_key": self.api_key,
}
await websocket.send(json.dumps(payload))
async def listen():
while 1:
try:
msg = await websocket.recv()
data = json.loads(msg)
if data.get("audio"):
print("audio received")
yield base64.b64decode(data["audio"])
elif data.get("isFinal"):
break
except websockets.exceptions.ConnectionClosed:
print("websocket closed")
break
listen_task = asyncio.create_task(self.stream(listen()))
await websocket.send(json.dumps({"text": text}))
# this is to signal the end of the text, either use this or flush
await websocket.send(json.dumps({"text": ""}))
await listen_task
async def stream(self, audio_stream):
if self.write_audio:
audio_bytes = BytesIO()
async for chunk in audio_stream:
if chunk:
audio_bytes.write(chunk)
with open("output_audio.mp3", "wb") as f:
f.write(audio_bytes.getvalue())
else:
async for chunk in audio_stream:
pass # depends on the streamer!
def test_elevenlabs_websocket():
"""
Tests the ElevenlabsTTS text_to_speech method with a sample prompt.
Prints out the base64-encoded result and writes it to 'output_audio.mp3'.
"""
# Instantiate your TTS class
tts = ElevenlabsTTS()
# Call the method with some sample text
tts.text_to_speech("Hello from ElevenLabs WebSocket!")
print("Saved audio to output_audio.mp3.")
if __name__ == "__main__":
test_elevenlabs_websocket()
# Encode to base64
audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
return audio_base64, lang

View File

@@ -1,3 +1,4 @@
import logging
import os
from abc import ABC, abstractmethod
@@ -9,13 +10,27 @@ from application.core.settings import settings
class EmbeddingsWrapper:
def __init__(self, model_name, *args, **kwargs):
self.model = SentenceTransformer(
model_name,
config_kwargs={"allow_dangerous_deserialization": True},
*args,
**kwargs
)
self.dimension = self.model.get_sentence_embedding_dimension()
logging.info(f"Initializing EmbeddingsWrapper with model: {model_name}")
try:
kwargs.setdefault("trust_remote_code", True)
self.model = SentenceTransformer(
model_name,
config_kwargs={"allow_dangerous_deserialization": True},
*args,
**kwargs,
)
if self.model is None or self.model._first_module() is None:
raise ValueError(
f"SentenceTransformer model failed to load properly for: {model_name}"
)
self.dimension = self.model.get_sentence_embedding_dimension()
logging.info(f"Successfully loaded model with dimension: {self.dimension}")
except Exception as e:
logging.error(
f"Failed to initialize SentenceTransformer with model {model_name}: {str(e)}",
exc_info=True,
)
raise
def embed_query(self, query: str):
return self.model.encode(query).tolist()
@@ -117,15 +132,29 @@ class BaseVectorStore(ABC):
embeddings_name, openai_api_key=embeddings_key
)
elif embeddings_name == "huggingface_sentence-transformers/all-mpnet-base-v2":
if os.path.exists("./models/all-mpnet-base-v2"):
possible_paths = [
"/app/models/all-mpnet-base-v2", # Docker absolute path
"./models/all-mpnet-base-v2", # Relative path
]
local_model_path = None
for path in possible_paths:
if os.path.exists(path):
local_model_path = path
logging.info(f"Found local model at path: {path}")
break
else:
logging.info(f"Path does not exist: {path}")
if local_model_path:
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name="./models/all-mpnet-base-v2",
local_model_path,
)
else:
logging.warning(
f"Local model not found in any of the paths: {possible_paths}. Falling back to HuggingFace download."
)
embedding_instance = EmbeddingsSingleton.get_instance(
embeddings_name,
)
else:
embedding_instance = EmbeddingsSingleton.get_instance(embeddings_name)
return embedding_instance

View File

@@ -33,7 +33,7 @@ function MainLayout() {
const [navOpen, setNavOpen] = useState(!(isMobile || isTablet));
return (
<div className="relative h-screen overflow-hidden dark:bg-raisin-black">
<div className="dark:bg-raisin-black relative h-screen overflow-hidden">
<Navigation navOpen={navOpen} setNavOpen={setNavOpen} />
<ActionButtons showNewChat={true} showShare={true} />
<div

View File

@@ -2,11 +2,11 @@ import { Link } from 'react-router-dom';
export default function PageNotFound() {
return (
<div className="grid min-h-screen dark:bg-raisin-black">
<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">
<div className="dark:bg-raisin-black grid min-h-screen">
<p className="text-jet dark:bg-outer-space 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 lg:p-10 xl:p-16 dark:text-gray-100">
<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 px-4 py-2 text-white transition-colors duration-100 hover:bg-blue-3000">
<button className="pointer-cursor bg-blue-1000 hover:bg-blue-3000 mr-4 flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100">
<Link to="/">Go Back Home</Link>
</button>
</p>

View File

@@ -46,11 +46,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
image: '',
source: '',
sources: [],
chunks: '',
retriever: '',
chunks: '2',
retriever: 'classic',
prompt_id: 'default',
tools: [],
agent_type: '',
agent_type: 'classic',
status: '',
json_schema: undefined,
});
@@ -122,7 +122,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
agent.name && agent.description && agent.prompt_id && agent.agent_type;
const isJsonSchemaValidOrEmpty =
jsonSchemaText.trim() === '' || jsonSchemaValid;
return hasRequiredFields && isJsonSchemaValidOrEmpty;
const hasSource = selectedSourceIds.size > 0;
return hasRequiredFields && isJsonSchemaValidOrEmpty && hasSource;
};
const isJsonSchemaInvalid = () => {
@@ -353,6 +354,26 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
getPrompts();
}, [token]);
// Auto-select default source if none selected
useEffect(() => {
if (sourceDocs && sourceDocs.length > 0 && selectedSourceIds.size === 0) {
const defaultSource = sourceDocs.find((s) => s.name === 'Default');
if (defaultSource) {
setSelectedSourceIds(
new Set([
defaultSource.id || defaultSource.retriever || defaultSource.name,
]),
);
} else {
setSelectedSourceIds(
new Set([
sourceDocs[0].id || sourceDocs[0].retriever || sourceDocs[0].name,
]),
);
}
}
}, [sourceDocs, selectedSourceIds.size]);
useEffect(() => {
if ((mode === 'edit' || mode === 'draft') && agentId) {
const getAgent = async () => {
@@ -650,7 +671,34 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
selectedIds={selectedSourceIds}
onSelectionChange={(newSelectedIds: Set<string | number>) => {
setSelectedSourceIds(newSelectedIds);
if (
newSelectedIds.size === 0 &&
sourceDocs &&
sourceDocs.length > 0
) {
const defaultSource = sourceDocs.find(
(s) => s.name === 'Default',
);
if (defaultSource) {
setSelectedSourceIds(
new Set([
defaultSource.id ||
defaultSource.retriever ||
defaultSource.name,
]),
);
} else {
setSelectedSourceIds(
new Set([
sourceDocs[0].id ||
sourceDocs[0].retriever ||
sourceDocs[0].name,
]),
);
}
} else {
setSelectedSourceIds(newSelectedIds);
}
}}
title="Select Sources"
searchPlaceholder="Search sources..."

View File

@@ -41,13 +41,13 @@ export default function ActionButtons({
navigate('/');
};
return (
<div className="fixed right-4 top-0 z-10 flex h-16 flex-col justify-center">
<div className="fixed top-0 right-4 z-10 flex h-16 flex-col justify-center">
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
{showNewChat && (
<button
title="Open New Chat"
onClick={newChat}
className="flex items-center gap-1 rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E] lg:hidden"
className="hover:bg-bright-gray flex items-center gap-1 rounded-full p-2 lg:hidden dark:hover:bg-[#28292E]"
>
<img
className="filter dark:invert"
@@ -64,7 +64,7 @@ export default function ActionButtons({
<button
title="Share"
onClick={() => setShareModalState(true)}
className="rounded-full p-2 hover:bg-bright-gray dark:hover:bg-[#28292E]"
className="hover:bg-bright-gray rounded-full p-2 dark:hover:bg-[#28292E]"
>
<img
className="filter dark:invert"

View File

@@ -2,12 +2,16 @@ import React, { useState, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { selectToken } from '../preferences/preferenceSlice';
import { useDarkTheme, useLoaderState, useMediaQuery, useOutsideAlerter } from '../hooks';
import {
useDarkTheme,
useLoaderState,
useMediaQuery,
useOutsideAlerter,
} from '../hooks';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import NoFilesIcon from '../assets/no-files.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import OutlineSource from '../assets/outline-source.svg';
import SkeletonLoader from './SkeletonLoader';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
@@ -33,7 +37,7 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
ariaLabel,
className = '',
editable = true,
onDoubleClick
onDoubleClick,
}) => {
const { isMobile } = useMediaQuery();
@@ -45,28 +49,31 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
const contentLines = value.split('\n').length;
const heightOffset = isMobile ? 200 : 300;
const minLinesForDisplay = Math.ceil((typeof window !== 'undefined' ? window.innerHeight - heightOffset : 600) / lineHeight);
const minLinesForDisplay = Math.ceil(
(typeof window !== 'undefined' ? window.innerHeight - heightOffset : 600) /
lineHeight,
);
const totalLines = Math.max(contentLines, minLinesForDisplay);
return (
<div className={`relative w-full ${className}`}>
<div
className="absolute left-0 top-0 w-8 lg:w-12 text-right text-gray-500 dark:text-gray-400 text-xs lg:text-sm font-mono leading-[19.93px] select-none pr-2 lg:pr-3 pointer-events-none"
className="pointer-events-none absolute top-0 left-0 w-8 pr-2 text-right font-mono text-xs leading-[19.93px] text-gray-500 select-none lg:w-12 lg:pr-3 lg:text-sm dark:text-gray-400"
style={{
height: `${totalLines * lineHeight}px`
height: `${totalLines * lineHeight}px`,
}}
>
{Array.from({ length: totalLines }, (_, i) => (
<div
key={i + 1}
className="flex items-center justify-end h-[19.93px] leading-[19.93px]"
className="flex h-[19.93px] items-center justify-end leading-[19.93px]"
>
{i + 1}
</div>
))}
</div>
<textarea
className={`w-full resize-none bg-transparent dark:text-white font-['Inter'] text-[13.68px] leading-[19.93px] text-[#18181B] outline-none border-none pl-8 lg:pl-12 overflow-hidden ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
className={`w-full resize-none overflow-hidden border-none bg-transparent pl-8 font-['Inter'] text-[13.68px] leading-[19.93px] text-[#18181B] outline-none lg:pl-12 dark:text-white ${isMobile ? 'min-h-[calc(100vh-200px)]' : 'min-h-[calc(100vh-300px)]'} ${!editable ? 'select-none' : ''}`}
value={value}
onChange={editable ? handleChange : undefined}
onDoubleClick={onDoubleClick}
@@ -75,7 +82,7 @@ const LineNumberedTextarea: React.FC<LineNumberedTextareaProps> = ({
rows={totalLines}
readOnly={!editable}
style={{
height: `${totalLines * lineHeight}px`
height: `${totalLines * lineHeight}px`,
}}
/>
</div>
@@ -105,7 +112,9 @@ const Chunks: React.FC<ChunksProps> = ({
onFileSelect,
}) => {
const [fileSearchQuery, setFileSearchQuery] = useState('');
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([]);
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>(
[],
);
const searchDropdownRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -120,7 +129,8 @@ const Chunks: React.FC<ChunksProps> = ({
const [editingTitle, setEditingTitle] = useState('');
const [editingText, setEditingText] = useState('');
const [isAddingChunk, setIsAddingChunk] = useState(false);
const [deleteModalState, setDeleteModalState] = useState<ActiveState>('INACTIVE');
const [deleteModalState, setDeleteModalState] =
useState<ActiveState>('INACTIVE');
const [chunkToDelete, setChunkToDelete] = useState<ChunkType | null>(null);
const [isEditing, setIsEditing] = useState(false);
@@ -189,7 +199,6 @@ const Chunks: React.FC<ChunksProps> = ({
};
const handleUpdateChunk = (title: string, text: string, chunk: ChunkType) => {
if (!text.trim()) {
return;
}
@@ -274,7 +283,7 @@ const Chunks: React.FC<ChunksProps> = ({
useEffect(() => {
!loading && fetchChunks();
}, [page, perPage, path]);
useEffect(() => {
setSearchTerm('');
setPage(1);
@@ -284,35 +293,45 @@ const Chunks: React.FC<ChunksProps> = ({
const renderPathNavigation = () => {
return (
<div className="mb-0 min-h-[38px] flex flex-col sm:flex-row sm:items-center sm:justify-between text-base gap-2">
<div className="mb-0 flex min-h-[38px] flex-col gap-2 text-base sm:flex-row sm:items-center sm:justify-between">
<div className="flex w-full items-center sm:w-auto">
<button
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34] transition-all duration-200 font-medium"
onClick={editingChunk ? () => setEditingChunk(null) : isAddingChunk ? () => setIsAddingChunk(false) : handleGoBack}
className="mr-3 flex h-[29px] w-[29px] items-center justify-center rounded-full border p-2 text-sm font-medium text-gray-400 transition-all duration-200 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
onClick={
editingChunk
? () => setEditingChunk(null)
: isAddingChunk
? () => setIsAddingChunk(false)
: handleGoBack
}
>
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<div className="flex items-center flex-wrap">
<div className="flex flex-wrap items-center">
{/* Removed the directory icon */}
<span className="text-[#7D54D1] font-semibold break-words">
<span className="font-semibold break-words text-[#7D54D1]">
{documentName}
</span>
{pathParts.length > 0 && (
<>
<span className="mx-1 text-gray-500 flex-shrink-0">/</span>
<span className="mx-1 flex-shrink-0 text-gray-500">/</span>
{pathParts.map((part, index) => (
<React.Fragment key={index}>
<span className={`break-words ${
index < pathParts.length - 1
? 'text-[#7D54D1] font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}>
<span
className={`break-words ${
index < pathParts.length - 1
? 'font-medium text-[#7D54D1]'
: 'text-gray-700 dark:text-gray-300'
}`}
>
{part}
</span>
{index < pathParts.length - 1 && (
<span className="mx-1 text-gray-500 flex-shrink-0">/</span>
<span className="mx-1 flex-shrink-0 text-gray-500">
/
</span>
)}
</React.Fragment>
))}
@@ -321,18 +340,18 @@ const Chunks: React.FC<ChunksProps> = ({
</div>
</div>
<div className="flex flex-row flex-nowrap items-center gap-2 w-full sm:w-auto justify-end mt-2 sm:mt-0 overflow-x-auto">
<div className="mt-2 flex w-full flex-row flex-nowrap items-center justify-end gap-2 overflow-x-auto sm:mt-0 sm:w-auto">
{editingChunk ? (
!isEditing ? (
<>
<button
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] whitespace-nowrap text-white font-medium"
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-nowrap text-white"
onClick={() => setIsEditing(true)}
>
{t('modals.chunk.edit')}
</button>
<button
className="rounded-full border border-solid border-red-500 px-4 py-1 text-[14px] text-nowrap text-red-500 hover:bg-red-500 hover:text-white h-[38px] min-w-[108px] flex items-center justify-center font-medium"
className="flex h-[38px] min-w-[108px] items-center justify-center rounded-full border border-solid border-red-500 px-4 py-1 text-[14px] font-medium text-nowrap text-red-500 hover:bg-red-500 hover:text-white"
onClick={() => {
confirmDeleteChunk(editingChunk);
}}
@@ -346,28 +365,40 @@ const Chunks: React.FC<ChunksProps> = ({
onClick={() => {
setIsEditing(false);
}}
className="dark:text-light-gray cursor-pointer rounded-full px-4 py-1 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50 text-nowrap h-[38px] min-w-[108px] flex items-center justify-center"
className="dark:text-light-gray flex h-[38px] min-w-[108px] cursor-pointer items-center justify-center rounded-full px-4 py-1 text-sm font-medium text-nowrap hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.chunk.cancel')}
</button>
<button
onClick={() => {
if (editingText.trim()) {
const hasChanges = editingTitle !== (editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || '');
const hasChanges =
editingTitle !==
(editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || '');
if (hasChanges) {
handleUpdateChunk(editingTitle, editingText, editingChunk);
handleUpdateChunk(
editingTitle,
editingText,
editingChunk,
);
}
setIsEditing(false);
setEditingChunk(null);
}
}}
disabled={!editingText.trim() || (editingTitle === (editingChunk?.metadata?.title || '') && editingText === (editingChunk?.text || ''))}
className={`text-nowrap rounded-full px-4 py-1 text-[14px] text-white transition-all flex items-center justify-center h-[38px] min-w-[108px] font-medium ${
editingText.trim() && (editingTitle !== (editingChunk?.metadata?.title || '') || editingText !== (editingChunk?.text || ''))
disabled={
!editingText.trim() ||
(editingTitle === (editingChunk?.metadata?.title || '') &&
editingText === (editingChunk?.text || ''))
}
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 py-1 text-[14px] font-medium text-nowrap text-white transition-all ${
editingText.trim() &&
(editingTitle !== (editingChunk?.metadata?.title || '') ||
editingText !== (editingChunk?.text || ''))
? 'bg-purple-30 hover:bg-violets-are-blue cursor-pointer'
: 'bg-gray-400 cursor-not-allowed'
: 'cursor-not-allowed bg-gray-400'
}`}
>
{t('modals.chunk.save')}
@@ -378,7 +409,7 @@ const Chunks: React.FC<ChunksProps> = ({
<>
<button
onClick={() => setIsAddingChunk(false)}
className="dark:text-light-gray cursor-pointer rounded-full px-4 py-1 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50 text-nowrap h-[38px] min-w-[108px] flex items-center justify-center"
className="dark:text-light-gray flex h-[38px] min-w-[108px] cursor-pointer items-center justify-center rounded-full px-4 py-1 text-sm font-medium text-nowrap hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.chunk.cancel')}
</button>
@@ -390,10 +421,10 @@ const Chunks: React.FC<ChunksProps> = ({
}
}}
disabled={!editingText.trim()}
className={`text-nowrap rounded-full px-4 py-1 text-[14px] text-white transition-all flex items-center justify-center h-[38px] min-w-[108px] font-medium ${
className={`flex h-[38px] min-w-[108px] items-center justify-center rounded-full px-4 py-1 text-[14px] font-medium text-nowrap text-white transition-all ${
editingText.trim()
? 'bg-purple-30 hover:bg-violets-are-blue cursor-pointer'
: 'bg-gray-400 cursor-not-allowed'
: 'cursor-not-allowed bg-gray-400'
}`}
>
{t('modals.chunk.add')}
@@ -437,33 +468,30 @@ const Chunks: React.FC<ChunksProps> = ({
setFileSearchResults([]);
},
[], // No additional dependencies
false // Don't handle escape key
false, // Don't handle escape key
);
const renderFileSearch = () => {
return (
<div className="relative" ref={searchDropdownRef}>
<div className="relative flex items-center">
<div className="absolute left-3 pointer-events-none">
<img src={SearchIcon} alt="Search" className="w-4 h-4" />
<div className="pointer-events-none absolute left-3">
<img src={SearchIcon} alt="Search" className="h-4 w-4" />
</div>
<input
type="text"
value={fileSearchQuery}
onChange={(e) => handleFileSearchChange(e.target.value)}
placeholder={t('settings.sources.searchFiles')}
className={`w-full h-[38px] border border-[#D1D9E0] pl-10 pr-4 py-2 dark:border-[#6A6A6A]
${fileSearchQuery
? 'rounded-t-[6px]'
: 'rounded-[6px]'
}
bg-transparent focus:outline-none dark:text-[#E0E0E0] transition-all duration-200`}
className={`h-[38px] w-full border border-[#D1D9E0] py-2 pr-4 pl-10 dark:border-[#6A6A6A] ${
fileSearchQuery ? 'rounded-t-[6px]' : 'rounded-[6px]'
} bg-transparent transition-all duration-200 focus:outline-none dark:text-[#E0E0E0]`}
/>
</div>
{fileSearchQuery && (
<div className="absolute z-10 max-h-[calc(100vh-200px)] w-full overflow-hidden rounded-b-[6px] border border-t-0 border-[#D1D9E0] bg-white shadow-lg dark:border-[#6A6A6A] dark:bg-[#1F2023]">
<div className="max-h-[calc(100vh-200px)] overflow-y-auto overflow-x-hidden">
<div className="max-h-[calc(100vh-200px)] overflow-x-hidden overflow-y-auto">
{fileSearchResults.length === 0 ? (
<div className="py-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t('settings.sources.noResults')}
@@ -485,7 +513,7 @@ const Chunks: React.FC<ChunksProps> = ({
alt={result.isFile ? 'File' : 'Folder'}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="text-sm dark:text-[#E0E0E0] truncate">
<span className="truncate text-sm dark:text-[#E0E0E0]">
{result.path.split('/').pop() || result.path}
</span>
</div>
@@ -500,42 +528,39 @@ const Chunks: React.FC<ChunksProps> = ({
return (
<div className="flex flex-col">
<div className="mb-2">
{renderPathNavigation()}
</div>
<div className="mb-2">{renderPathNavigation()}</div>
<div className="flex gap-4">
{onFileSearch && onFileSelect && (
<div className="hidden lg:block w-[198px]">
{renderFileSearch()}
</div>
<div className="hidden w-[198px] lg:block">{renderFileSearch()}</div>
)}
{/* Right side: Chunks content */}
<div className="flex-1">
{!editingChunk && !isAddingChunk ? (
<>
<div className="mb-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="flex-1 w-full flex items-center border border-[#D1D9E0] dark:border-[#6A6A6A] rounded-md overflow-hidden h-[38px]">
<div className="px-4 flex items-center text-gray-700 dark:text-[#E0E0E0] font-medium whitespace-nowrap h-full">
<div className="mb-3 flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div className="flex h-[38px] w-full flex-1 items-center overflow-hidden rounded-md border border-[#D1D9E0] dark:border-[#6A6A6A]">
<div className="flex h-full items-center px-4 font-medium whitespace-nowrap text-gray-700 dark:text-[#E0E0E0]">
{totalChunks > 999999
? `${(totalChunks / 1000000).toFixed(2)}M`
: totalChunks > 999
? `${(totalChunks / 1000).toFixed(2)}K`
: totalChunks} {t('settings.sources.chunks')}
: totalChunks}{' '}
{t('settings.sources.chunks')}
</div>
<div className="h-full w-[1px] bg-[#D1D9E0] dark:bg-[#6A6A6A]"></div>
<div className="flex-1 h-full">
<div className="h-full flex-1">
<input
type="text"
placeholder={t('settings.sources.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full h-full px-3 py-2 bg-transparent border-none outline-none font-normal text-[13.56px] leading-[100%] dark:text-[#E0E0E0]"
className="h-full w-full border-none bg-transparent px-3 py-2 text-[13.56px] leading-[100%] font-normal outline-none dark:text-[#E0E0E0]"
/>
</div>
</div>
<button
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] w-full sm:w-auto min-w-[108px] items-center justify-center rounded-full px-4 text-[14px] whitespace-normal text-white shrink-0 font-medium"
className="bg-purple-30 hover:bg-violets-are-blue flex h-[38px] w-full min-w-[108px] shrink-0 items-center justify-center rounded-full px-4 text-[14px] font-medium whitespace-normal text-white sm:w-auto"
title={t('settings.sources.addChunk')}
onClick={() => {
setIsAddingChunk(true);
@@ -547,13 +572,13 @@ const Chunks: React.FC<ChunksProps> = ({
</button>
</div>
{loading ? (
<div className="w-full grid grid-cols-1 sm:[grid-template-columns:repeat(auto-fit,minmax(400px,1fr))] gap-4 justify-items-start">
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:[grid-template-columns:repeat(auto-fit,minmax(400px,1fr))]">
<SkeletonLoader component="chunkCards" count={perPage} />
</div>
) : (
<div className="w-full grid grid-cols-1 sm:[grid-template-columns:repeat(auto-fit,minmax(400px,1fr))] gap-4 justify-items-start">
<div className="grid w-full grid-cols-1 justify-items-start gap-4 sm:[grid-template-columns:repeat(auto-fit,minmax(400px,1fr))]">
{filteredChunks.length === 0 ? (
<div className="col-span-full w-full min-h-[50vh] flex flex-col items-center justify-center text-center text-gray-500 dark:text-gray-400">
<div className="col-span-full flex min-h-[50vh] w-full flex-col items-center justify-center text-center text-gray-500 dark:text-gray-400">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt={t('settings.sources.noChunksAlt')}
@@ -565,7 +590,7 @@ const Chunks: React.FC<ChunksProps> = ({
filteredChunks.map((chunk, index) => (
<div
key={index}
className="transform transition-transform duration-200 hover:scale-105 relative flex h-[197px] flex-col justify-between rounded-[5.86px] border border-[#D1D9E0] dark:border-[#6A6A6A] overflow-hidden cursor-pointer w-full max-w-[487px]"
className="relative flex h-[197px] w-full max-w-[487px] transform cursor-pointer flex-col justify-between overflow-hidden rounded-[5.86px] border border-[#D1D9E0] transition-transform duration-200 hover:scale-105 dark:border-[#6A6A6A]"
onClick={() => {
setEditingChunk(chunk);
setEditingTitle(chunk.metadata?.title || '');
@@ -573,13 +598,16 @@ const Chunks: React.FC<ChunksProps> = ({
}}
>
<div className="w-full">
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] dark:bg-[#27282D] dark:border-[#6A6A6A] px-4 py-3">
<div className="text-[#59636E] text-sm dark:text-[#E0E0E0]">
{chunk.metadata.token_count ? chunk.metadata.token_count.toLocaleString() : '-'} {t('settings.sources.tokensUnit')}
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] px-4 py-3 dark:border-[#6A6A6A] dark:bg-[#27282D]">
<div className="text-sm text-[#59636E] dark:text-[#E0E0E0]">
{chunk.metadata.token_count
? chunk.metadata.token_count.toLocaleString()
: '-'}{' '}
{t('settings.sources.tokensUnit')}
</div>
</div>
<div className="px-4 pt-3 pb-6">
<p className="font-['Inter'] text-[13.68px] leading-[19.93px] text-[#18181B] dark:text-[#E0E0E0] line-clamp-6 font-normal">
<p className="line-clamp-6 font-['Inter'] text-[13.68px] leading-[19.93px] font-normal text-[#18181B] dark:text-[#E0E0E0]">
{chunk.text}
</p>
</div>
@@ -592,7 +620,7 @@ const Chunks: React.FC<ChunksProps> = ({
</>
) : isAddingChunk ? (
<div className="w-full">
<div className="relative border border-[#D1D9E0] dark:border-[#6A6A6A] rounded-lg overflow-hidden">
<div className="relative overflow-hidden rounded-lg border border-[#D1D9E0] dark:border-[#6A6A6A]">
<LineNumberedTextarea
value={editingText}
onChange={setEditingText}
@@ -601,45 +629,53 @@ const Chunks: React.FC<ChunksProps> = ({
/>
</div>
</div>
) : editingChunk && (
<div className="w-full">
<div className="relative flex flex-col rounded-[5.86px] border border-[#D1D9E0] dark:border-[#6A6A6A] overflow-hidden w-full">
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] dark:bg-[#27282D] dark:border-[#6A6A6A] px-4 py-3">
<div className="text-[#59636E] text-sm dark:text-[#E0E0E0]">
{editingChunk.metadata.token_count ? editingChunk.metadata.token_count.toLocaleString() : '-'} {t('settings.sources.tokensUnit')}
) : (
editingChunk && (
<div className="w-full">
<div className="relative flex w-full flex-col overflow-hidden rounded-[5.86px] border border-[#D1D9E0] dark:border-[#6A6A6A]">
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] px-4 py-3 dark:border-[#6A6A6A] dark:bg-[#27282D]">
<div className="text-sm text-[#59636E] dark:text-[#E0E0E0]">
{editingChunk.metadata.token_count
? editingChunk.metadata.token_count.toLocaleString()
: '-'}{' '}
{t('settings.sources.tokensUnit')}
</div>
</div>
<div className="overflow-hidden p-4">
<LineNumberedTextarea
value={isEditing ? editingText : editingChunk.text}
onChange={setEditingText}
ariaLabel={t('modals.chunk.promptText')}
editable={isEditing}
onDoubleClick={() => {
if (!isEditing) {
setIsEditing(true);
setEditingTitle(editingChunk.metadata.title || '');
setEditingText(editingChunk.text);
}
}}
/>
</div>
</div>
<div className="p-4 overflow-hidden">
<LineNumberedTextarea
value={isEditing ? editingText : editingChunk.text}
onChange={setEditingText}
ariaLabel={t('modals.chunk.promptText')}
editable={isEditing}
onDoubleClick={() => {
if (!isEditing) {
setIsEditing(true);
setEditingTitle(editingChunk.metadata.title || '');
setEditingText(editingChunk.text);
}
}}
/>
</div>
</div>
</div>
)
)}
{!loading && totalChunks > perPage && !editingChunk && !isAddingChunk && (
<Pagination
currentPage={page}
totalPages={Math.ceil(totalChunks / perPage)}
rowsPerPage={perPage}
onPageChange={setPage}
onRowsPerPageChange={(rows) => {
setPerPage(rows);
setPage(1);
}}
/>
)}
{!loading &&
totalChunks > perPage &&
!editingChunk &&
!isAddingChunk && (
<Pagination
currentPage={page}
totalPages={Math.ceil(totalChunks / perPage)}
rowsPerPage={perPage}
onPageChange={setPage}
onRowsPerPageChange={(rows) => {
setPerPage(rows);
setPage(1);
}}
/>
)}
</div>
</div>

View File

@@ -111,15 +111,27 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
return (
<>
{errorMessage && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-[#E60000] dark:border-[#D42626] bg-transparent dark:bg-[#D426261A] p-2">
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.09974 24.5422H22.9C24.5156 24.5422 25.5228 22.7901 24.715 21.3947L16.8149 7.74526C16.007 6.34989 13.9927 6.34989 13.1848 7.74526L5.28471 21.3947C4.47686 22.7901 5.48405 24.5422 7.09974 24.5422ZM14.9998 17.1981C14.4228 17.1981 13.9507 16.726 13.9507 16.149V14.0507C13.9507 13.4736 14.4228 13.0015 14.9998 13.0015C15.5769 13.0015 16.049 13.4736 16.049 14.0507V16.149C16.049 16.726 15.5769 17.1981 14.9998 17.1981ZM16.049 21.3947H13.9507V19.2964H16.049V21.3947Z" fill={isDarkTheme ? '#EECF56' : '#E60000'} />
<div className="mb-4 flex items-center gap-2 rounded-lg border border-[#E60000] bg-transparent p-2 dark:border-[#D42626] dark:bg-[#D426261A]">
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.09974 24.5422H22.9C24.5156 24.5422 25.5228 22.7901 24.715 21.3947L16.8149 7.74526C16.007 6.34989 13.9927 6.34989 13.1848 7.74526L5.28471 21.3947C4.47686 22.7901 5.48405 24.5422 7.09974 24.5422ZM14.9998 17.1981C14.4228 17.1981 13.9507 16.726 13.9507 16.149V14.0507C13.9507 13.4736 14.4228 13.0015 14.9998 13.0015C15.5769 13.0015 16.049 13.4736 16.049 14.0507V16.149C16.049 16.726 15.5769 17.1981 14.9998 17.1981ZM16.049 21.3947H13.9507V19.2964H16.049V21.3947Z"
fill={isDarkTheme ? '#EECF56' : '#E60000'}
/>
</svg>
<span className='text-[#E60000] dark:text-[#E37064] text-sm' style={{
fontFamily: 'Inter',
lineHeight: '100%'
}}>
<span
className="text-sm text-[#E60000] dark:text-[#E37064]"
style={{
fontFamily: 'Inter',
lineHeight: '100%',
}}
>
{errorMessage}
</span>
</div>
@@ -127,17 +139,20 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
{isConnected ? (
<div className="mb-4">
<div className="w-full flex items-center justify-between rounded-[10px] bg-[#8FDD51] px-4 py-2 text-[#212121] font-medium text-sm">
<div className="flex w-full items-center justify-between rounded-[10px] bg-[#8FDD51] px-4 py-2 text-sm font-medium text-[#212121]">
<div className="flex items-center gap-2">
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
<path
fill="currentColor"
d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"
/>
</svg>
<span>Connected as {userEmail}</span>
</div>
{onDisconnect && (
<button
onClick={onDisconnect}
className="text-[#212121] hover:text-gray-700 font-medium text-xs underline"
className="text-xs font-medium text-[#212121] underline hover:text-gray-700"
>
Disconnect
</button>
@@ -162,4 +177,4 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
);
};
export default ConnectorAuth;
export default ConnectorAuth;

View File

@@ -76,7 +76,8 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
const [syncProgress, setSyncProgress] = useState<number>(0);
const [sourceProvider, setSourceProvider] = useState<string>('');
const [syncDone, setSyncDone] = useState<boolean>(false);
const [syncConfirmationModal, setSyncConfirmationModal] = useState<ActiveState>('INACTIVE');
const [syncConfirmationModal, setSyncConfirmationModal] =
useState<ActiveState>('INACTIVE');
useOutsideAlerter(
searchDropdownRef,
@@ -392,31 +393,26 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
const parentRow =
currentPath.length > 0
? [
<TableRow
key="parent-dir"
onClick={navigateUp}
>
<TableCell width="40%" align="left">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
..
</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
-
</TableCell>
<TableCell width="20%" align="left">
-
</TableCell>
<TableCell width="10%" align="right"></TableCell>
</TableRow>,
]
<TableRow key="parent-dir" onClick={navigateUp}>
<TableCell width="40%" align="left">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">..</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
-
</TableCell>
<TableCell width="20%" align="left">
-
</TableCell>
<TableCell width="10%" align="right"></TableCell>
</TableRow>,
]
: [];
// Sort entries: directories first, then files, both alphabetically
@@ -444,10 +440,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
return (
<TableRow
key={itemId}
onClick={() => navigateToDirectory(name)}
>
<TableRow key={itemId} onClick={() => navigateToDirectory(name)}>
<TableCell width="40%" align="left">
<div className="flex min-w-0 items-center">
<img
@@ -455,9 +448,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
alt={t('settings.sources.folderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
{name}
</span>
<span className="truncate">{name}</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
@@ -472,7 +463,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
<div ref={menuRef} className="relative">
<button
onClick={(e) => handleMenuClick(e, itemId)}
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E] font-medium"
className="inline-flex h-[35px] w-[24px] shrink-0 items-center justify-center rounded-md font-medium transition-colors hover:bg-[#EBEBEB] dark:hover:bg-[#26272E]"
aria-label={t('settings.sources.menuAlt')}
>
<img
@@ -505,10 +496,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
const menuRef = getMenuRef(itemId);
return (
<TableRow
key={itemId}
onClick={() => handleFileClick(name)}
>
<TableRow key={itemId} onClick={() => handleFileClick(name)}>
<TableCell width="40%" align="left">
<div className="flex min-w-0 items-center">
<img
@@ -516,9 +504,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
alt={t('settings.sources.fileAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
{name}
</span>
<span className="truncate">{name}</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
@@ -730,9 +716,7 @@ const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{renderFileTree(getCurrentDirectory())}
</TableBody>
<TableBody>{renderFileTree(getCurrentDirectory())}</TableBody>
</Table>
</TableContainer>
</div>

View File

@@ -61,12 +61,12 @@ const Pagination: React.FC<PaginationProps> = ({
<div className="relative">
<button
onClick={toggleDropdown}
className="rounded border px-3 py-1 hover:bg-gray-200 dark:bg-dark-charcoal dark:text-light-gray dark:hover:bg-neutral-700"
className="dark:bg-dark-charcoal dark:text-light-gray rounded border px-3 py-1 hover:bg-gray-200 dark:hover:bg-neutral-700"
>
{rowsPerPage}
</button>
<div
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 ${
className={`ring-opacity-5 dark:bg-dark-charcoal absolute right-0 z-50 mt-1 w-28 transform bg-white shadow-lg ring-1 ring-black transition-all duration-200 ease-in-out ${
isDropdownOpen
? 'block scale-100 opacity-100'
: 'hidden scale-95 opacity-0'
@@ -78,8 +78,8 @@ const Pagination: React.FC<PaginationProps> = ({
onClick={() => handleSelectRowsPerPage(option)}
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'
? 'dark:text-light-gray bg-gray-100 dark:bg-neutral-700'
: 'dark:bg-dark-charcoal dark:text-light-gray bg-white'
}`}
>
{option}

View File

@@ -88,7 +88,7 @@ export default function DropdownMenu({
onClick={(e) => e.stopPropagation()}
>
<div
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}`}
className={`ring-opacity-5 dark:bg-dark-charcoal w-28 transform rounded-md bg-white shadow-lg ring-1 ring-black transition-all duration-200 ease-in-out ${className}`}
>
<div
role="menu"
@@ -99,10 +99,10 @@ export default function DropdownMenu({
{options.map((option, idx) => (
<div
id={`option-${idx}`}
className={`cursor-pointer px-4 py-2 text-xs hover:bg-gray-100 dark:text-light-gray dark:hover:bg-purple-taupe ${
className={`dark:text-light-gray dark:hover:bg-purple-taupe cursor-pointer px-4 py-2 text-xs hover:bg-gray-100 ${
selectedOption.value === option.value
? 'bg-gray-100 dark:bg-purple-taupe'
: 'bg-white dark:bg-dark-charcoal'
? 'dark:bg-purple-taupe bg-gray-100'
: 'dark:bg-dark-charcoal bg-white'
}`}
role="menuitem"
key={option.value}

View File

@@ -1,7 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { formatBytes } from '../utils/stringUtils';
import { formatDate } from '../utils/dateTimeUtils';
import { getSessionToken, setSessionToken, removeSessionToken } from '../utils/providerUtils';
import {
getSessionToken,
setSessionToken,
removeSessionToken,
} from '../utils/providerUtils';
import ConnectorAuth from '../components/ConnectorAuth';
import FileIcon from '../assets/file.svg';
import FolderIcon from '../assets/folder.svg';
@@ -28,7 +32,10 @@ interface CloudFile {
}
interface CloudFilePickerProps {
onSelectionChange: (selectedFileIds: string[], selectedFolderIds?: string[]) => void;
onSelectionChange: (
selectedFileIds: string[],
selectedFolderIds?: string[],
) => void;
onDisconnect?: () => void;
provider: string;
token: string | null;
@@ -51,23 +58,30 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
} as const;
const getProviderConfig = (provider: string) => {
return PROVIDER_CONFIG[provider as keyof typeof PROVIDER_CONFIG] || {
displayName: provider,
rootName: 'Root',
};
return (
PROVIDER_CONFIG[provider as keyof typeof PROVIDER_CONFIG] || {
displayName: provider,
rootName: 'Root',
}
);
};
const [files, setFiles] = useState<CloudFile[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>(initialSelectedFiles);
const [selectedFiles, setSelectedFiles] =
useState<string[]>(initialSelectedFiles);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasMoreFiles, setHasMoreFiles] = useState(false);
const [nextPageToken, setNextPageToken] = useState<string | null>(null);
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
const [folderPath, setFolderPath] = useState<Array<{ id: string | null, name: string }>>([{
id: null,
name: getProviderConfig(provider).rootName
}]);
const [folderPath, setFolderPath] = useState<
Array<{ id: string | null; name: string }>
>([
{
id: null,
name: getProviderConfig(provider).rootName,
},
]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [authError, setAuthError] = useState<string>('');
const [isConnected, setIsConnected] = useState(false);
@@ -77,9 +91,11 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFolder = (file: CloudFile) => {
return file.isFolder ||
return (
file.isFolder ||
file.type === 'application/vnd.google-apps.folder' ||
file.type === 'folder';
file.type === 'folder'
);
};
const loadCloudFiles = useCallback(
@@ -87,7 +103,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
sessionToken: string,
folderId: string | null,
pageToken?: string,
searchQuery: string = ''
searchQuery = '',
) => {
setIsLoading(true);
@@ -101,7 +117,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: provider,
@@ -109,13 +125,15 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
folder_id: folderId,
limit: 10,
page_token: pageToken,
search_query: searchQuery
})
search_query: searchQuery,
}),
});
const data = await response.json();
if (data.success) {
setFiles(prev => pageToken ? [...prev, ...data.files] : data.files);
setFiles((prev) =>
pageToken ? [...prev, ...data.files] : data.files,
);
setNextPageToken(data.next_page_token);
setHasMoreFiles(!!data.next_page_token);
} else {
@@ -133,7 +151,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
setIsLoading(false);
}
},
[token, provider]
[token, provider],
);
const validateAndLoadFiles = useCallback(async () => {
@@ -145,14 +163,20 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
try {
const apiHost = import.meta.env.VITE_API_HOST;
const validateResponse = await fetch(`${apiHost}/api/connectors/validate-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
const validateResponse = await fetch(
`${apiHost}/api/connectors/validate-session`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: provider,
session_token: sessionToken,
}),
},
body: JSON.stringify({ provider: provider, session_token: sessionToken })
});
);
if (!validateResponse.ok) {
removeSessionToken(provider);
@@ -171,14 +195,20 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
setNextPageToken(null);
setHasMoreFiles(false);
setCurrentFolderId(null);
setFolderPath([{
id: null, name: getProviderConfig(provider).rootName
}]);
setFolderPath([
{
id: null,
name: getProviderConfig(provider).rootName,
},
]);
loadCloudFiles(sessionToken, null, undefined, '');
} else {
removeSessionToken(provider);
setIsConnected(false);
setAuthError(validateData.error || 'Session expired. Please reconnect your account.');
setAuthError(
validateData.error ||
'Session expired. Please reconnect your account.',
);
}
} catch (error) {
console.error('Error validating session:', error);
@@ -201,10 +231,23 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
if (isNearBottom && hasMoreFiles && !isLoading && nextPageToken) {
const sessionToken = getSessionToken(provider);
if (sessionToken) {
loadCloudFiles(sessionToken, currentFolderId, nextPageToken, searchQuery);
loadCloudFiles(
sessionToken,
currentFolderId,
nextPageToken,
searchQuery,
);
}
}
}, [hasMoreFiles, isLoading, nextPageToken, currentFolderId, searchQuery, provider, loadCloudFiles]);
}, [
hasMoreFiles,
isLoading,
nextPageToken,
currentFolderId,
searchQuery,
provider,
loadCloudFiles,
]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@@ -245,7 +288,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
setIsLoading(true);
setCurrentFolderId(folderId);
setFolderPath(prev => [...prev, { id: folderId, name: folderName }]);
setFolderPath((prev) => [...prev, { id: folderId, name: folderName }]);
setSearchQuery('');
const sessionToken = getSessionToken(provider);
@@ -273,13 +316,13 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
const handleFileSelect = (fileId: string, isFolder: boolean) => {
if (isFolder) {
const newSelectedFolders = selectedFolders.includes(fileId)
? selectedFolders.filter(id => id !== fileId)
? selectedFolders.filter((id) => id !== fileId)
: [...selectedFolders, fileId];
setSelectedFolders(newSelectedFolders);
onSelectionChange(selectedFiles, newSelectedFolders);
} else {
const newSelectedFiles = selectedFiles.includes(fileId)
? selectedFiles.filter(id => id !== fileId)
? selectedFiles.filter((id) => id !== fileId)
: [...selectedFiles, fileId];
setSelectedFiles(newSelectedFiles);
onSelectionChange(newSelectedFiles, selectedFolders);
@@ -287,11 +330,11 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
};
return (
<div className=''>
<div className="">
{authError && (
<div className="text-red-500 text-sm mb-4 text-center">{authError}</div>
<div className="mb-4 text-center text-sm text-red-500">{authError}</div>
)}
<ConnectorAuth
provider={provider}
onSuccess={(data) => {
@@ -318,10 +361,18 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ provider: provider, session_token: sessionToken })
}).catch(err => console.error(`Error disconnecting from ${getProviderConfig(provider).displayName}:`, err));
body: JSON.stringify({
provider: provider,
session_token: sessionToken,
}),
}).catch((err) =>
console.error(
`Error disconnecting from ${getProviderConfig(provider).displayName}:`,
err,
),
);
}
removeSessionToken(provider);
@@ -337,13 +388,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
/>
{isConnected && (
<div className="border border-[#D7D7D7] rounded-lg dark:border-[#6A6A6A] mt-3">
<div className="border-[#EEE6FF78] dark:border-[#6A6A6A] rounded-t-lg">
<div className="mt-3 rounded-lg border border-[#D7D7D7] dark:border-[#6A6A6A]">
<div className="rounded-t-lg border-[#EEE6FF78] dark:border-[#6A6A6A]">
{/* Breadcrumb navigation */}
<div className="px-4 pt-4 bg-[#EEE6FF78] dark:bg-[#2A262E] rounded-t-lg">
<div className="flex items-center gap-1 mb-2">
<div className="rounded-t-lg bg-[#EEE6FF78] px-4 pt-4 dark:bg-[#2A262E]">
<div className="mb-2 flex items-center gap-1">
{folderPath.map((path, index) => (
<div key={path.id || 'root'} className="flex items-center gap-1">
<div
key={path.id || 'root'}
className="flex items-center gap-1"
>
{index > 0 && <span className="text-gray-400">/</span>}
<button
onClick={() => navigateBack(index)}
@@ -369,7 +423,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
colorVariant="silver"
borderVariant="thin"
labelBgClassName="bg-[#EEE6FF78] dark:bg-[#2A262E]"
leftIcon={<img src={SearchIcon} alt="Search" width={16} height={16} />}
leftIcon={
<img src={SearchIcon} alt="Search" width={16} height={16} />
}
/>
</div>
@@ -386,7 +442,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
className="scrollbar-thin md:w-4xl lg:w-5xl"
bordered={false}
>
{(
{
<>
<Table minWidth="1200px">
<TableHead>
@@ -411,13 +467,16 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
>
<TableCell width="40px" align="center">
<div
className="flex h-5 w-5 text-sm shrink-0 items-center justify-center border border-[#EEE6FF78] p-[0.5px] dark:border-[#6A6A6A] cursor-pointer mx-auto"
className="mx-auto flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center border border-[#EEE6FF78] p-[0.5px] text-sm dark:border-[#6A6A6A]"
onClick={(e) => {
e.stopPropagation();
handleFileSelect(file.id, isFolder(file));
}}
>
{(isFolder(file) ? selectedFolders : selectedFiles).includes(file.id) && (
{(isFolder(file)
? selectedFolders
: selectedFiles
).includes(file.id) && (
<img
src={CheckIcon}
alt="Selected"
@@ -427,21 +486,21 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3 min-w-0">
<div className="flex min-w-0 items-center gap-3">
<div className="flex-shrink-0">
<img
src={isFolder(file) ? FolderIcon : FileIcon}
alt={isFolder(file) ? "Folder" : "File"}
alt={isFolder(file) ? 'Folder' : 'File'}
className="h-6 w-6"
/>
</div>
<span className="truncate">{file.name}</span>
</div>
</TableCell>
<TableCell className='text-xs'>
<TableCell className="text-xs">
{formatDate(file.modifiedTime)}
</TableCell>
<TableCell className='text-xs'>
<TableCell className="text-xs">
{file.size ? formatBytes(file.size) : '-'}
</TableCell>
</TableRow>
@@ -450,7 +509,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</Table>
{isLoading && (
<div className="flex items-center justify-center p-4 border-t border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="flex items-center justify-center border-t border-[#EEE6FF78] p-4 dark:border-[#6A6A6A]">
<div className="inline-flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
Loading more files...
@@ -458,7 +517,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</div>
)}
</>
)}
}
</TableContainer>
</div>
</div>

View File

@@ -542,31 +542,26 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const parentRow =
currentPath.length > 0
? [
<TableRow
key="parent-dir"
onClick={navigateUp}
>
<TableCell width="40%" align="left">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
..
</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
-
</TableCell>
<TableCell width="20%" align="right">
-
</TableCell>
<TableCell width="10%" align="right"></TableCell>
</TableRow>,
]
<TableRow key="parent-dir" onClick={navigateUp}>
<TableCell width="40%" align="left">
<div className="flex items-center">
<img
src={FolderIcon}
alt={t('settings.sources.parentFolderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">..</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
-
</TableCell>
<TableCell width="20%" align="right">
-
</TableCell>
<TableCell width="10%" align="right"></TableCell>
</TableRow>,
]
: [];
// Render directories first, then files
@@ -578,10 +573,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const dirStats = calculateDirectoryStats(node as DirectoryStructure);
return (
<TableRow
key={itemId}
onClick={() => navigateToDirectory(name)}
>
<TableRow key={itemId} onClick={() => navigateToDirectory(name)}>
<TableCell width="40%" align="left">
<div className="flex min-w-0 items-center">
<img
@@ -589,9 +581,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
alt={t('settings.sources.folderAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
{name}
</span>
<span className="truncate">{name}</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
@@ -635,10 +625,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
const menuRef = getMenuRef(itemId);
return (
<TableRow
key={itemId}
onClick={() => handleFileClick(name)}
>
<TableRow key={itemId} onClick={() => handleFileClick(name)}>
<TableCell width="40%" align="left">
<div className="flex min-w-0 items-center">
<img
@@ -646,9 +633,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
alt={t('settings.sources.fileAlt')}
className="mr-2 h-4 w-4 flex-shrink-0"
/>
<span className="truncate">
{name}
</span>
<span className="truncate">{name}</span>
</div>
</TableCell>
<TableCell width="30%" align="left">
@@ -854,9 +839,7 @@ const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{renderFileTree(currentDirectory)}
</TableBody>
<TableBody>{renderFileTree(currentDirectory)}</TableBody>
</Table>
</TableContainer>
</div>

View File

@@ -159,7 +159,7 @@ export const FileUpload = ({
e.stopPropagation();
handleRemove();
}}
className="absolute -right-2 -top-2 rounded-full bg-[#7D54D1] p-1 transition-colors hover:bg-[#714cbc]"
className="absolute -top-2 -right-2 rounded-full bg-[#7D54D1] p-1 transition-colors hover:bg-[#714cbc]"
>
<img src={Cross} alt="remove" className="h-3 w-3" />
</button>
@@ -215,7 +215,7 @@ export const FileUpload = ({
<input {...getInputProps()} />
{children || defaultContent}
{errors.length > 0 && (
<div className="absolute left-0 right-0 mt-[2px] px-4 text-xs text-red-600">
<div className="absolute right-0 left-0 mt-[2px] px-4 text-xs text-red-600">
{errors.map((error, i) => (
<p key={i} className="truncate">
{error}

View File

@@ -2,8 +2,11 @@ import React, { useState, useEffect } from 'react';
import useDrivePicker from 'react-google-drive-picker';
import ConnectorAuth from './ConnectorAuth';
import { getSessionToken, setSessionToken, removeSessionToken } from '../utils/providerUtils';
import {
getSessionToken,
setSessionToken,
removeSessionToken,
} from '../utils/providerUtils';
interface PickerFile {
id: string;
@@ -31,9 +34,9 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const [authError, setAuthError] = useState<string>('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [openPicker] = useDrivePicker();
useEffect(() => {
const sessionToken = getSessionToken('google_drive');
if (sessionToken) {
@@ -46,14 +49,20 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const validateSession = async (sessionToken: string) => {
try {
const apiHost = import.meta.env.VITE_API_HOST;
const validateResponse = await fetch(`${apiHost}/api/connectors/validate-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
const validateResponse = await fetch(
`${apiHost}/api/connectors/validate-session`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: 'google_drive',
session_token: sessionToken,
}),
},
body: JSON.stringify({ provider: 'google_drive', session_token: sessionToken })
});
);
if (!validateResponse.ok) {
setIsConnected(false);
@@ -72,7 +81,10 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
return true;
} else {
setIsConnected(false);
setAuthError(validateData.error || 'Session expired. Please reconnect your account.');
setAuthError(
validateData.error ||
'Session expired. Please reconnect your account.',
);
setIsValidating(false);
return false;
}
@@ -87,21 +99,23 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const handleOpenPicker = async () => {
setIsLoading(true);
const sessionToken = getSessionToken('google_drive');
if (!sessionToken) {
setAuthError('No valid session found. Please reconnect to Google Drive.');
setIsLoading(false);
return;
}
if (!accessToken) {
setAuthError('No access token available. Please reconnect to Google Drive.');
setAuthError(
'No access token available. Please reconnect to Google Drive.',
);
setIsLoading(false);
return;
}
try {
const clientId: string = import.meta.env.VITE_GOOGLE_CLIENT_ID;
@@ -117,17 +131,18 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
openPicker({
clientId: clientId,
developerKey: "",
developerKey: '',
appId: appId,
setSelectFolderEnabled: false,
viewId: "DOCS",
viewId: 'DOCS',
showUploadView: false,
showUploadFolders: false,
supportDrives: false,
multiselect: true,
token: accessToken,
viewMimeTypes: 'application/vnd.google-apps.document,application/vnd.google-apps.presentation,application/vnd.google-apps.spreadsheet,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.ms-powerpoint,application/vnd.ms-excel,text/plain,text/csv,text/html,text/markdown,text/x-rst,application/json,application/epub+zip,application/rtf,image/jpeg,image/jpg,image/png',
callbackFunction: (data:any) => {
viewMimeTypes:
'application/vnd.google-apps.document,application/vnd.google-apps.presentation,application/vnd.google-apps.spreadsheet,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.presentationml.presentation,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.ms-powerpoint,application/vnd.ms-excel,text/plain,text/csv,text/html,text/markdown,text/x-rst,application/json,application/epub+zip,application/rtf,image/jpeg,image/jpg,image/png',
callbackFunction: (data: any) => {
setIsLoading(false);
if (data.action === 'picked') {
const docs = data.docs;
@@ -136,14 +151,14 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const newFolders: PickerFile[] = [];
docs.forEach((doc: any) => {
const item = {
id: doc.id,
name: doc.name,
mimeType: doc.mimeType,
iconUrl: doc.iconUrl || '',
description: doc.description,
sizeBytes: doc.sizeBytes
};
const item = {
id: doc.id,
name: doc.name,
mimeType: doc.mimeType,
iconUrl: doc.iconUrl || '',
description: doc.description,
sizeBytes: doc.sizeBytes,
};
if (doc.mimeType === 'application/vnd.google-apps.folder') {
newFolders.push(item);
@@ -152,20 +167,26 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
}
});
setSelectedFiles(prevFiles => {
const existingFileIds = new Set(prevFiles.map(file => file.id));
const uniqueNewFiles = newFiles.filter(file => !existingFileIds.has(file.id));
setSelectedFiles((prevFiles) => {
const existingFileIds = new Set(prevFiles.map((file) => file.id));
const uniqueNewFiles = newFiles.filter(
(file) => !existingFileIds.has(file.id),
);
return [...prevFiles, ...uniqueNewFiles];
});
setSelectedFolders(prevFolders => {
const existingFolderIds = new Set(prevFolders.map(folder => folder.id));
const uniqueNewFolders = newFolders.filter(folder => !existingFolderIds.has(folder.id));
setSelectedFolders((prevFolders) => {
const existingFolderIds = new Set(
prevFolders.map((folder) => folder.id),
);
const uniqueNewFolders = newFolders.filter(
(folder) => !existingFolderIds.has(folder.id),
);
return [...prevFolders, ...uniqueNewFolders];
});
onSelectionChange(
[...selectedFiles, ...newFiles].map(file => file.id),
[...selectedFolders, ...newFolders].map(folder => folder.id)
[...selectedFiles, ...newFiles].map((file) => file.id),
[...selectedFolders, ...newFolders].map((folder) => folder.id),
);
}
},
@@ -186,9 +207,12 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ provider: 'google_drive', session_token: sessionToken })
body: JSON.stringify({
provider: 'google_drive',
session_token: sessionToken,
}),
});
} catch (err) {
console.error('Error disconnecting from Google Drive:', err);
@@ -207,24 +231,24 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
const ConnectedStateSkeleton = () => (
<div className="mb-4">
<div className="w-full flex items-center justify-between rounded-[10px] bg-gray-200 dark:bg-gray-700 px-4 py-2 animate-pulse">
<div className="flex w-full animate-pulse items-center justify-between rounded-[10px] bg-gray-200 px-4 py-2 dark:bg-gray-700">
<div className="flex items-center gap-2">
<div className="h-4 w-4 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-4 w-32 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-4 w-4 rounded bg-gray-300 dark:bg-gray-600"></div>
<div className="h-4 w-32 rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
<div className="h-4 w-16 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-4 w-16 rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
</div>
);
const FilesSectionSkeleton = () => (
<div className="border border-[#EEE6FF78] rounded-lg dark:border-[#6A6A6A]">
<div className="rounded-lg border border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<div className="h-5 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div className="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div className="mb-4 flex items-center justify-between">
<div className="h-5 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-8 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div className="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
);
@@ -262,13 +286,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
/>
{isConnected && (
<div className="border border-[#EEE6FF78] rounded-lg dark:border-[#6A6A6A]">
<div className="rounded-lg border border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium">Selected Files</h3>
<button
onClick={() => handleOpenPicker()}
className="bg-[#A076F6] hover:bg-[#8A5FD4] text-white text-sm py-1 px-3 rounded-md"
className="rounded-md bg-[#A076F6] px-3 py-1 text-sm text-white hover:bg-[#8A5FD4]"
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Select Files'}
@@ -276,26 +300,42 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
</div>
{selectedFiles.length === 0 && selectedFolders.length === 0 ? (
<p className="text-gray-600 dark:text-gray-400 text-sm">No files or folders selected</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
No files or folders selected
</p>
) : (
<div className="max-h-60 overflow-y-auto">
{selectedFolders.length > 0 && (
<div className="mb-2">
<h4 className="text-xs font-medium text-gray-500 mb-1">Folders</h4>
<h4 className="mb-1 text-xs font-medium text-gray-500">
Folders
</h4>
{selectedFolders.map((folder) => (
<div key={folder.id} className="flex items-center p-2 border-b border-gray-200 dark:border-gray-700">
<img src={folder.iconUrl} alt="Folder" className="w-5 h-5 mr-2" />
<span className="text-sm truncate flex-1">{folder.name}</span>
<div
key={folder.id}
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
>
<img
src={folder.iconUrl}
alt="Folder"
className="mr-2 h-5 w-5"
/>
<span className="flex-1 truncate text-sm">
{folder.name}
</span>
<button
onClick={() => {
const newSelectedFolders = selectedFolders.filter(f => f.id !== folder.id);
const newSelectedFolders =
selectedFolders.filter(
(f) => f.id !== folder.id,
);
setSelectedFolders(newSelectedFolders);
onSelectionChange(
selectedFiles.map(f => f.id),
newSelectedFolders.map(f => f.id)
selectedFiles.map((f) => f.id),
newSelectedFolders.map((f) => f.id),
);
}}
className="text-red-500 hover:text-red-700 text-sm ml-2"
className="ml-2 text-sm text-red-500 hover:text-red-700"
>
Remove
</button>
@@ -306,21 +346,34 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
{selectedFiles.length > 0 && (
<div>
<h4 className="text-xs font-medium text-gray-500 mb-1">Files</h4>
<h4 className="mb-1 text-xs font-medium text-gray-500">
Files
</h4>
{selectedFiles.map((file) => (
<div key={file.id} className="flex items-center p-2 border-b border-gray-200 dark:border-gray-700">
<img src={file.iconUrl} alt="File" className="w-5 h-5 mr-2" />
<span className="text-sm truncate flex-1">{file.name}</span>
<div
key={file.id}
className="flex items-center border-b border-gray-200 p-2 dark:border-gray-700"
>
<img
src={file.iconUrl}
alt="File"
className="mr-2 h-5 w-5"
/>
<span className="flex-1 truncate text-sm">
{file.name}
</span>
<button
onClick={() => {
const newSelectedFiles = selectedFiles.filter(f => f.id !== file.id);
const newSelectedFiles = selectedFiles.filter(
(f) => f.id !== file.id,
);
setSelectedFiles(newSelectedFiles);
onSelectionChange(
newSelectedFiles.map(f => f.id),
selectedFolders.map(f => f.id)
newSelectedFiles.map((f) => f.id),
selectedFolders.map((f) => f.id),
);
}}
className="text-red-500 hover:text-red-700 text-sm ml-2"
className="ml-2 text-sm text-red-500 hover:text-red-700"
>
Remove
</button>

View File

@@ -59,7 +59,7 @@ const Input = ({
{children}
</input>
{leftIcon && (
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 flex items-center justify-center">
<div className="absolute top-1/2 left-3 flex -translate-y-1/2 transform items-center justify-center">
{leftIcon}
</div>
)}
@@ -69,7 +69,9 @@ const Input = ({
className={`absolute select-none ${
hasValue ? '-top-2.5 left-3 text-xs' : ''
} px-2 transition-all peer-placeholder-shown:top-2.5 ${
leftIcon ? 'peer-placeholder-shown:left-7' : 'peer-placeholder-shown:left-3'
leftIcon
? 'peer-placeholder-shown:left-7'
: 'peer-placeholder-shown:left-3'
} peer-placeholder-shown:${
textSizeStyles[textSize]
} text-gray-4000 pointer-events-none cursor-none peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs dark:text-gray-400 ${labelBgClassName} max-w-[calc(100%-24px)] overflow-hidden text-ellipsis whitespace-nowrap`}

View File

@@ -15,7 +15,7 @@ export default function ShareButton({ conversationId }: ShareButtonProps) {
onClick={() => {
setShareModalState(true);
}}
className="absolute right-20 top-4 z-20 rounded-full hover:bg-bright-gray dark:hover:bg-[#28292E]"
className="hover:bg-bright-gray absolute top-4 right-20 z-20 rounded-full dark:hover:bg-[#28292E]"
>
<img
className="m-2 h-5 w-5 filter dark:invert"

View File

@@ -189,19 +189,19 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{Array.from({ length: count }).map((_, index) => (
<div
key={`chunk-skel-${index}`}
className="relative flex h-[197px] flex-col rounded-[5.86px] border border-[#D1D9E0] dark:border-[#6A6A6A] overflow-hidden w-full max-w-[487px] animate-pulse"
className="relative flex h-[197px] w-full max-w-[487px] animate-pulse flex-col overflow-hidden rounded-[5.86px] border border-[#D1D9E0] dark:border-[#6A6A6A]"
>
<div className="w-full">
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] dark:bg-[#27282D] dark:border-[#6A6A6A] px-4 py-3">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-20"></div>
<div className="flex w-full items-center justify-between border-b border-[#D1D9E0] bg-[#F6F8FA] px-4 py-3 dark:border-[#6A6A6A] dark:bg-[#27282D]">
<div className="h-4 w-20 rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
<div className="px-4 pt-4 pb-6 space-y-3">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-full"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-11/12"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-4/5"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
<div className="space-y-3 px-4 pt-4 pb-6">
<div className="h-3 w-full rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-11/12 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-5/6 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-4/5 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-3/4 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-2/3 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
</div>
@@ -214,24 +214,24 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{Array.from({ length: count }).map((_, idx) => (
<div
key={`source-skel-${idx}`}
className="flex h-[130px] w-full flex-col rounded-2xl bg-[#F9F9F9] dark:bg-[#383838] p-3 animate-pulse"
className="flex h-[130px] w-full animate-pulse flex-col rounded-2xl bg-[#F9F9F9] p-3 dark:bg-[#383838]"
>
<div className="w-full flex-1">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex-1">
<div className="h-[13px] w-full rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="w-6 h-6 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-6 w-6 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div className="flex flex-col items-start justify-start gap-1 pt-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="mb-1 flex items-center gap-2">
<div className="h-3 w-3 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-[12px] w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-3 w-3 rounded bg-gray-200 dark:bg-gray-700"></div>
<div className="h-[12px] w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
@@ -251,7 +251,6 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
sourceCards: renderSourceCards,
};
const render = componentMap[component] || componentMap.default;
return <>{render()}</>;

View File

@@ -6,11 +6,9 @@ interface TableProps {
minWidth?: string;
}
interface TableContainerProps {
children: React.ReactNode;
className?: string;
ref?: React.Ref<HTMLDivElement>;
height?: string;
bordered?: boolean;
}
@@ -34,45 +32,51 @@ interface TableCellProps {
align?: 'left' | 'right' | 'center';
}
const TableContainer = React.forwardRef<HTMLDivElement, TableContainerProps>(({
children,
className = '',
height = 'auto',
bordered = true
}, ref) => {
return (
<div className={`relative rounded-[6px] ${className}`}>
<div
ref={ref}
className={`w-full overflow-x-auto rounded-[6px] bg-transparent ${bordered ? 'border border-[#D7D7D7] dark:border-[#6A6A6A]' : ''}`}
style={{
maxHeight: height === 'auto' ? undefined : height,
overflowY: height === 'auto' ? 'hidden' : 'auto'
}}
>
{children}
const TableContainer = React.forwardRef<HTMLDivElement, TableContainerProps>(
function TableContainer(
{
children,
className = '',
height = 'auto',
bordered = true,
}: TableContainerProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
return (
<div className={`relative rounded-[6px] ${className}`}>
<div
ref={ref}
className={`w-full overflow-x-auto rounded-[6px] bg-transparent ${bordered ? 'border border-[#D7D7D7] dark:border-[#6A6A6A]' : ''}`}
style={{
maxHeight: height === 'auto' ? undefined : height,
overflowY: height === 'auto' ? 'hidden' : 'auto',
}}
>
{children}
</div>
</div>
</div>
);
});;
);
},
);
const Table: React.FC<TableProps> = ({
children,
className = '',
minWidth = 'min-w-[600px]'
minWidth = 'min-w-[600px]',
}) => {
return (
<table className={`w-full table-auto border-collapse bg-transparent ${minWidth} ${className}`}>
<table
className={`w-full table-auto border-collapse bg-transparent ${minWidth} ${className}`}
>
{children}
</table>
);
};
const TableHead: React.FC<TableHeadProps> = ({ children, className = '' }) => {
return (
<thead className={`
sticky top-0 z-10
bg-gray-100 dark:bg-[#27282D]
${className}
`}>
<thead
className={`sticky top-0 z-10 bg-gray-100 dark:bg-[#27282D] ${className} `}
>
{children}
</thead>
);
@@ -86,12 +90,20 @@ const TableBody: React.FC<TableHeadProps> = ({ children, className = '' }) => {
);
};
const TableRow: React.FC<TableRowProps> = ({ children, className = '', onClick }) => {
const baseClasses = "border-b border-[#D7D7D7] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]";
const cursorClass = onClick ? "cursor-pointer" : "";
const TableRow: React.FC<TableRowProps> = ({
children,
className = '',
onClick,
}) => {
const baseClasses =
'border-b border-[#D7D7D7] hover:bg-[#ECEEEF] dark:border-[#6A6A6A] dark:hover:bg-[#27282D]';
const cursorClass = onClick ? 'cursor-pointer' : '';
return (
<tr className={`${baseClasses} ${cursorClass} ${className}`} onClick={onClick}>
<tr
className={`${baseClasses} ${cursorClass} ${className}`}
onClick={onClick}
>
{children}
</tr>
);
@@ -102,7 +114,7 @@ const TableHeader: React.FC<TableCellProps> = ({
className = '',
minWidth,
width,
align = 'left'
align = 'left',
}) => {
const getAlignmentClass = () => {
switch (align) {
@@ -133,7 +145,7 @@ const TableCell: React.FC<TableCellProps> = ({
className = '',
minWidth,
width,
align = 'left'
align = 'left',
}) => {
const getAlignmentClass = () => {
switch (align) {

View File

@@ -68,7 +68,7 @@ export default function SpeakButton({
<div
className={`flex items-center justify-center rounded-full p-2 ${
isSpeakHovered
? `bg-[#EEEEEE] dark:bg-purple-taupe`
? `dark:bg-purple-taupe bg-[#EEEEEE]`
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
}`}
>

View File

@@ -157,7 +157,7 @@ export default function ConversationMessages({
if (query.error) {
const retryButton = (
<button
className="flex items-center justify-center gap-3 self-center rounded-full px-5 py-3 text-lg text-gray-500 transition-colors delay-100 hover:border-gray-500 disabled:cursor-not-allowed dark:text-bright-gray"
className="dark:text-bright-gray flex items-center justify-center gap-3 self-center rounded-full px-5 py-3 text-lg text-gray-500 transition-colors delay-100 hover:border-gray-500 disabled:cursor-not-allowed"
disabled={status === 'loading'}
onClick={() => {
const questionToRetry = queries[index].prompt;
@@ -199,12 +199,12 @@ export default function ConversationMessages({
scrollConversationToBottom();
}}
aria-label={t('Scroll to bottom') || 'Scroll to bottom'}
className="fixed bottom-40 right-14 z-10 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-gray-alpha bg-gray-100 bg-opacity-50 dark:bg-gunmetal md:h-9 md:w-9 md:bg-opacity-100"
className="border-gray-alpha bg-opacity-50 dark:bg-gunmetal md:bg-opacity-100 fixed right-14 bottom-40 z-10 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] bg-gray-100 md:h-9 md:w-9"
>
<img
src={ArrowDown}
alt="arrow down"
className="h-4 w-4 opacity-50 filter dark:invert md:h-5 md:w-5"
className="h-4 w-4 opacity-50 filter md:h-5 md:w-5 dark:invert"
/>
</button>
)}

View File

@@ -43,7 +43,7 @@ export default function AddActionModal({
className="sm:w-[512px]"
>
<div>
<h2 className="px-3 text-xl font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray px-3 text-xl font-semibold">
New Action
</h2>
<div className="relative mt-6 px-3">
@@ -61,7 +61,7 @@ export default function AddActionModal({
required={true}
/>
<p
className={`ml-1 mt-2 text-xs italic ${
className={`mt-2 ml-1 text-xs italic ${
functionNameError ? 'text-red-500' : 'text-gray-500'
}`}
>
@@ -73,7 +73,7 @@ export default function AddActionModal({
<div className="mt-3 flex flex-row-reverse gap-1 px-3">
<button
onClick={handleAddAction}
className="rounded-3xl bg-purple-30 px-5 py-2 text-sm text-white transition-all hover:bg-violets-are-blue"
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white transition-all"
>
Add
</button>
@@ -83,7 +83,7 @@ export default function AddActionModal({
setModalState('INACTIVE');
setActionName('');
}}
className="cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:text-light-gray dark:hover:bg-[#767183]/50"
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.configTool.closeButton')}
</button>

View File

@@ -85,19 +85,19 @@ export default function AgentDetailsModal({
}}
>
<div>
<h2 className="text-xl font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray text-xl font-semibold">
Access Details
</h2>
<div className="mt-8 flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray text-base font-semibold">
Public Link
</h2>
</div>
{sharedToken ? (
<div className="flex flex-col gap-2">
<p className="inline break-all font-roboto text-[14px] font-medium leading-normal text-gray-700 dark:text-[#ECECF1]">
<p className="font-roboto inline text-[14px] leading-normal font-medium break-all text-gray-700 dark:text-[#ECECF1]">
<a
href={`${baseURL}/shared/agent/${sharedToken}`}
target="_blank"
@@ -113,7 +113,7 @@ export default function AgentDetailsModal({
</p>
<a
href="https://docs.docsgpt.cloud/Agents/basics#core-components-of-an-agent"
className="flex w-fit items-center gap-1 text-purple-30 hover:underline"
className="text-purple-30 flex w-fit items-center gap-1 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
@@ -127,7 +127,7 @@ export default function AgentDetailsModal({
</div>
) : (
<button
className="flex w-28 items-center justify-center rounded-3xl border border-solid border-purple-30 px-5 py-2 text-sm font-medium text-purple-30 transition-colors hover:bg-purple-30 hover:text-white"
className="border-purple-30 text-purple-30 hover:bg-purple-30 flex w-28 items-center justify-center rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={handleGeneratePublicLink}
>
{loadingStates.publicLink ? (
@@ -139,13 +139,13 @@ export default function AgentDetailsModal({
)}
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray text-base font-semibold">
API Key
</h2>
{apiKey ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="break-all font-roboto text-[14px] font-medium leading-normal text-gray-700 dark:text-[#ECECF1]">
<div className="font-roboto text-[14px] leading-normal font-medium break-all text-gray-700 dark:text-[#ECECF1]">
{apiKey}
{!apiKey.includes('...') && (
<CopyButton
@@ -158,7 +158,7 @@ export default function AgentDetailsModal({
{!apiKey.includes('...') && (
<a
href={`https://widget.docsgpt.cloud/?api-key=${apiKey}`}
className="group ml-8 flex w-[101px] items-center justify-center gap-1 rounded-[62px] border border-purple-30 py-1.5 text-sm font-medium text-purple-30 transition-colors hover:bg-purple-30 hover:text-white"
className="group border-purple-30 text-purple-30 hover:bg-purple-30 ml-8 flex w-[101px] items-center justify-center gap-1 rounded-[62px] border py-1.5 text-sm font-medium transition-colors hover:text-white"
target="_blank"
rel="noopener noreferrer"
>
@@ -173,20 +173,20 @@ export default function AgentDetailsModal({
</div>
</div>
) : (
<button className="w-28 rounded-3xl border border-solid border-purple-30 px-5 py-2 text-sm font-medium text-purple-30 transition-colors hover:bg-purple-30 hover:text-white">
<button className="border-purple-30 text-purple-30 hover:bg-purple-30 w-28 rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white">
Generate
</button>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray text-base font-semibold">
Webhook URL
</h2>
</div>
{webhookUrl ? (
<div className="flex flex-col gap-2">
<p className="break-all font-roboto text-[14px] font-medium leading-normal text-gray-700 dark:text-[#ECECF1]">
<p className="font-roboto text-[14px] leading-normal font-medium break-all text-gray-700 dark:text-[#ECECF1]">
<a href={webhookUrl} target="_blank" rel="noreferrer">
{webhookUrl}
</a>
@@ -198,7 +198,7 @@ export default function AgentDetailsModal({
</p>
<a
href="https://docs.docsgpt.cloud/Agents/basics#core-components-of-an-agent"
className="flex w-fit items-center gap-1 text-purple-30 hover:underline"
className="text-purple-30 flex w-fit items-center gap-1 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
@@ -212,7 +212,7 @@ export default function AgentDetailsModal({
</div>
) : (
<button
className="flex w-28 items-center justify-center rounded-3xl border border-solid border-purple-30 px-5 py-2 text-sm font-medium text-purple-30 transition-colors hover:bg-purple-30 hover:text-white"
className="border-purple-30 text-purple-30 hover:bg-purple-30 flex w-28 items-center justify-center rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={handleGenerateWebhook}
>
{loadingStates.webhook ? (

View File

@@ -53,7 +53,7 @@ export default function ConfigToolModal({
return (
<WrapperModal close={() => setModalState('INACTIVE')}>
<div>
<h2 className="px-3 text-xl font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray px-3 text-xl font-semibold">
{t('modals.configTool.title')}
</h2>
<p className="mt-5 px-3 text-sm text-gray-600 dark:text-gray-400">
@@ -85,13 +85,13 @@ export default function ConfigToolModal({
onClick={() => {
tool && handleAddTool(tool);
}}
className="rounded-3xl bg-purple-30 px-5 py-2 text-sm text-white transition-all hover:bg-violets-are-blue"
className="bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm text-white transition-all"
>
{t('modals.configTool.addButton')}
</button>
<button
onClick={() => setModalState('INACTIVE')}
className="cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:text-light-gray dark:hover:bg-[#767183]/50"
className="dark:text-light-gray cursor-pointer rounded-3xl px-5 py-2 text-sm font-medium hover:bg-gray-100 dark:bg-transparent dark:hover:bg-[#767183]/50"
>
{t('modals.configTool.closeButton')}
</button>

View File

@@ -76,11 +76,11 @@ export default function CreateAPIKeyModal({
return (
<WrapperModal close={close} className="p-4">
<div className="mb-6">
<span className="text-xl text-jet dark:text-bright-gray">
<span className="text-jet dark:text-bright-gray text-xl">
{t('modals.createAPIKey.label')}
</span>
</div>
<div className="relative mb-4 mt-5">
<div className="relative mt-5 mb-4">
<Input
type="text"
className="rounded-md"
@@ -117,7 +117,7 @@ export default function CreateAPIKeyModal({
/>
</div>
<div className="my-4">
<p className="mb-2 ml-2 font-semibold text-jet dark:text-bright-gray">
<p className="text-jet dark:text-bright-gray mb-2 ml-2 font-semibold">
{t('modals.createAPIKey.chunks')}
</p>
<Dropdown
@@ -146,7 +146,7 @@ export default function CreateAPIKeyModal({
createAPIKey(payload);
}
}}
className="float-right mt-4 rounded-full bg-purple-30 px-5 py-2 text-sm text-white hover:bg-violets-are-blue disabled:opacity-50"
className="bg-purple-30 hover:bg-violets-are-blue float-right mt-4 rounded-full px-5 py-2 text-sm text-white disabled:opacity-50"
>
{t('modals.createAPIKey.create')}
</button>

View File

@@ -24,11 +24,11 @@ export default function JWTModal({
close={() => undefined}
>
<div className="mb-6">
<span className="text-lg text-jet dark:text-bright-gray">
<span className="text-jet dark:text-bright-gray text-lg">
Add JWT Token
</span>
</div>
<div className="relative mb-4 mt-5">
<div className="relative mt-5 mb-4">
<Input
name="JWT Token"
type="text"
@@ -41,7 +41,7 @@ export default function JWTModal({
<button
disabled={jwtToken.length === 0}
onClick={handleTokenSubmit.bind(null, jwtToken)}
className="float-right mt-4 rounded-full bg-purple-30 px-5 py-2 text-sm text-white hover:bg-[#6F3FD1] disabled:opacity-50"
className="bg-purple-30 float-right mt-4 rounded-full px-5 py-2 text-sm text-white hover:bg-[#6F3FD1] disabled:opacity-50"
>
Save Token
</button>

View File

@@ -18,23 +18,23 @@ export default function SaveAPIKeyModal({
return (
<WrapperModal close={close}>
<h1 className="my-0 text-xl font-medium text-jet dark:text-bright-gray">
<h1 className="text-jet dark:text-bright-gray my-0 text-xl font-medium">
{t('modals.saveKey.note')}
</h1>
<h3 className="text-sm font-normal text-outer-space dark:text-silver">
<h3 className="text-outer-space dark:text-silver text-sm font-normal">
{t('modals.saveKey.disclaimer')}
</h3>
<div className="flex justify-between py-2">
<div>
<h2 className="text-base font-semibold text-jet dark:text-bright-gray">
<h2 className="text-jet dark:text-bright-gray text-base font-semibold">
API Key
</h2>
<span className="text-sm font-normal leading-7 text-jet dark:text-bright-gray">
<span className="text-jet dark:text-bright-gray text-sm leading-7 font-normal">
{apiKey}
</span>
</div>
<button
className="my-1 h-10 w-20 rounded-full border border-solid border-violets-are-blue p-2 text-sm text-violets-are-blue hover:bg-violets-are-blue hover:text-white"
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue my-1 h-10 w-20 rounded-full border border-solid p-2 text-sm hover:text-white"
onClick={handleCopyKey}
>
{isCopied ? t('modals.saveKey.copied') : t('modals.saveKey.copy')}
@@ -42,7 +42,7 @@ export default function SaveAPIKeyModal({
</div>
<button
onClick={close}
className="rounded-full bg-philippine-yellow px-4 py-3 font-medium text-black hover:bg-[#E6B91A]"
className="bg-philippine-yellow rounded-full px-4 py-3 font-medium text-black hover:bg-[#E6B91A]"
>
{t('modals.saveKey.confirm')}
</button>

View File

@@ -45,7 +45,7 @@ export default function WrapperModal({
<div className="fixed top-0 left-0 z-30 flex h-screen w-screen items-center justify-center">
<div
ref={modalRef}
className={`relative rounded-2xl bg-white dark:bg-[#26272E] p-8 shadow-[0px_4px_40px_-3px_#0000001A] ${className}`}
className={`relative rounded-2xl bg-white p-8 shadow-[0px_4px_40px_-3px_#0000001A] dark:bg-[#26272E] ${className}`}
>
{!isPerformingTask && (
<button
@@ -55,7 +55,11 @@ export default function WrapperModal({
<img className="filter dark:invert" src={Exit} alt="Close" />
</button>
)}
<div className={`overflow-y-auto no-scrollbar text-[#18181B] dark:text-[#ECECF1] ${contentClassName}`}>{children}</div>
<div
className={`no-scrollbar overflow-y-auto text-[#18181B] dark:text-[#ECECF1] ${contentClassName}`}
>
{children}
</div>
</div>
</div>
);

View File

@@ -177,9 +177,9 @@ export default function Analytics({ agentId }: AnalyticsProps) {
<div className="mt-12">
{/* Messages Analytics */}
<div className="mt-8 flex w-full flex-col gap-3 [@media(min-width:1080px)]:flex-row">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="border-silver dark:border-silver/40 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
<p className="text-jet dark:text-bright-gray font-bold">
{t('settings.analytics.messages')}
</p>
<Dropdown
@@ -225,9 +225,9 @@ export default function Analytics({ agentId }: AnalyticsProps) {
</div>
{/* Token Usage Analytics */}
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40 [@media(min-width:1080px)]:w-1/2">
<div className="border-silver dark:border-silver/40 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5 [@media(min-width:1080px)]:w-1/2">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
<p className="text-jet dark:text-bright-gray font-bold">
{t('settings.analytics.tokenUsage')}
</p>
<Dropdown
@@ -275,9 +275,9 @@ export default function Analytics({ agentId }: AnalyticsProps) {
{/* Feedback Analytics */}
<div className="mt-8 flex w-full flex-col gap-3">
<div className="h-[345px] w-full overflow-hidden rounded-2xl border border-silver px-6 py-5 dark:border-silver/40">
<div className="border-silver dark:border-silver/40 h-[345px] w-full overflow-hidden rounded-2xl border px-6 py-5">
<div className="flex flex-row items-center justify-start gap-3">
<p className="font-bold text-jet dark:text-bright-gray">
<p className="text-jet dark:text-bright-gray font-bold">
{t('settings.analytics.userFeedback')}
</p>
<Dropdown

View File

@@ -171,7 +171,7 @@ export default function Tools() {
/>
</div>
<button
className="flex h-[32px] min-w-[108px] items-center justify-center whitespace-normal rounded-full bg-purple-30 px-4 text-sm text-white hover:bg-violets-are-blue"
className="bg-purple-30 hover:bg-violets-are-blue flex h-[32px] min-w-[108px] items-center justify-center rounded-full px-4 text-sm whitespace-normal text-white"
onClick={() => {
setAddToolModalState('ACTIVE');
}}
@@ -179,7 +179,7 @@ export default function Tools() {
{t('settings.tools.addTool')}
</button>
</div>
<div className="mb-8 mt-5 border-b border-light-silver dark:border-dim-gray" />
<div className="border-light-silver dark:border-dim-gray mt-5 mb-8 border-b" />
{loading ? (
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
<div className="col-span-2 mt-24 flex h-32 items-center justify-center lg:col-span-3">
@@ -219,7 +219,7 @@ export default function Tools() {
activeMenuId === tool.id ? null : tool.id,
);
}}
className="absolute right-4 top-4 z-10 cursor-pointer"
className="absolute top-4 right-4 z-10 cursor-pointer"
>
<img
src={ThreeDotsIcon}
@@ -248,16 +248,16 @@ export default function Tools() {
<div className="mt-[9px]">
<p
title={tool.customName || tool.displayName}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-raisin-black-light dark:text-bright-gray"
className="text-raisin-black-light dark:text-bright-gray truncate px-1 text-[13px] leading-relaxed font-semibold capitalize"
>
{tool.customName || tool.displayName}
</p>
<p className="mt-1 h-24 overflow-auto px-1 text-[12px] leading-relaxed text-old-silver dark:text-sonic-silver-light">
<p className="text-old-silver dark:text-sonic-silver-light mt-1 h-24 overflow-auto px-1 text-[12px] leading-relaxed">
{tool.description}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4">
<div className="absolute right-4 bottom-4">
<ToggleSwitch
checked={tool.status}
onChange={(checked) =>

View File

@@ -50,7 +50,7 @@ const Widgets: React.FC<{
return (
<div>
<div className="mt-[59px]">
<p className="font-bold text-jet">Widget Source</p>
<p className="text-jet font-bold">Widget Source</p>
<Dropdown
options={widgetSources}
selectedValue={selectedWidgetSource}
@@ -58,7 +58,7 @@ const Widgets: React.FC<{
/>
</div>
<div className="mt-5">
<p className="font-bold text-jet">Widget Method</p>
<p className="text-jet font-bold">Widget Method</p>
<Dropdown
options={widgetMethods}
selectedValue={selectedWidgetMethod}
@@ -66,7 +66,7 @@ const Widgets: React.FC<{
/>
</div>
<div className="mt-5">
<p className="font-bold text-jet">Widget Type</p>
<p className="text-jet font-bold">Widget Type</p>
<Dropdown
options={widgetTypes}
selectedValue={selectedWidgetType}
@@ -74,7 +74,7 @@ const Widgets: React.FC<{
/>
</div>
<div className="mt-6">
<p className="font-bold text-jet">Widget Code Snippet</p>
<p className="text-jet font-bold">Widget Code Snippet</p>
<textarea
rows={4}
value={widgetCode}

View File

@@ -38,8 +38,7 @@ export default function Settings() {
const getActiveTabFromPath = () => {
const path = location.pathname;
if (path.includes('/settings/sources'))
return t('settings.sources.label');
if (path.includes('/settings/sources')) return t('settings.sources.label');
if (path.includes('/settings/analytics'))
return t('settings.analytics.label');
if (path.includes('/settings/logs')) return t('settings.logs.label');
@@ -53,8 +52,7 @@ export default function Settings() {
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (tab === t('settings.general.label')) navigate('/settings');
else if (tab === t('settings.sources.label'))
navigate('/settings/sources');
else if (tab === t('settings.sources.label')) navigate('/settings/sources');
else if (tab === t('settings.analytics.label'))
navigate('/settings/analytics');
else if (tab === t('settings.logs.label')) navigate('/settings/logs');
@@ -103,7 +101,7 @@ export default function Settings() {
return (
<div className="h-full overflow-auto p-4 md:p-12">
<p className="text-2xl font-bold text-eerie-black dark:text-bright-gray">
<p className="text-eerie-black dark:text-bright-gray text-2xl font-bold">
{t('settings.label')}
</p>
<SettingsBar

View File

@@ -9,7 +9,7 @@ import Dropdown from '../components/Dropdown';
import Input from '../components/Input';
import ToggleSwitch from '../components/ToggleSwitch';
import WrapperModal from '../modals/WrapperModal';
import { ActiveState, Doc } from '../models/misc';
import { ActiveState, Doc } from '../models/misc';
import { getDocs } from '../preferences/preferenceApi';
import {
@@ -18,14 +18,15 @@ import {
setSelectedDocs,
setSourceDocs,
} from '../preferences/preferenceSlice';
import { IngestorDefaultConfigs, IngestorFormSchemas, getIngestorSchema, IngestorOption } from '../upload/types/ingestor';
import {
FormField,
IngestorConfig,
IngestorType,
} from './types/ingestor';
IngestorDefaultConfigs,
IngestorFormSchemas,
getIngestorSchema,
IngestorOption,
} from '../upload/types/ingestor';
import { FormField, IngestorConfig, IngestorType } from './types/ingestor';
import {FilePicker} from '../components/FilePicker';
import { FilePicker } from '../components/FilePicker';
import GoogleDrivePicker from '../components/GoogleDrivePicker';
import ChevronRight from '../assets/chevron-right.svg';
@@ -46,7 +47,7 @@ function Upload({
onSuccessfulUpload?: () => void;
}) {
const token = useSelector(selectToken);
const [files, setfiles] = useState<File[]>(receivedFile);
const [activeTab, setActiveTab] = useState<boolean>(true);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
@@ -55,9 +56,6 @@ function Upload({
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const renderFormFields = () => {
if (!ingestor.type) return null;
const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType);
@@ -190,7 +188,7 @@ function Upload({
return (
<div key={field.name}>
<div className="mb-3" {...getRootProps()}>
<span className="inline-block text-purple-30 dark:text-silver rounded-3xl border border-[#7F7F82] bg-transparent px-4 py-2 font-medium hover:cursor-pointer">
<span className="text-purple-30 dark:text-silver inline-block rounded-3xl border border-[#7F7F82] bg-transparent px-4 py-2 font-medium hover:cursor-pointer">
<input type="button" {...getInputProps()} />
Choose Files
</span>
@@ -203,7 +201,7 @@ function Upload({
{files.map((file) => (
<p
key={file.name}
className="text-gray-6000 dark:text-[#ececf1] truncate overflow-hidden text-ellipsis"
className="text-gray-6000 truncate overflow-hidden text-ellipsis dark:text-[#ececf1]"
title={file.name}
>
{file.name}
@@ -222,7 +220,10 @@ function Upload({
return (
<FilePicker
key={field.name}
onSelectionChange={(selectedFileIds: string[], selectedFolderIds: string[] = []) => {
onSelectionChange={(
selectedFileIds: string[],
selectedFolderIds: string[] = [],
) => {
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
@@ -236,7 +237,10 @@ function Upload({
return (
<GoogleDrivePicker
key={field.name}
onSelectionChange={(selectedFileIds: string[], selectedFolderIds: string[] = []) => {
onSelectionChange={(
selectedFileIds: string[],
selectedFolderIds: string[] = [],
) => {
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
@@ -265,14 +269,14 @@ function Upload({
const { t } = useTranslation();
const setTimeoutRef = useRef<number | null>(null);
const ingestorOptions: IngestorOption[] = IngestorFormSchemas
.filter(schema => schema.validate ? schema.validate() : true)
.map(schema => ({
label: schema.label,
value: schema.key,
icon: schema.icon,
heading: schema.heading
}));
const ingestorOptions: IngestorOption[] = IngestorFormSchemas.filter(
(schema) => (schema.validate ? schema.validate() : true),
).map((schema) => ({
label: schema.label,
value: schema.key,
icon: schema.icon,
heading: schema.heading,
}));
const sourceDocs = useSelector(selectSourceDocs);
useEffect(() => {
@@ -383,7 +387,8 @@ function Upload({
data?.find(
(d: Doc) => d.type?.toLowerCase() === 'local',
),
));
),
);
});
setProgress(
(progress) =>
@@ -457,21 +462,24 @@ function Upload({
);
}
const onDrop = useCallback((acceptedFiles: File[]) => {
setfiles(acceptedFiles);
setIngestor(prev => ({ ...prev, name: acceptedFiles[0]?.name || '' }));
const onDrop = useCallback(
(acceptedFiles: File[]) => {
setfiles(acceptedFiles);
setIngestor((prev) => ({ ...prev, name: acceptedFiles[0]?.name || '' }));
// If we're in local_file mode, update the ingestor config
if (ingestor.type === 'local_file') {
setIngestor((prevState) => ({
...prevState,
config: {
...prevState.config,
files: acceptedFiles,
},
}));
}
}, [ingestor.type]);
// If we're in local_file mode, update the ingestor config
if (ingestor.type === 'local_file') {
setIngestor((prevState) => ({
...prevState,
config: {
...prevState.config,
files: acceptedFiles,
},
}));
}
},
[ingestor.type],
);
const doNothing = () => undefined;
@@ -512,9 +520,15 @@ function Upload({
const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType);
if (!ingestorSchema) return;
const schema: FormField[] = ingestorSchema.fields;
const hasLocalFilePicker = schema.some((field: FormField) => field.type === 'local_file_picker');
const hasRemoteFilePicker = schema.some((field: FormField) => field.type === 'remote_file_picker');
const hasGoogleDrivePicker = schema.some((field: FormField) => field.type === 'google_drive_picker');
const hasLocalFilePicker = schema.some(
(field: FormField) => field.type === 'local_file_picker',
);
const hasRemoteFilePicker = schema.some(
(field: FormField) => field.type === 'remote_file_picker',
);
const hasGoogleDrivePicker = schema.some(
(field: FormField) => field.type === 'google_drive_picker',
);
if (hasLocalFilePicker) {
files.forEach((file) => {
@@ -557,15 +571,16 @@ function Upload({
}, 3000);
};
const endpoint = ingestor.type === 'local_file' ? `${apiHost}/api/upload` : `${apiHost}/api/remote`;
const endpoint =
ingestor.type === 'local_file'
? `${apiHost}/api/upload`
: `${apiHost}/api/remote`;
xhr.open('POST', endpoint);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
};
const { getRootProps, getInputProps } = useDropzone({
onDrop,
multiple: true,
@@ -604,12 +619,20 @@ function Upload({
}
if (!ingestor.type) return true;
const ingestorSchemaForValidation = getIngestorSchema(ingestor.type as IngestorType);
const ingestorSchemaForValidation = getIngestorSchema(
ingestor.type as IngestorType,
);
if (!ingestorSchemaForValidation) return true;
const schema: FormField[] = ingestorSchemaForValidation.fields;
const hasLocalFilePicker = schema.some((field: FormField) => field.type === 'local_file_picker');
const hasRemoteFilePicker = schema.some((field: FormField) => field.type === 'remote_file_picker');
const hasGoogleDrivePicker = schema.some((field: FormField) => field.type === 'google_drive_picker');
const hasLocalFilePicker = schema.some(
(field: FormField) => field.type === 'local_file_picker',
);
const hasRemoteFilePicker = schema.some(
(field: FormField) => field.type === 'remote_file_picker',
);
const hasGoogleDrivePicker = schema.some(
(field: FormField) => field.type === 'google_drive_picker',
);
if (hasLocalFilePicker) {
if (files.length === 0) {
@@ -621,7 +644,9 @@ function Upload({
}
}
const ingestorSchemaForFields = getIngestorSchema(ingestor.type as IngestorType);
const ingestorSchemaForFields = getIngestorSchema(
ingestor.type as IngestorType,
);
if (!ingestorSchemaForFields) return false;
const formFields: FormField[] = ingestorSchemaForFields.fields;
for (const field of formFields) {
@@ -686,26 +711,28 @@ function Upload({
const renderIngestorSelection = () => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 w-full">
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{ingestorOptions.map((option) => (
<div
key={option.value}
className={`relative flex flex-col justify-between rounded-2xl cursor-pointer w-full h-[91.2px] border border-solid pt-[21.1px] pr-[21px] pb-[15px] pl-[21px] gap-2 transition-colors duration-300 ease-out mx-auto ${
ingestor.type === option.value
? 'bg-[#7D54D1] text-white border-[#7D54D1]'
: 'bg-transparent hover:bg-[#ECECEC]/30 dark:hover:bg-[#383838]/30 border-[#D7D7D7] dark:border-[#4A4A4A] hover:shadow-[0_0_15px_0_#00000026] transition-shadow duration-300'
className={`relative mx-auto flex h-[91.2px] w-full cursor-pointer flex-col justify-between gap-2 rounded-2xl border border-solid pt-[21.1px] pr-[21px] pb-[15px] pl-[21px] transition-colors duration-300 ease-out ${
ingestor.type === option.value
? 'border-[#7D54D1] bg-[#7D54D1] text-white'
: 'border-[#D7D7D7] bg-transparent transition-shadow duration-300 hover:bg-[#ECECEC]/30 hover:shadow-[0_0_15px_0_#00000026] dark:border-[#4A4A4A] dark:hover:bg-[#383838]/30'
}`}
onClick={() => handleIngestorTypeChange(option.value as IngestorType)}
onClick={() =>
handleIngestorTypeChange(option.value as IngestorType)
}
>
<div className="flex flex-col justify-between h-full">
<div className="w-6 h-6">
<img
src={option.icon}
alt={option.label}
className={`${ingestor.type === option.value ? 'filter invert' : ''} dark:filter dark:invert`}
<div className="flex h-full flex-col justify-between">
<div className="h-6 w-6">
<img
src={option.icon}
alt={option.label}
className={`${ingestor.type === option.value ? 'invert filter' : ''} dark:invert dark:filter`}
/>
</div>
<p className="font-inter font-semibold text-[13px] leading-[18px] self-start">
<p className="font-inter self-start text-[13px] leading-[18px] font-semibold">
{option.label}
</p>
</div>
@@ -720,15 +747,15 @@ function Upload({
view = <UploadProgress></UploadProgress>;
} else if (progress?.type === 'TRAINING') {
view = <TrainingProgress></TrainingProgress>;
} else {
} else {
view = (
<div className="flex w-full flex-col gap-6">
{!ingestor.type && (
<p className="text-[#18181B] dark:text-[#ECECF1] text-left font-inter font-semibold text-[20px] leading-[28px] tracking-[0.15px]">
<p className="font-inter text-left text-[20px] leading-[28px] font-semibold tracking-[0.15px] text-[#18181B] dark:text-[#ECECF1]">
Select the way to add your source
</p>
)}
{activeTab && (
<>
{!ingestor.type && renderIngestorSelection()}
@@ -736,18 +763,19 @@ function Upload({
<div className="flex flex-col gap-4">
<button
onClick={() => handleIngestorTypeChange(null)}
className="flex items-center gap-2 text-[#777777] hover:text-[#555555] w-fit"
className="flex w-fit items-center gap-2 text-[#777777] hover:text-[#555555]"
>
<img
src={ChevronRight}
alt="back"
className="h-3 w-3 transform rotate-180"
<img
src={ChevronRight}
alt="back"
className="h-3 w-3 rotate-180 transform"
/>
<span>Back</span>
</button>
<h2 className="font-inter font-semibold text-[22px] leading-[28px] tracking-[0.15px] text-black dark:text-[#E0E0E0]">
{ingestor.type && getIngestorSchema(ingestor.type as IngestorType)?.heading}
<h2 className="font-inter text-[22px] leading-[28px] font-semibold tracking-[0.15px] text-black dark:text-[#E0E0E0]">
{ingestor.type &&
getIngestorSchema(ingestor.type as IngestorType)?.heading}
</h2>
<Input
@@ -769,19 +797,20 @@ function Upload({
{renderFormFields()}
</div>
)}
{ingestor.type && getIngestorSchema(ingestor.type as IngestorType)?.fields.some(
(field: FormField) => field.advanced,
) && (
<button
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
className="text-purple-30 bg-transparent py-2 pl-0 text-left text-sm font-normal hover:cursor-pointer"
>
{showAdvancedOptions
? t('modals.uploadDoc.hideAdvanced')
: t('modals.uploadDoc.showAdvanced')}
</button>
)}
{ingestor.type &&
getIngestorSchema(ingestor.type as IngestorType)?.fields.some(
(field: FormField) => field.advanced,
) && (
<button
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
className="text-purple-30 bg-transparent py-2 pl-0 text-left text-sm font-normal hover:cursor-pointer"
>
{showAdvancedOptions
? t('modals.uploadDoc.hideAdvanced')
: t('modals.uploadDoc.showAdvanced')}
</button>
)}
</>
)}
<div className="flex justify-end gap-4">
@@ -789,10 +818,14 @@ function Upload({
<button
onClick={() => {
if (!ingestor.type) return;
const ingestorSchemaForUpload = getIngestorSchema(ingestor.type as IngestorType);
const ingestorSchemaForUpload = getIngestorSchema(
ingestor.type as IngestorType,
);
if (!ingestorSchemaForUpload) return;
const schema: FormField[] = ingestorSchemaForUpload.fields;
const hasLocalFilePicker = schema.some((field: FormField) => field.type === 'local_file_picker');
const hasLocalFilePicker = schema.some(
(field: FormField) => field.type === 'local_file_picker',
);
if (hasLocalFilePicker) {
uploadFile();
@@ -815,10 +848,6 @@ function Upload({
);
}
return (
<WrapperModal
isPerformingTask={progress !== undefined && progress.percentage < 100}
@@ -828,7 +857,7 @@ function Upload({
setfiles([]);
setModalState('INACTIVE');
}}
className="w-11/12 sm:w-auto sm:min-w-[600px] md:min-w-[700px] max-h-[90vh] sm:max-h-none"
className="max-h-[90vh] w-11/12 sm:max-h-none sm:w-auto sm:min-w-[600px] md:min-w-[700px]"
contentClassName="max-h-[80vh] sm:max-h-none"
>
{view}

View File

@@ -5,7 +5,13 @@ import GithubIcon from '../../assets/github.svg';
import RedditIcon from '../../assets/reddit.svg';
import DriveIcon from '../../assets/drive.svg';
export type IngestorType = 'crawler' | 'github' | 'reddit' | 'url' | 'google_drive' | 'local_file';
export type IngestorType =
| 'crawler'
| 'github'
| 'reddit'
| 'url'
| 'google_drive'
| 'local_file';
export interface IngestorConfig {
type: IngestorType | null;
@@ -20,7 +26,14 @@ export type IngestorFormData = {
data: string;
};
export type FieldType = 'string' | 'number' | 'enum' | 'boolean' | 'local_file_picker' | 'remote_file_picker' | 'google_drive_picker';
export type FieldType =
| 'string'
| 'number'
| 'enum'
| 'boolean'
| 'local_file_picker'
| 'remote_file_picker'
| 'google_drive_picker';
export interface FormField {
name: string;
@@ -47,29 +60,41 @@ export const IngestorFormSchemas: IngestorSchema[] = [
icon: FileUploadIcon,
heading: 'Upload new document',
fields: [
{ name: 'files', label: 'Select files', type: 'local_file_picker', required: true },
]
{
name: 'files',
label: 'Select files',
type: 'local_file_picker',
required: true,
},
],
},
{
key: 'crawler',
label: 'Crawler',
icon: CrawlerIcon,
heading: 'Add content with Web Crawler',
fields: [{ name: 'url', label: 'URL', type: 'string', required: true }]
fields: [{ name: 'url', label: 'URL', type: 'string', required: true }],
},
{
key: 'url',
label: 'Link',
icon: UrlIcon,
heading: 'Add content from URL',
fields: [{ name: 'url', label: 'URL', type: 'string', required: true }]
fields: [{ name: 'url', label: 'URL', type: 'string', required: true }],
},
{
key: 'github',
label: 'GitHub',
icon: GithubIcon,
heading: 'Add content from GitHub',
fields: [{ name: 'repo_url', label: 'Repository URL', type: 'string', required: true }]
fields: [
{
name: 'repo_url',
label: 'Repository URL',
type: 'string',
required: true,
},
],
},
{
key: 'reddit',
@@ -78,11 +103,31 @@ export const IngestorFormSchemas: IngestorSchema[] = [
heading: 'Add content from Reddit',
fields: [
{ name: 'client_id', label: 'Client ID', type: 'string', required: true },
{ name: 'client_secret', label: 'Client Secret', type: 'string', required: true },
{ name: 'user_agent', label: 'User Agent', type: 'string', required: true },
{ name: 'search_queries', label: 'Search Queries', type: 'string', required: true },
{ name: 'number_posts', label: 'Number of Posts', type: 'number', required: true },
]
{
name: 'client_secret',
label: 'Client Secret',
type: 'string',
required: true,
},
{
name: 'user_agent',
label: 'User Agent',
type: 'string',
required: true,
},
{
name: 'search_queries',
label: 'Search Queries',
type: 'string',
required: true,
},
{
name: 'number_posts',
label: 'Number of Posts',
type: 'number',
required: true,
},
],
},
{
key: 'google_drive',
@@ -91,7 +136,7 @@ export const IngestorFormSchemas: IngestorSchema[] = [
heading: 'Upload from Google Drive',
validate: () => {
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
return !!(googleClientId);
return !!googleClientId;
},
fields: [
{
@@ -99,12 +144,15 @@ export const IngestorFormSchemas: IngestorSchema[] = [
label: 'Select Files from Google Drive',
type: 'google_drive_picker',
required: true,
}
]
},
],
},
];
export const IngestorDefaultConfigs: Record<IngestorType, Omit<IngestorConfig, 'type'>> = {
export const IngestorDefaultConfigs: Record<
IngestorType,
Omit<IngestorConfig, 'type'>
> = {
crawler: { name: '', config: { url: '' } },
url: { name: '', config: { url: '' } },
reddit: {
@@ -114,8 +162,8 @@ export const IngestorDefaultConfigs: Record<IngestorType, Omit<IngestorConfig, '
client_secret: '',
user_agent: '',
search_queries: '',
number_posts: 10
}
number_posts: 10,
},
},
github: { name: '', config: { repo_url: '' } },
google_drive: {
@@ -123,8 +171,8 @@ export const IngestorDefaultConfigs: Record<IngestorType, Omit<IngestorConfig, '
config: {
file_ids: '',
folder_ids: '',
recursive: true
}
recursive: true,
},
},
local_file: { name: '', config: { files: [] } },
};
@@ -136,8 +184,8 @@ export interface IngestorOption {
heading: string;
}
export const getIngestorSchema = (key: IngestorType): IngestorSchema | undefined => {
return IngestorFormSchemas.find(schema => schema.key === key);
export const getIngestorSchema = (
key: IngestorType,
): IngestorSchema | undefined => {
return IngestorFormSchemas.find((schema) => schema.key === key);
};

View File

@@ -0,0 +1,94 @@
import base64
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from application.security import encryption
def test_derive_key_uses_secret_and_user(monkeypatch):
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
salt = bytes(range(16))
expected_kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
backend=default_backend(),
)
expected_key = expected_kdf.derive(b"test-secret#user-123")
derived = encryption._derive_key("user-123", salt)
assert derived == expected_key
def _fake_os_urandom_factory(values):
values_iter = iter(values)
def _fake(length):
value = next(values_iter)
assert len(value) == length
return value
return _fake
def test_encrypt_and_decrypt_round_trip(monkeypatch):
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
salt = bytes(range(16))
iv = bytes(range(16, 32))
monkeypatch.setattr(encryption.os, "urandom", _fake_os_urandom_factory([salt, iv]))
credentials = {"token": "abc123", "refresh": "xyz789"}
encrypted = encryption.encrypt_credentials(credentials, "user-123")
decoded = base64.b64decode(encrypted)
assert decoded[:16] == salt
assert decoded[16:32] == iv
decrypted = encryption.decrypt_credentials(encrypted, "user-123")
assert decrypted == credentials
def test_encrypt_credentials_returns_empty_for_empty_input(monkeypatch):
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
assert encryption.encrypt_credentials({}, "user-123") == ""
assert encryption.encrypt_credentials(None, "user-123") == ""
def test_encrypt_credentials_returns_empty_on_serialization_error(monkeypatch):
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
monkeypatch.setattr(encryption.os, "urandom", lambda length: b"\x00" * length)
class NonSerializable: # pragma: no cover - simple helper container
pass
credentials = {"bad": NonSerializable()}
assert encryption.encrypt_credentials(credentials, "user-123") == ""
def test_decrypt_credentials_returns_empty_for_invalid_input(monkeypatch):
monkeypatch.setattr(encryption.settings, "ENCRYPTION_SECRET_KEY", "test-secret")
assert encryption.decrypt_credentials("", "user-123") == {}
assert encryption.decrypt_credentials("not-base64", "user-123") == {}
invalid_payload = base64.b64encode(b"short").decode()
assert encryption.decrypt_credentials(invalid_payload, "user-123") == {}
def test_pad_and_unpad_are_inverse():
original = b"secret-data"
padded = encryption._pad_data(original)
assert len(padded) % 16 == 0
assert encryption._unpad_data(padded) == original

View File

@@ -0,0 +1,352 @@
"""Tests for LocalStorage implementation
"""
import io
import pytest
from unittest.mock import patch, MagicMock, mock_open
from application.storage.local import LocalStorage
@pytest.fixture
def temp_base_dir():
"""Provide a temporary base directory path for testing."""
return "/tmp/test_storage"
@pytest.fixture
def local_storage(temp_base_dir):
"""Create LocalStorage instance with test base directory."""
return LocalStorage(base_dir=temp_base_dir)
class TestLocalStorageInitialization:
"""Test LocalStorage initialization and configuration."""
def test_init_with_custom_base_dir(self):
"""Should use provided base directory."""
storage = LocalStorage(base_dir="/custom/path")
assert storage.base_dir == "/custom/path"
def test_init_with_default_base_dir(self):
"""Should use default base directory when none provided."""
storage = LocalStorage()
# Default is three levels up from the file location
assert storage.base_dir is not None
assert isinstance(storage.base_dir, str)
def test_get_full_path_with_relative_path(self, local_storage):
"""Should combine base_dir with relative path."""
result = local_storage._get_full_path("documents/test.txt")
assert result == "/tmp/test_storage/documents/test.txt"
def test_get_full_path_with_absolute_path(self, local_storage):
"""Should return absolute path unchanged."""
result = local_storage._get_full_path("/absolute/path/test.txt")
assert result == "/absolute/path/test.txt"
class TestLocalStorageSaveFile:
"""Test file saving functionality."""
@patch('os.makedirs')
@patch('builtins.open', new_callable=mock_open)
@patch('shutil.copyfileobj')
def test_save_file_creates_directory_and_saves(
self, mock_copyfileobj, mock_file, mock_makedirs, local_storage
):
"""Should create directory and save file content."""
file_data = io.BytesIO(b"test content")
path = "documents/test.txt"
result = local_storage.save_file(file_data, path)
# Verify directory creation
mock_makedirs.assert_called_once_with(
"/tmp/test_storage/documents",
exist_ok=True
)
# Verify file write
mock_file.assert_called_once_with("/tmp/test_storage/documents/test.txt", 'wb')
mock_copyfileobj.assert_called_once_with(file_data, mock_file())
# Verify result
assert result == {'storage_type': 'local'}
@patch('os.makedirs')
def test_save_file_with_save_method(self, mock_makedirs, local_storage):
"""Should use save method if file_data has it."""
file_data = MagicMock()
file_data.save = MagicMock()
path = "documents/test.txt"
result = local_storage.save_file(file_data, path)
# Verify save method was called
file_data.save.assert_called_once_with("/tmp/test_storage/documents/test.txt")
# Verify result
assert result == {'storage_type': 'local'}
@patch('os.makedirs')
@patch('builtins.open', new_callable=mock_open)
def test_save_file_with_absolute_path(self, mock_file, mock_makedirs, local_storage):
"""Should handle absolute paths correctly."""
file_data = io.BytesIO(b"test content")
path = "/absolute/path/test.txt"
local_storage.save_file(file_data, path)
mock_makedirs.assert_called_once_with("/absolute/path", exist_ok=True)
mock_file.assert_called_once_with("/absolute/path/test.txt", 'wb')
class TestLocalStorageGetFile:
"""Test file retrieval functionality."""
@patch('os.path.exists', return_value=True)
@patch('builtins.open', new_callable=mock_open, read_data=b"file content")
def test_get_file_returns_file_handle(self, mock_file, mock_exists, local_storage):
"""Should open and return file handle when file exists."""
path = "documents/test.txt"
result = local_storage.get_file(path)
mock_exists.assert_called_once_with("/tmp/test_storage/documents/test.txt")
mock_file.assert_called_once_with("/tmp/test_storage/documents/test.txt", 'rb')
assert result is not None
@patch('os.path.exists', return_value=False)
def test_get_file_raises_error_when_not_found(self, mock_exists, local_storage):
"""Should raise FileNotFoundError when file doesn't exist."""
path = "documents/nonexistent.txt"
with pytest.raises(FileNotFoundError, match="File not found"):
local_storage.get_file(path)
mock_exists.assert_called_once_with("/tmp/test_storage/documents/nonexistent.txt")
class TestLocalStorageDeleteFile:
"""Test file deletion functionality."""
@patch('os.remove')
@patch('os.path.exists', return_value=True)
def test_delete_file_removes_existing_file(self, mock_exists, mock_remove, local_storage):
"""Should delete file and return True when file exists."""
path = "documents/test.txt"
result = local_storage.delete_file(path)
assert result is True
mock_exists.assert_called_once_with("/tmp/test_storage/documents/test.txt")
mock_remove.assert_called_once_with("/tmp/test_storage/documents/test.txt")
@patch('os.path.exists', return_value=False)
def test_delete_file_returns_false_when_not_found(self, mock_exists, local_storage):
"""Should return False when file doesn't exist."""
path = "documents/nonexistent.txt"
result = local_storage.delete_file(path)
assert result is False
mock_exists.assert_called_once_with("/tmp/test_storage/documents/nonexistent.txt")
class TestLocalStorageFileExists:
"""Test file existence checking."""
@patch('os.path.exists', return_value=True)
def test_file_exists_returns_true_when_file_found(self, mock_exists, local_storage):
"""Should return True when file exists."""
path = "documents/test.txt"
result = local_storage.file_exists(path)
assert result is True
mock_exists.assert_called_once_with("/tmp/test_storage/documents/test.txt")
@patch('os.path.exists', return_value=False)
def test_file_exists_returns_false_when_not_found(self, mock_exists, local_storage):
"""Should return False when file doesn't exist."""
path = "documents/nonexistent.txt"
result = local_storage.file_exists(path)
assert result is False
mock_exists.assert_called_once_with("/tmp/test_storage/documents/nonexistent.txt")
class TestLocalStorageListFiles:
"""Test directory listing functionality."""
@patch('os.walk')
@patch('os.path.exists', return_value=True)
def test_list_files_returns_all_files_in_directory(
self, mock_exists, mock_walk, local_storage
):
"""Should return all files in directory and subdirectories."""
directory = "documents"
# Mock os.walk to return files in directory structure
mock_walk.return_value = [
("/tmp/test_storage/documents", ["subdir"], ["file1.txt", "file2.txt"]),
("/tmp/test_storage/documents/subdir", [], ["file3.txt"])
]
result = local_storage.list_files(directory)
assert len(result) == 3
assert "documents/file1.txt" in result
assert "documents/file2.txt" in result
assert "documents/subdir/file3.txt" in result
mock_exists.assert_called_once_with("/tmp/test_storage/documents")
mock_walk.assert_called_once_with("/tmp/test_storage/documents")
@patch('os.path.exists', return_value=False)
def test_list_files_returns_empty_list_when_directory_not_found(
self, mock_exists, local_storage
):
"""Should return empty list when directory doesn't exist."""
directory = "nonexistent"
result = local_storage.list_files(directory)
assert result == []
mock_exists.assert_called_once_with("/tmp/test_storage/nonexistent")
class TestLocalStorageProcessFile:
"""Test file processing functionality."""
@patch('os.path.exists', return_value=True)
def test_process_file_calls_processor_with_full_path(
self, mock_exists, local_storage
):
"""Should call processor function with full file path."""
path = "documents/test.txt"
processor_func = MagicMock(return_value="processed")
result = local_storage.process_file(path, processor_func, extra_arg="value")
assert result == "processed"
processor_func.assert_called_once_with(
local_path="/tmp/test_storage/documents/test.txt",
extra_arg="value"
)
mock_exists.assert_called_once_with("/tmp/test_storage/documents/test.txt")
@patch('os.path.exists', return_value=False)
def test_process_file_raises_error_when_file_not_found(self, mock_exists, local_storage):
"""Should raise FileNotFoundError when file doesn't exist."""
path = "documents/nonexistent.txt"
processor_func = MagicMock()
with pytest.raises(FileNotFoundError, match="File not found"):
local_storage.process_file(path, processor_func)
processor_func.assert_not_called()
class TestLocalStorageIsDirectory:
"""Test directory checking functionality."""
@patch('os.path.isdir', return_value=True)
def test_is_directory_returns_true_when_directory_exists(
self, mock_isdir, local_storage
):
"""Should return True when path is a directory."""
path = "documents"
result = local_storage.is_directory(path)
assert result is True
mock_isdir.assert_called_once_with("/tmp/test_storage/documents")
@patch('os.path.isdir', return_value=False)
def test_is_directory_returns_false_when_not_directory(
self, mock_isdir, local_storage
):
"""Should return False when path is not a directory or doesn't exist."""
path = "documents/test.txt"
result = local_storage.is_directory(path)
assert result is False
mock_isdir.assert_called_once_with("/tmp/test_storage/documents/test.txt")
class TestLocalStorageRemoveDirectory:
"""Test directory removal functionality."""
@patch('shutil.rmtree')
@patch('os.path.isdir', return_value=True)
@patch('os.path.exists', return_value=True)
def test_remove_directory_deletes_directory(
self, mock_exists, mock_isdir, mock_rmtree, local_storage
):
"""Should remove directory and return True when successful."""
directory = "documents"
result = local_storage.remove_directory(directory)
assert result is True
mock_exists.assert_called_once_with("/tmp/test_storage/documents")
mock_isdir.assert_called_once_with("/tmp/test_storage/documents")
mock_rmtree.assert_called_once_with("/tmp/test_storage/documents")
@patch('os.path.exists', return_value=False)
def test_remove_directory_returns_false_when_not_exists(
self, mock_exists, local_storage
):
"""Should return False when directory doesn't exist."""
directory = "nonexistent"
result = local_storage.remove_directory(directory)
assert result is False
mock_exists.assert_called_once_with("/tmp/test_storage/nonexistent")
@patch('os.path.isdir', return_value=False)
@patch('os.path.exists', return_value=True)
def test_remove_directory_returns_false_when_not_directory(
self, mock_exists, mock_isdir, local_storage
):
"""Should return False when path is not a directory."""
path = "documents/test.txt"
result = local_storage.remove_directory(path)
assert result is False
mock_exists.assert_called_once_with("/tmp/test_storage/documents/test.txt")
mock_isdir.assert_called_once_with("/tmp/test_storage/documents/test.txt")
@patch('shutil.rmtree', side_effect=OSError("Permission denied"))
@patch('os.path.isdir', return_value=True)
@patch('os.path.exists', return_value=True)
def test_remove_directory_returns_false_on_os_error(
self, mock_exists, mock_isdir, mock_rmtree, local_storage
):
"""Should return False when OSError occurs during removal."""
directory = "documents"
result = local_storage.remove_directory(directory)
assert result is False
mock_rmtree.assert_called_once_with("/tmp/test_storage/documents")
@patch('shutil.rmtree', side_effect=PermissionError("Access denied"))
@patch('os.path.isdir', return_value=True)
@patch('os.path.exists', return_value=True)
def test_remove_directory_returns_false_on_permission_error(
self, mock_exists, mock_isdir, mock_rmtree, local_storage
):
"""Should return False when PermissionError occurs during removal."""
directory = "documents"
result = local_storage.remove_directory(directory)
assert result is False
mock_rmtree.assert_called_once_with("/tmp/test_storage/documents")

View File

@@ -0,0 +1,382 @@
"""Tests for S3 storage implementation.
"""
import io
import pytest
from unittest.mock import patch, MagicMock
from botocore.exceptions import ClientError
from application.storage.s3 import S3Storage
@pytest.fixture
def mock_boto3_client():
"""Mock boto3.client to isolate S3 client creation."""
with patch('boto3.client') as mock_client:
s3_mock = MagicMock()
mock_client.return_value = s3_mock
yield s3_mock
@pytest.fixture
def s3_storage(mock_boto3_client):
"""Create S3Storage instance with mocked boto3 client."""
return S3Storage(bucket_name="test-bucket")
class TestS3StorageInitialization:
"""Test S3Storage initialization and configuration."""
def test_init_with_default_bucket(self):
"""Should use default bucket name when none provided."""
with patch('boto3.client'):
storage = S3Storage()
assert storage.bucket_name == "docsgpt-test-bucket"
def test_init_with_custom_bucket(self):
"""Should use provided bucket name."""
with patch('boto3.client'):
storage = S3Storage(bucket_name="custom-bucket")
assert storage.bucket_name == "custom-bucket"
def test_init_creates_boto3_client(self):
"""Should create boto3 S3 client with credentials from settings."""
with patch('boto3.client') as mock_client, \
patch('application.storage.s3.settings') as mock_settings:
mock_settings.SAGEMAKER_ACCESS_KEY = "test-key"
mock_settings.SAGEMAKER_SECRET_KEY = "test-secret"
mock_settings.SAGEMAKER_REGION = "us-west-2"
S3Storage()
mock_client.assert_called_once_with(
"s3",
aws_access_key_id="test-key",
aws_secret_access_key="test-secret",
region_name="us-west-2"
)
class TestS3StorageSaveFile:
"""Test file saving functionality."""
def test_save_file_uploads_to_s3(self, s3_storage, mock_boto3_client):
"""Should upload file to S3 with correct parameters."""
file_data = io.BytesIO(b"test content")
path = "documents/test.txt"
with patch('application.storage.s3.settings') as mock_settings:
mock_settings.SAGEMAKER_REGION = "us-east-1"
result = s3_storage.save_file(file_data, path)
mock_boto3_client.upload_fileobj.assert_called_once_with(
file_data,
"test-bucket",
path,
ExtraArgs={"StorageClass": "INTELLIGENT_TIERING"}
)
assert result == {
"storage_type": "s3",
"bucket_name": "test-bucket",
"uri": "s3://test-bucket/documents/test.txt",
"region": "us-east-1"
}
def test_save_file_with_custom_storage_class(self, s3_storage, mock_boto3_client):
"""Should use custom storage class when provided."""
file_data = io.BytesIO(b"test content")
path = "documents/test.txt"
with patch('application.storage.s3.settings') as mock_settings:
mock_settings.SAGEMAKER_REGION = "us-east-1"
s3_storage.save_file(file_data, path, storage_class="STANDARD")
mock_boto3_client.upload_fileobj.assert_called_once_with(
file_data,
"test-bucket",
path,
ExtraArgs={"StorageClass": "STANDARD"}
)
def test_save_file_propagates_client_error(self, s3_storage, mock_boto3_client):
"""Should propagate ClientError when upload fails."""
file_data = io.BytesIO(b"test content")
path = "documents/test.txt"
mock_boto3_client.upload_fileobj.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"upload_fileobj"
)
with pytest.raises(ClientError):
s3_storage.save_file(file_data, path)
class TestS3StorageFileExists:
"""Test file existence checking."""
def test_file_exists_returns_true_when_file_found(self, s3_storage, mock_boto3_client):
"""Should return True when head_object succeeds."""
path = "documents/test.txt"
mock_boto3_client.head_object.return_value = {"ContentLength": 100}
result = s3_storage.file_exists(path)
assert result is True
mock_boto3_client.head_object.assert_called_once_with(
Bucket="test-bucket",
Key=path
)
def test_file_exists_returns_false_on_client_error(self, s3_storage, mock_boto3_client):
"""Should return False when head_object raises ClientError."""
path = "documents/nonexistent.txt"
mock_boto3_client.head_object.side_effect = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "Not found"}},
"head_object"
)
result = s3_storage.file_exists(path)
assert result is False
class TestS3StorageGetFile:
"""Test file retrieval functionality."""
def test_get_file_downloads_and_returns_file_object(self, s3_storage, mock_boto3_client):
"""Should download file from S3 and return BytesIO object."""
path = "documents/test.txt"
test_content = b"file content"
mock_boto3_client.head_object.return_value = {}
def mock_download(bucket, key, file_obj):
file_obj.write(test_content)
mock_boto3_client.download_fileobj.side_effect = mock_download
result = s3_storage.get_file(path)
assert isinstance(result, io.BytesIO)
assert result.read() == test_content
mock_boto3_client.download_fileobj.assert_called_once()
def test_get_file_raises_error_when_file_not_found(self, s3_storage, mock_boto3_client):
"""Should raise FileNotFoundError when file doesn't exist."""
path = "documents/nonexistent.txt"
mock_boto3_client.head_object.side_effect = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "Not found"}},
"head_object"
)
with pytest.raises(FileNotFoundError, match="File not found"):
s3_storage.get_file(path)
class TestS3StorageDeleteFile:
"""Test file deletion functionality."""
def test_delete_file_returns_true_on_success(self, s3_storage, mock_boto3_client):
"""Should return True when deletion succeeds."""
path = "documents/test.txt"
mock_boto3_client.delete_object.return_value = {}
result = s3_storage.delete_file(path)
assert result is True
mock_boto3_client.delete_object.assert_called_once_with(
Bucket="test-bucket",
Key=path
)
def test_delete_file_returns_false_on_client_error(self, s3_storage, mock_boto3_client):
"""Should return False when deletion fails with ClientError."""
path = "documents/test.txt"
mock_boto3_client.delete_object.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"delete_object"
)
result = s3_storage.delete_file(path)
assert result is False
class TestS3StorageListFiles:
"""Test directory listing functionality."""
def test_list_files_returns_all_keys_with_prefix(self, s3_storage, mock_boto3_client):
"""Should return all file keys matching the directory prefix."""
directory = "documents/"
paginator_mock = MagicMock()
mock_boto3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [
{
"Contents": [
{"Key": "documents/file1.txt"},
{"Key": "documents/file2.txt"},
{"Key": "documents/subdir/file3.txt"}
]
}
]
result = s3_storage.list_files(directory)
assert len(result) == 3
assert "documents/file1.txt" in result
assert "documents/file2.txt" in result
assert "documents/subdir/file3.txt" in result
mock_boto3_client.get_paginator.assert_called_once_with('list_objects_v2')
paginator_mock.paginate.assert_called_once_with(
Bucket="test-bucket",
Prefix="documents/"
)
def test_list_files_returns_empty_list_when_no_contents(self, s3_storage, mock_boto3_client):
"""Should return empty list when directory has no files."""
directory = "empty/"
paginator_mock = MagicMock()
mock_boto3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [{}]
result = s3_storage.list_files(directory)
assert result == []
class TestS3StorageProcessFile:
"""Test file processing functionality."""
def test_process_file_downloads_and_processes_file(self, s3_storage, mock_boto3_client):
"""Should download file to temp location and call processor function."""
path = "documents/test.txt"
mock_boto3_client.head_object.return_value = {}
with patch('tempfile.NamedTemporaryFile') as mock_temp:
mock_file = MagicMock()
mock_file.name = "/tmp/test_file"
mock_temp.return_value.__enter__.return_value = mock_file
processor_func = MagicMock(return_value="processed")
result = s3_storage.process_file(path, processor_func, extra_arg="value")
assert result == "processed"
processor_func.assert_called_once_with(local_path="/tmp/test_file", extra_arg="value")
mock_boto3_client.download_fileobj.assert_called_once()
def test_process_file_raises_error_when_file_not_found(self, s3_storage, mock_boto3_client):
"""Should raise FileNotFoundError when file doesn't exist."""
path = "documents/nonexistent.txt"
mock_boto3_client.head_object.side_effect = ClientError(
{"Error": {"Code": "NoSuchKey", "Message": "Not found"}},
"head_object"
)
processor_func = MagicMock()
with pytest.raises(FileNotFoundError, match="File not found in S3"):
s3_storage.process_file(path, processor_func)
class TestS3StorageIsDirectory:
"""Test directory checking functionality."""
def test_is_directory_returns_true_when_objects_exist(self, s3_storage, mock_boto3_client):
"""Should return True when objects exist with the directory prefix."""
path = "documents/"
mock_boto3_client.list_objects_v2.return_value = {
"Contents": [{"Key": "documents/file1.txt"}]
}
result = s3_storage.is_directory(path)
assert result is True
mock_boto3_client.list_objects_v2.assert_called_once_with(
Bucket="test-bucket",
Prefix="documents/",
MaxKeys=1
)
def test_is_directory_returns_false_when_no_objects_exist(self, s3_storage, mock_boto3_client):
"""Should return False when no objects exist with the directory prefix."""
path = "nonexistent/"
mock_boto3_client.list_objects_v2.return_value = {}
result = s3_storage.is_directory(path)
assert result is False
class TestS3StorageRemoveDirectory:
"""Test directory removal functionality."""
def test_remove_directory_deletes_all_objects(self, s3_storage, mock_boto3_client):
"""Should delete all objects with the directory prefix."""
directory = "documents/"
paginator_mock = MagicMock()
mock_boto3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [
{
"Contents": [
{"Key": "documents/file1.txt"},
{"Key": "documents/file2.txt"}
]
}
]
mock_boto3_client.delete_objects.return_value = {
"Deleted": [
{"Key": "documents/file1.txt"},
{"Key": "documents/file2.txt"}
]
}
result = s3_storage.remove_directory(directory)
assert result is True
mock_boto3_client.delete_objects.assert_called_once()
call_args = mock_boto3_client.delete_objects.call_args[1]
assert call_args["Bucket"] == "test-bucket"
assert len(call_args["Delete"]["Objects"]) == 2
def test_remove_directory_returns_false_when_empty(self, s3_storage, mock_boto3_client):
"""Should return False when directory is empty (no objects to delete)."""
directory = "empty/"
paginator_mock = MagicMock()
mock_boto3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [{}]
result = s3_storage.remove_directory(directory)
assert result is False
mock_boto3_client.delete_objects.assert_not_called()
def test_remove_directory_returns_false_on_client_error(self, s3_storage, mock_boto3_client):
"""Should return False when deletion fails with ClientError."""
directory = "documents/"
paginator_mock = MagicMock()
mock_boto3_client.get_paginator.return_value = paginator_mock
paginator_mock.paginate.return_value = [
{"Contents": [{"Key": "documents/file1.txt"}]}
]
mock_boto3_client.delete_objects.side_effect = ClientError(
{"Error": {"Code": "AccessDenied", "Message": "Access denied"}},
"delete_objects"
)
result = s3_storage.remove_directory(directory)
assert result is False

View File

@@ -0,0 +1,43 @@
import base64
import sys
from types import ModuleType, SimpleNamespace
from application.tts.elevenlabs import ElevenlabsTTS
def test_elevenlabs_text_to_speech_monkeypatched_client(monkeypatch):
monkeypatch.setattr(
"application.tts.elevenlabs.settings",
SimpleNamespace(ELEVENLABS_API_KEY="api-key"),
)
created = {}
class DummyClient:
def __init__(self, api_key):
created["api_key"] = api_key
self.generate_calls = []
def generate(self, *, text, model, voice):
self.generate_calls.append({"text": text, "model": model, "voice": voice})
yield b"chunk-one"
yield b"chunk-two"
client_module = ModuleType("elevenlabs.client")
client_module.ElevenLabs = DummyClient
package_module = ModuleType("elevenlabs")
package_module.client = client_module
monkeypatch.setitem(sys.modules, "elevenlabs", package_module)
monkeypatch.setitem(sys.modules, "elevenlabs.client", client_module)
tts = ElevenlabsTTS()
audio_base64, lang = tts.text_to_speech("Speak")
assert created["api_key"] == "api-key"
assert tts.client.generate_calls == [
{"text": "Speak", "model": "eleven_multilingual_v2", "voice": "Brian"}
]
assert lang == "en"
assert base64.b64decode(audio_base64.encode()) == b"chunk-onechunk-two"

View File

@@ -0,0 +1,24 @@
import base64
from application.tts.google_tts import GoogleTTS
def test_google_tts_text_to_speech(monkeypatch):
captured = {}
class DummyGTTS:
def __init__(self, *, text, lang, slow):
captured["args"] = {"text": text, "lang": lang, "slow": slow}
def write_to_fp(self, fp):
fp.write(b"synthetic-audio")
monkeypatch.setattr("application.tts.google_tts.gTTS", DummyGTTS)
tts = GoogleTTS()
audio_base64, lang = tts.text_to_speech("hello world")
assert captured["args"] == {"text": "hello world", "lang": "en", "slow": False}
assert lang == "en"
assert base64.b64decode(audio_base64.encode()) == b"synthetic-audio"