Compare commits

..

53 Commits

Author SHA1 Message Date
Alex
b43f9f3cb2 feat: enhance MemoryTool and NotesTool with tool_id management and directory renaming tests 2025-10-06 21:41:47 +01:00
Alex
e012189672 feat: implement MemoryTool with CRUD operations and integrate with MongoDB 2025-10-06 21:09:22 +01:00
Dhairya Parikh
4c31e9a8b1 Add MongoDB-backed NotesTool with CRUD actions and unit tests (#1982)
* Add MongoDB-backed NotesTool with CRUD actions and unit tests

* NotesTool: enforce single note per user, require decoded_token['sub'] user_id, fix tests

* chore: remove accidentally committed results.txt and ignore it

* fix: remove results.txt, enforce single note per user, add tests

* refactor: update NotesTool actions and tests for clarity and consistency

* refactor: update NotesTool docstring for clarity

* refactor: simplify MCPTool docstring and remove redundant import in test_notes_tool

* lint: fix test

* refactor: remove unused import from test_notes_tool.py

---------

Co-authored-by: Alex <a@tushynski.me>
2025-10-06 19:24:03 +03:00
Alex
7cfc230316 Update README.md 2025-10-06 18:30:47 +03:00
Alex
9605e85f1c Merge pull request #2004 from Lanthoiba2022/AgentImageFix1
Fix #1983: Agent image fallback added
2025-10-06 14:47:26 +01:00
Alex
498e2b772c fix: update save_file method to accept additional keyword arguments; enhance AgentImage component with useEffect for dynamic source updates 2025-10-06 14:41:51 +01:00
Manish Madan
dad897da51 Merge pull request #1997 from arc53/dependabot/npm_and_yarn/frontend/i18next-25.5.3
build(deps): bump i18next from 24.2.0 to 25.5.3 in /frontend
2025-10-06 16:44:15 +05:30
dependabot[bot]
02ad5f062e build(deps): bump i18next from 24.2.0 to 25.5.3 in /frontend
Bumps [i18next](https://github.com/i18next/i18next) from 24.2.0 to 25.5.3.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v24.2.0...v25.5.3)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.5.3
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-06 11:03:35 +00:00
Manish Madan
4eb9471b4f Merge pull request #2021 from M4cr0Chen/enhance-edit-button-and-response-ui
adjusted edit button padding and response box padding & roundness
2025-10-06 13:13:00 +05:30
Zhenghong Chen
b505d207d7 adjusted edit button padding and response box padding & roundness 2025-10-05 16:34:17 -04:00
Alex
3c954bd07f Merge pull request #2007 from ManishMadan2882/upload-toast
Upload Sources: Tasks are notified with UI toasts
2025-10-05 13:49:11 +01:00
ManishMadan2882
c00b6459dc (fix:upload) set docs 2025-10-05 18:07:21 +05:30
ManishMadan2882
eb4d776784 (feat:upload) wording, icons and rmv clear button 2025-10-05 16:12:58 +05:30
Alex
5d7a890533 Merge pull request #2015 from JeevaRamanathan/fix/twitter-navigation-link
chore: update twitter.com to x.com
2025-10-04 21:50:47 +01:00
JeevaRamanathan
9c6aefef1e chore: update twitter.com to x.com for avoiding redirection
Signed-off-by: JeevaRamanathan <jeevaramanathan.m@infosys.com>
2025-10-05 02:00:57 +05:30
ManishMadan2882
e4554d6c09 Merge branch 'main' of https://github.com/arc53/DocsGPT into upload-toast 2025-10-03 23:26:32 +05:30
ManishMadan2882
c184b63df8 (feat:upload) i18n 2025-10-03 20:32:43 +05:30
ManishMadan2882
6bb4195393 (feat:upload) dismiss, but notify on completion 2025-10-03 19:55:41 +05:30
Alex
7827a4d40d Merge pull request #1960 from jayamrutiya/letmecheck/bug-return-value-not-be-ignored
Fix: replace map with for...of loop to avoid ignoring return value (S2201)
2025-10-03 14:15:39 +01:00
ManishMadan2882
f09fa8231a (feat:upload) new toast UI 2025-10-03 17:08:39 +05:30
Alex
96ff10000d Merge pull request #1999 from hanzalahwaheed/feat/ui-enhancements
Feat: UI enhancements
2025-10-03 10:15:54 +01:00
Alex
9460636867 Merge pull request #2005 from siiddhantt/feat/routes-refactor
refactor: modularize user API routes into domain-driven structure
2025-10-03 10:03:33 +01:00
Siddhant Rai
6c43245295 refactor: organize import statements for clarity and consistency 2025-10-03 12:41:03 +05:30
Pavel
266b6cf638 Fix typo in HACKTOBERFEST.md 2025-10-03 08:09:22 +01:00
Siddhant Rai
70183e234a refactor: break up monolithic routes.py into modular domain structure 2025-10-03 12:30:04 +05:30
Hanzalah Waheed
17b9c359ca fix: use hexcode for purple taupe and rm extra props 2025-10-03 00:22:36 +04:00
Lanthoiba22
045630b8a5 Agent image fallback added 2025-10-02 21:53:38 +05:30
Alex
55ff7dd640 Merge pull request #2003 from arc53/hacktoberfest-rules
Update HACKTOBERFEST.md
2025-10-02 16:58:22 +01:00
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
Hanzalah Waheed
5b2738aec9 fix (ui): is_copied true, disable hover state 2025-10-02 13:44:05 +04:00
Hanzalah Waheed
892312fc08 fix: hover bg color change fixed using correct css var 2025-10-02 12:23:17 +04:00
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
Hanzalah Waheed
444b1a0b65 feat(ui): add transition animation to navigation sidebar 2025-10-01 23:16:24 +04:00
Hanzalah Waheed
814ea1c016 fix(ui): add text-32px for smaller than lg view for agent headings 2025-10-01 21:21:23 +04:00
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
jayamrutiya
7c15a4c7ff Fix: replace map with forEach to avoid ignoring return value (S2201) 2025-09-16 22:11:55 +05:30
99 changed files with 9795 additions and 5613 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 design later into the October.

View File

@@ -17,7 +17,7 @@
<a href="https://github.com/arc53/DocsGPT/blob/main/LICENSE">![link to license file](https://img.shields.io/github/license/arc53/docsgpt)</a>
<a href="https://www.bestpractices.dev/projects/9907"><img src="https://www.bestpractices.dev/projects/9907/badge"></a>
<a href="https://discord.gg/n5BX8dh8rU">![link to discord](https://img.shields.io/discord/1070046503302877216)</a>
<a href="https://twitter.com/docsgptai">![X (formerly Twitter) URL](https://img.shields.io/twitter/follow/docsgptai)</a>
<a href="https://x.com/docsgptai">![X (formerly Twitter) URL](https://img.shields.io/twitter/follow/docsgptai)</a>
<a href="https://docs.docsgpt.cloud/quickstart">⚡️ Quickstart</a><a href="https://app.docsgpt.cloud/">☁️ Cloud Version</a><a href="https://discord.gg/n5BX8dh8rU">💬 Discord</a>
<br>
@@ -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">
@@ -57,7 +67,7 @@
- [x] Json Responses (August 2025)
- [x] MCP support (August 2025)
- [x] Google Drive integration (September 2025)
- [ ] Add OAuth 2.0 authentication for MCP (September 2025)
- [x] Add OAuth 2.0 authentication for MCP (September 2025)
- [ ] Sharepoint integration (October 2025)
- [ ] Deep Agents (October 2025)
- [ ] Agent scheduling

View File

@@ -213,18 +213,24 @@ class BaseAgent(ABC):
):
target_dict[param] = value
tm = ToolManager(config={})
# Prepare tool_config and add tool_id for memory tools
if tool_data["name"] == "api_tool":
tool_config = {
"url": tool_data["config"]["actions"][action_name]["url"],
"method": tool_data["config"]["actions"][action_name]["method"],
"headers": headers,
"query_params": query_params,
}
else:
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
# Add tool_id from MongoDB _id for tools that need instance isolation (like memory tool)
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
tool_config["tool_id"] = str(tool_data.get("_id", tool_id))
tool = tm.load_tool(
tool_data["name"],
tool_config=(
{
"url": tool_data["config"]["actions"][action_name]["url"],
"method": tool_data["config"]["actions"][action_name]["method"],
"headers": headers,
"query_params": query_params,
}
if tool_data["name"] == "api_tool"
else tool_data["config"]
),
tool_config=tool_config,
user_id=self.user, # Pass user ID for MCP tools credential decryption
)
if tool_data["name"] == "api_tool":

View File

@@ -37,7 +37,7 @@ _mcp_clients_cache = {}
class MCPTool(Tool):
"""
MCP Tool
Connect to remote Model Context Protocol (MCP) servers to access dynamic tools and resources. Supports various authentication methods and provides secure access to external services through the MCP protocol.
Connect to remote Model Context Protocol (MCP) servers to access dynamic tools and resources.
"""
def __init__(self, config: Dict[str, Any], user_id: Optional[str] = None):

View File

@@ -0,0 +1,546 @@
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
import re
import uuid
from .base import Tool
from application.core.mongo_db import MongoDB
from application.core.settings import settings
class MemoryTool(Tool):
"""Memory
Stores and retrieves information across conversations through a memory file directory.
"""
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
"""Initialize the tool.
Args:
tool_config: Optional tool configuration. Should include:
- tool_id: Unique identifier for this memory tool instance (from user_tools._id)
This ensures each user's tool configuration has isolated memories
user_id: The authenticated user's id (should come from decoded_token["sub"]).
"""
self.user_id: Optional[str] = user_id
# Get tool_id from configuration (passed from user_tools._id in production)
# In production, tool_id is the MongoDB ObjectId string from user_tools collection
if tool_config and "tool_id" in tool_config:
self.tool_id = tool_config["tool_id"]
elif user_id:
# Fallback for backward compatibility or testing
self.tool_id = f"default_{user_id}"
else:
# Last resort fallback (shouldn't happen in normal use)
self.tool_id = str(uuid.uuid4())
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["memories"]
# -----------------------------
# Action implementations
# -----------------------------
def execute_action(self, action_name: str, **kwargs: Any) -> str:
"""Execute an action by name.
Args:
action_name: One of view, create, str_replace, insert, delete, rename.
**kwargs: Parameters for the action.
Returns:
A human-readable string result.
"""
if not self.user_id:
return "Error: MemoryTool requires a valid user_id."
if action_name == "view":
return self._view(
kwargs.get("path", "/"),
kwargs.get("view_range")
)
if action_name == "create":
return self._create(
kwargs.get("path", ""),
kwargs.get("file_text", "")
)
if action_name == "str_replace":
return self._str_replace(
kwargs.get("path", ""),
kwargs.get("old_str", ""),
kwargs.get("new_str", "")
)
if action_name == "insert":
return self._insert(
kwargs.get("path", ""),
kwargs.get("insert_line", 1),
kwargs.get("insert_text", "")
)
if action_name == "delete":
return self._delete(kwargs.get("path", ""))
if action_name == "rename":
return self._rename(
kwargs.get("old_path", ""),
kwargs.get("new_path", "")
)
return f"Unknown action: {action_name}"
def get_actions_metadata(self) -> List[Dict[str, Any]]:
"""Return JSON metadata describing supported actions for tool schemas."""
return [
{
"name": "view",
"description": "Shows directory contents or file contents with optional line ranges.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to file or directory (e.g., /notes.txt or /project/ or /)."
},
"view_range": {
"type": "array",
"items": {"type": "integer"},
"description": "Optional [start_line, end_line] to view specific lines (1-indexed)."
}
},
"required": ["path"]
},
},
{
"name": "create",
"description": "Create or overwrite a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path to create (e.g., /notes.txt or /project/task.txt)."
},
"file_text": {
"type": "string",
"description": "Content to write to the file."
}
},
"required": ["path", "file_text"]
},
},
{
"name": "str_replace",
"description": "Replace text in a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (e.g., /notes.txt)."
},
"old_str": {
"type": "string",
"description": "String to find."
},
"new_str": {
"type": "string",
"description": "String to replace with."
}
},
"required": ["path", "old_str", "new_str"]
},
},
{
"name": "insert",
"description": "Insert text at a specific line in a file.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path (e.g., /notes.txt)."
},
"insert_line": {
"type": "integer",
"description": "Line number to insert at (1-indexed)."
},
"insert_text": {
"type": "string",
"description": "Text to insert."
}
},
"required": ["path", "insert_line", "insert_text"]
},
},
{
"name": "delete",
"description": "Delete a file or directory.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to delete (e.g., /notes.txt or /project/)."
}
},
"required": ["path"]
},
},
{
"name": "rename",
"description": "Rename or move a file/directory.",
"parameters": {
"type": "object",
"properties": {
"old_path": {
"type": "string",
"description": "Current path (e.g., /old.txt)."
},
"new_path": {
"type": "string",
"description": "New path (e.g., /new.txt)."
}
},
"required": ["old_path", "new_path"]
},
},
]
def get_config_requirements(self) -> Dict[str, Any]:
"""Return configuration requirements."""
return {}
# -----------------------------
# Path validation
# -----------------------------
def _validate_path(self, path: str) -> Optional[str]:
"""Validate and normalize path.
Args:
path: User-provided path.
Returns:
Normalized path or None if invalid.
"""
if not path:
return None
# Remove any leading/trailing whitespace
path = path.strip()
# Preserve whether path ends with / (indicates directory)
is_directory = path.endswith("/")
# Ensure path starts with / for consistency
if not path.startswith("/"):
path = "/" + path
# Check for directory traversal patterns
if ".." in path or path.count("//") > 0:
return None
# Normalize the path
try:
# Convert to Path object and resolve to canonical form
normalized = str(Path(path).as_posix())
# Ensure it still starts with /
if not normalized.startswith("/"):
return None
# Preserve trailing slash for directories
if is_directory and not normalized.endswith("/") and normalized != "/":
normalized = normalized + "/"
return normalized
except Exception:
return None
# -----------------------------
# Internal helpers
# -----------------------------
def _view(self, path: str, view_range: Optional[List[int]] = None) -> str:
"""View directory contents or file contents."""
validated_path = self._validate_path(path)
if not validated_path:
return "Error: Invalid path."
# Check if viewing directory (ends with / or is root)
if validated_path == "/" or validated_path.endswith("/"):
return self._view_directory(validated_path)
# Otherwise view file
return self._view_file(validated_path, view_range)
def _view_directory(self, path: str) -> str:
"""List files in a directory."""
# Ensure path ends with / for proper prefix matching
search_path = path if path.endswith("/") else path + "/"
# Find all files that start with this directory path
query = {
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": {"$regex": f"^{re.escape(search_path)}"}
}
docs = list(self.collection.find(query, {"path": 1}))
if not docs:
return f"Directory: {path}\n(empty)"
# Extract filenames relative to the directory
files = []
for doc in docs:
file_path = doc["path"]
# Remove the directory prefix
if file_path.startswith(search_path):
relative = file_path[len(search_path):]
if relative:
files.append(relative)
files.sort()
file_list = "\n".join(f"- {f}" for f in files)
return f"Directory: {path}\n{file_list}"
def _view_file(self, path: str, view_range: Optional[List[int]] = None) -> str:
"""View file contents with optional line range."""
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id, "path": path})
if not doc or not doc.get("content"):
return f"Error: File not found: {path}"
content = str(doc["content"])
# Apply view_range if specified
if view_range and len(view_range) == 2:
lines = content.split("\n")
start, end = view_range
# Convert to 0-indexed
start_idx = max(0, start - 1)
end_idx = min(len(lines), end)
if start_idx >= len(lines):
return f"Error: Line range out of bounds. File has {len(lines)} lines."
selected_lines = lines[start_idx:end_idx]
# Add line numbers (enumerate with 1-based start)
numbered_lines = [f"{i}: {line}" for i, line in enumerate(selected_lines, start=start)]
return "\n".join(numbered_lines)
return content
def _create(self, path: str, file_text: str) -> str:
"""Create or overwrite a file."""
validated_path = self._validate_path(path)
if not validated_path:
return "Error: Invalid path."
if validated_path == "/" or validated_path.endswith("/"):
return "Error: Cannot create a file at directory path."
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_path},
{
"$set": {
"content": file_text,
"updated_at": datetime.now()
}
},
upsert=True
)
return f"File created: {validated_path}"
def _str_replace(self, path: str, old_str: str, new_str: str) -> str:
"""Replace text in a file."""
validated_path = self._validate_path(path)
if not validated_path:
return "Error: Invalid path."
if not old_str:
return "Error: old_str is required."
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_path})
if not doc or not doc.get("content"):
return f"Error: File not found: {validated_path}"
current_content = str(doc["content"])
# Check if old_str exists (case-insensitive)
if old_str.lower() not in current_content.lower():
return f"Error: String '{old_str}' not found in file."
# Replace the string (case-insensitive)
import re as regex_module
updated_content = regex_module.sub(regex_module.escape(old_str), new_str, current_content, flags=regex_module.IGNORECASE)
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_path},
{
"$set": {
"content": updated_content,
"updated_at": datetime.now()
}
}
)
return f"File updated: {validated_path}"
def _insert(self, path: str, insert_line: int, insert_text: str) -> str:
"""Insert text at a specific line."""
validated_path = self._validate_path(path)
if not validated_path:
return "Error: Invalid path."
if not insert_text:
return "Error: insert_text is required."
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_path})
if not doc or not doc.get("content"):
return f"Error: File not found: {validated_path}"
current_content = str(doc["content"])
lines = current_content.split("\n")
# Convert to 0-indexed
index = insert_line - 1
if index < 0 or index > len(lines):
return f"Error: Invalid line number. File has {len(lines)} lines."
lines.insert(index, insert_text)
updated_content = "\n".join(lines)
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_path},
{
"$set": {
"content": updated_content,
"updated_at": datetime.now()
}
}
)
return f"Text inserted at line {insert_line} in {validated_path}"
def _delete(self, path: str) -> str:
"""Delete a file or directory."""
validated_path = self._validate_path(path)
if not validated_path:
return "Error: Invalid path."
if validated_path == "/":
# Delete all files for this user and tool
result = self.collection.delete_many({"user_id": self.user_id, "tool_id": self.tool_id})
return f"Deleted {result.deleted_count} file(s) from memory."
# Check if it's a directory (ends with /)
if validated_path.endswith("/"):
# Delete all files in directory
result = self.collection.delete_many({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": {"$regex": f"^{re.escape(validated_path)}"}
})
return f"Deleted directory and {result.deleted_count} file(s)."
# Try to delete as directory first (without trailing slash)
# Check if any files start with this path + /
search_path = validated_path + "/"
directory_result = self.collection.delete_many({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": {"$regex": f"^{re.escape(search_path)}"}
})
if directory_result.deleted_count > 0:
return f"Deleted directory and {directory_result.deleted_count} file(s)."
# Delete single file
result = self.collection.delete_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": validated_path
})
if result.deleted_count:
return f"Deleted: {validated_path}"
return f"Error: File not found: {validated_path}"
def _rename(self, old_path: str, new_path: str) -> str:
"""Rename or move a file/directory."""
validated_old = self._validate_path(old_path)
validated_new = self._validate_path(new_path)
if not validated_old or not validated_new:
return "Error: Invalid path."
if validated_old == "/" or validated_new == "/":
return "Error: Cannot rename root directory."
# Check if renaming a directory
if validated_old.endswith("/"):
# Ensure validated_new also ends with / for proper path replacement
if not validated_new.endswith("/"):
validated_new = validated_new + "/"
# Find all files in the old directory
docs = list(self.collection.find({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": {"$regex": f"^{re.escape(validated_old)}"}
}))
if not docs:
return f"Error: Directory not found: {validated_old}"
# Update paths for all files
for doc in docs:
old_file_path = doc["path"]
new_file_path = old_file_path.replace(validated_old, validated_new, 1)
self.collection.update_one(
{"_id": doc["_id"]},
{"$set": {"path": new_file_path, "updated_at": datetime.now()}}
)
return f"Renamed directory: {validated_old} -> {validated_new} ({len(docs)} files)"
# Rename single file
doc = self.collection.find_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": validated_old
})
if not doc:
return f"Error: File not found: {validated_old}"
# Check if new path already exists
existing = self.collection.find_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": validated_new
})
if existing:
return f"Error: File already exists at {validated_new}"
# Delete the old document and create a new one with the new path
self.collection.delete_one({"user_id": self.user_id, "tool_id": self.tool_id, "path": validated_old})
self.collection.insert_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"path": validated_new,
"content": doc.get("content", ""),
"updated_at": datetime.now()
})
return f"Renamed: {validated_old} -> {validated_new}"

View File

@@ -0,0 +1,199 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
import uuid
from .base import Tool
from application.core.mongo_db import MongoDB
from application.core.settings import settings
class NotesTool(Tool):
"""Notepad
Single note. Supports viewing, overwriting, string replacement.
"""
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
"""Initialize the tool.
Args:
tool_config: Optional tool configuration. Should include:
- tool_id: Unique identifier for this notes tool instance (from user_tools._id)
This ensures each user's tool configuration has isolated notes
user_id: The authenticated user's id (should come from decoded_token["sub"]).
"""
self.user_id: Optional[str] = user_id
# Get tool_id from configuration (passed from user_tools._id in production)
# In production, tool_id is the MongoDB ObjectId string from user_tools collection
if tool_config and "tool_id" in tool_config:
self.tool_id = tool_config["tool_id"]
elif user_id:
# Fallback for backward compatibility or testing
self.tool_id = f"default_{user_id}"
else:
# Last resort fallback (shouldn't happen in normal use)
self.tool_id = str(uuid.uuid4())
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["notes"]
# -----------------------------
# Action implementations
# -----------------------------
def execute_action(self, action_name: str, **kwargs: Any) -> str:
"""Execute an action by name.
Args:
action_name: One of view, overwrite, str_replace, insert, delete.
**kwargs: Parameters for the action.
Returns:
A human-readable string result.
"""
if not self.user_id:
return "Error: NotesTool requires a valid user_id."
if action_name == "view":
return self._get_note()
if action_name == "overwrite":
return self._overwrite_note(kwargs.get("text", ""))
if action_name == "str_replace":
return self._str_replace(kwargs.get("old_str", ""), kwargs.get("new_str", ""))
if action_name == "insert":
return self._insert(kwargs.get("line_number", 1), kwargs.get("text", ""))
if action_name == "delete":
return self._delete_note()
return f"Unknown action: {action_name}"
def get_actions_metadata(self) -> List[Dict[str, Any]]:
"""Return JSON metadata describing supported actions for tool schemas."""
return [
{
"name": "view",
"description": "Retrieve the user's note.",
"parameters": {"type": "object", "properties": {}},
},
{
"name": "overwrite",
"description": "Replace the entire note content (creates if doesn't exist).",
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "New note content."}
},
"required": ["text"],
},
},
{
"name": "str_replace",
"description": "Replace occurrences of old_str with new_str in the note.",
"parameters": {
"type": "object",
"properties": {
"old_str": {"type": "string", "description": "String to find."},
"new_str": {"type": "string", "description": "String to replace with."}
},
"required": ["old_str", "new_str"],
},
},
{
"name": "insert",
"description": "Insert text at the specified line number (1-indexed).",
"parameters": {
"type": "object",
"properties": {
"line_number": {"type": "integer", "description": "Line number to insert at (1-indexed)."},
"text": {"type": "string", "description": "Text to insert."}
},
"required": ["line_number", "text"],
},
},
{
"name": "delete",
"description": "Delete the user's note.",
"parameters": {"type": "object", "properties": {}},
},
]
def get_config_requirements(self) -> Dict[str, Any]:
"""Return configuration requirements (none for now)."""
return {}
# -----------------------------
# Internal helpers (single-note)
# -----------------------------
def _get_note(self) -> str:
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
return str(doc["note"])
def _overwrite_note(self, content: str) -> str:
content = (content or "").strip()
if not content:
return "Note content required."
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": content, "updated_at": datetime.utcnow()}},
upsert=True, # ✅ create if missing
)
return "Note saved."
def _str_replace(self, old_str: str, new_str: str) -> str:
if not old_str:
return "old_str is required."
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
current_note = str(doc["note"])
# Case-insensitive search
if old_str.lower() not in current_note.lower():
return f"String '{old_str}' not found in note."
# Case-insensitive replacement
import re
updated_note = re.sub(re.escape(old_str), new_str, current_note, flags=re.IGNORECASE)
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
)
return "Note updated."
def _insert(self, line_number: int, text: str) -> str:
if not text:
return "Text is required."
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
current_note = str(doc["note"])
lines = current_note.split("\n")
# Convert to 0-indexed and validate
index = line_number - 1
if index < 0 or index > len(lines):
return f"Invalid line number. Note has {len(lines)} lines."
lines.insert(index, text)
updated_note = "\n".join(lines)
self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
)
return "Text inserted."
def _delete_note(self) -> str:
res = self.collection.delete_one({"user_id": self.user_id, "tool_id": self.tool_id})
return "Note deleted." if res.deleted_count else "No note found to delete."

View File

@@ -28,7 +28,7 @@ class ToolManager:
module = importlib.import_module(f"application.agents.tools.{tool_name}")
for member_name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Tool) and obj is not Tool:
if tool_name == "mcp_tool" and user_id:
if tool_name in {"mcp_tool", "notes", "memory"} and user_id:
return obj(tool_config, user_id)
else:
return obj(tool_config)
@@ -36,7 +36,7 @@ class ToolManager:
def execute_action(self, tool_name, action_name, user_id=None, **kwargs):
if tool_name not in self.tools:
raise ValueError(f"Tool '{tool_name}' not loaded")
if tool_name == "mcp_tool" and user_id:
if tool_name in {"mcp_tool", "memory"} and user_id:
tool_config = self.config.get(tool_name, {})
tool = self.load_tool(tool_name, tool_config, user_id)
return tool.execute_action(action_name, **kwargs)

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

@@ -0,0 +1,5 @@
"""User API module - provides all user-related API endpoints"""
from .routes import user
__all__ = ["user"]

View File

@@ -0,0 +1,7 @@
"""Agents module."""
from .routes import agents_ns
from .sharing import agents_sharing_ns
from .webhooks import agents_webhooks_ns
__all__ = ["agents_ns", "agents_sharing_ns", "agents_webhooks_ns"]

View File

@@ -0,0 +1,910 @@
"""Agent management routes."""
import datetime
import json
import uuid
from bson.dbref import DBRef
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import (
agents_collection,
db,
ensure_user_doc,
handle_image_upload,
resolve_tool_details,
storage,
users_collection,
)
from application.utils import (
check_required_fields,
generate_image_url,
validate_required_fields,
)
agents_ns = Namespace("agents", description="Agent management operations", path="/api")
@agents_ns.route("/get_agent")
class GetAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Get agent by ID")
def get(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
if not (agent_id := request.args.get("id")):
return {"success": False, "message": "ID required"}, 400
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": decoded_token["sub"]}
)
if not agent:
return {"status": "Not found"}, 404
data = {
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef)
and (source_doc := db.dereference(agent.get("source")))
else ""
),
"sources": [
(
str(db.dereference(source_ref)["_id"])
if isinstance(source_ref, DBRef) and db.dereference(source_ref)
else source_ref
)
for source_ref in agent.get("sources", [])
if (isinstance(source_ref, DBRef) and db.dereference(source_ref))
or source_ref == "default"
],
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent.get("prompt_id", ""),
"tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
if "key" in agent
else ""
),
"pinned": agent.get("pinned", False),
"shared": agent.get("shared_publicly", False),
"shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""),
}
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
return {"success": False}, 400
@agents_ns.route("/get_agents")
class GetAgents(Resource):
@api.doc(description="Retrieve agents for the user")
def get(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
try:
user_doc = ensure_user_doc(user)
pinned_ids = set(user_doc.get("agent_preferences", {}).get("pinned", []))
agents = agents_collection.find({"user": user})
list_agents = [
{
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef)
and (source_doc := db.dereference(agent.get("source")))
else (
agent.get("source", "")
if agent.get("source") == "default"
else ""
)
),
"sources": [
(
source_ref
if source_ref == "default"
else str(db.dereference(source_ref)["_id"])
)
for source_ref in agent.get("sources", [])
if source_ref == "default"
or (
isinstance(source_ref, DBRef) and db.dereference(source_ref)
)
],
"chunks": agent["chunks"],
"retriever": agent.get("retriever", ""),
"prompt_id": agent.get("prompt_id", ""),
"tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
if "key" in agent
else ""
),
"pinned": str(agent["_id"]) in pinned_ids,
"shared": agent.get("shared_publicly", False),
"shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""),
}
for agent in agents
if "source" in agent or "retriever" in agent
]
except Exception as err:
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_agents), 200)
@agents_ns.route("/create_agent")
class CreateAgent(Resource):
create_agent_model = api.model(
"CreateAgentModel",
{
"name": fields.String(required=True, description="Name of the agent"),
"description": fields.String(
required=True, description="Description of the agent"
),
"image": fields.Raw(
required=False, description="Image file upload", type="file"
),
"source": fields.String(
required=False, description="Source ID (legacy single source)"
),
"sources": fields.List(
fields.String,
required=False,
description="List of source identifiers for multiple sources",
),
"chunks": fields.Integer(required=True, description="Chunks count"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
"json_schema": fields.Raw(
required=False,
description="JSON schema for enforcing structured output format",
),
},
)
@api.expect(create_agent_model)
@api.doc(description="Create a new agent")
def post(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 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
print(f"Received data: {data}")
# Validate JSON schema if provided
if data.get("json_schema"):
try:
# Basic validation - ensure it's a valid JSON structure
json_schema = data.get("json_schema")
if not isinstance(json_schema, dict):
return make_response(
jsonify(
{
"success": False,
"message": "JSON schema must be a valid JSON object",
}
),
400,
)
# Validate that it has either a 'schema' property or is itself a schema
if "schema" not in json_schema and "type" not in json_schema:
return make_response(
jsonify(
{
"success": False,
"message": "JSON schema must contain either a 'schema' property or be a valid JSON schema with 'type' property",
}
),
400,
)
except Exception as e:
return make_response(
jsonify(
{"success": False, "message": f"Invalid JSON schema: {str(e)}"}
),
400,
)
if data.get("status") not in ["draft", "published"]:
return make_response(
jsonify(
{
"success": False,
"message": "Status must be either 'draft' or 'published'",
}
),
400,
)
if data.get("status") == "published":
required_fields = [
"name",
"description",
"chunks",
"retriever",
"prompt_id",
"agent_type",
]
# Require either source or sources (but not both)
if not data.get("source") and not data.get("sources"):
return make_response(
jsonify(
{
"success": False,
"message": "Either 'source' or 'sources' field is required for published agents",
}
),
400,
)
validate_fields = ["name", "description", "prompt_id", "agent_type"]
else:
required_fields = ["name"]
validate_fields = []
missing_fields = check_required_fields(data, required_fields)
invalid_fields = validate_required_fields(data, validate_fields)
if missing_fields:
return missing_fields
if invalid_fields:
return invalid_fields
image_url, error = handle_image_upload(request, "", user, storage)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
try:
key = str(uuid.uuid4()) if data.get("status") == "published" else ""
sources_list = []
if data.get("sources") and len(data.get("sources", [])) > 0:
for source_id in data.get("sources", []):
if source_id == "default":
sources_list.append("default")
elif ObjectId.is_valid(source_id):
sources_list.append(DBRef("sources", ObjectId(source_id)))
source_field = ""
else:
source_value = data.get("source", "")
if source_value == "default":
source_field = "default"
elif ObjectId.is_valid(source_value):
source_field = DBRef("sources", ObjectId(source_value))
else:
source_field = ""
new_agent = {
"user": user,
"name": data.get("name"),
"description": data.get("description", ""),
"image": image_url,
"source": source_field,
"sources": sources_list,
"chunks": data.get("chunks", ""),
"retriever": data.get("retriever", ""),
"prompt_id": data.get("prompt_id", ""),
"tools": data.get("tools", []),
"agent_type": data.get("agent_type", ""),
"status": data.get("status"),
"json_schema": data.get("json_schema"),
"createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None,
"key": key,
}
if new_agent["chunks"] == "":
new_agent["chunks"] = "2"
if (
new_agent["source"] == ""
and new_agent["retriever"] == ""
and not new_agent["sources"]
):
new_agent["retriever"] = "classic"
resp = agents_collection.insert_one(new_agent)
new_id = str(resp.inserted_id)
except Exception as err:
current_app.logger.error(f"Error creating agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id, "key": key}), 201)
@agents_ns.route("/update_agent/<string:agent_id>")
class UpdateAgent(Resource):
update_agent_model = api.model(
"UpdateAgentModel",
{
"name": fields.String(required=True, description="New name of the agent"),
"description": fields.String(
required=True, description="New description of the agent"
),
"image": fields.String(
required=False, description="New image URL or identifier"
),
"source": fields.String(
required=False, description="Source ID (legacy single source)"
),
"sources": fields.List(
fields.String,
required=False,
description="List of source identifiers for multiple sources",
),
"chunks": fields.Integer(required=True, description="Chunks count"),
"retriever": fields.String(required=True, description="Retriever ID"),
"prompt_id": fields.String(required=True, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(required=True, description="Type of the agent"),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
"json_schema": fields.Raw(
required=False,
description="JSON schema for enforcing structured output format",
),
},
)
@api.expect(update_agent_model)
@api.doc(description="Update an existing agent")
def put(self, agent_id):
if not (decoded_token := request.decoded_token):
return make_response(
jsonify({"success": False, "message": "Unauthorized"}), 401
)
user = decoded_token.get("sub")
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(
jsonify({"success": False, "message": "Database error finding agent"}),
500,
)
if not existing_agent:
return make_response(
jsonify(
{"success": False, "message": "Agent not found or not authorized"}
),
404,
)
image_url, error = handle_image_upload(
request, existing_agent.get("image", ""), user, storage
)
if error:
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",
"description",
"image",
"source",
"sources",
"chunks",
"retriever",
"prompt_id",
"tools",
"agent_type",
"status",
"json_schema",
]
for field in allowed_fields:
if field not in data:
continue
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": f"Invalid source ID in list: {source_id}",
}
),
400,
)
update_fields[field] = valid_sources
else:
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 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": "Agent name",
"description": "Agent description",
"chunks": "Chunks count",
"prompt_id": "Prompt",
"agent_type": "Agent type",
}
missing_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 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(
{
"success": False,
"message": f"Cannot publish agent. Missing or invalid required fields: {', '.join(missing_published_fields)}",
}
),
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:
result = agents_collection.update_one(
{"_id": oid, "user": user}, {"$set": update_fields}
)
if result.matched_count == 0:
return make_response(
jsonify(
{
"success": False,
"message": "Agent not found or update failed",
}
),
404,
)
if result.modified_count == 0 and result.matched_count == 1:
return make_response(
jsonify(
{
"success": True,
"message": "No changes detected",
"id": agent_id,
}
),
200,
)
except Exception as err:
current_app.logger.error(
f"Error updating agent {agent_id}: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Database error during update"}),
500,
)
response_data = {
"success": True,
"id": agent_id,
"message": "Agent updated successfully",
}
if newly_generated_key:
response_data["key"] = newly_generated_key
return make_response(jsonify(response_data), 200)
@agents_ns.route("/delete_agent")
class DeleteAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Delete an agent by ID")
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
deleted_agent = agents_collection.find_one_and_delete(
{"_id": ObjectId(agent_id), "user": user}
)
if not deleted_agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
deleted_id = str(deleted_agent["_id"])
except Exception as err:
current_app.logger.error(f"Error deleting agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": deleted_id}), 200)
@agents_ns.route("/pinned_agents")
class PinnedAgents(Resource):
@api.doc(description="Get pinned agents for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
user_doc = ensure_user_doc(user_id)
pinned_ids = user_doc.get("agent_preferences", {}).get("pinned", [])
if not pinned_ids:
return make_response(jsonify([]), 200)
pinned_object_ids = [ObjectId(agent_id) for agent_id in pinned_ids]
pinned_agents_cursor = agents_collection.find(
{"_id": {"$in": pinned_object_ids}}
)
pinned_agents = list(pinned_agents_cursor)
existing_ids = {str(agent["_id"]) for agent in pinned_agents}
# Clean up any stale pinned IDs
stale_ids = [
agent_id for agent_id in pinned_ids if agent_id not in existing_ids
]
if stale_ids:
users_collection.update_one(
{"user_id": user_id},
{"$pullAll": {"agent_preferences.pinned": stale_ids}},
)
list_pinned_agents = [
{
"id": str(agent["_id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": (
str(db.dereference(agent["source"])["_id"])
if "source" in agent
and agent["source"]
and isinstance(agent["source"], DBRef)
and db.dereference(agent["source"]) is not None
else ""
),
"chunks": agent.get("chunks", ""),
"retriever": agent.get("retriever", ""),
"prompt_id": agent.get("prompt_id", ""),
"tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
if "key" in agent
else ""
),
"pinned": True,
}
for agent in pinned_agents
if "source" in agent or "retriever" in agent
]
except Exception as err:
current_app.logger.error(f"Error retrieving pinned agents: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_pinned_agents), 200)
@agents_ns.route("/pin_agent")
class PinAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Pin or unpin an agent")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
agent = agents_collection.find_one({"_id": ObjectId(agent_id)})
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
user_doc = ensure_user_doc(user_id)
pinned_list = user_doc.get("agent_preferences", {}).get("pinned", [])
if agent_id in pinned_list:
users_collection.update_one(
{"user_id": user_id},
{"$pull": {"agent_preferences.pinned": agent_id}},
)
action = "unpinned"
else:
users_collection.update_one(
{"user_id": user_id},
{"$addToSet": {"agent_preferences.pinned": agent_id}},
)
action = "pinned"
except Exception as err:
current_app.logger.error(f"Error pinning/unpinning agent: {err}")
return make_response(
jsonify({"success": False, "message": "Server error"}), 500
)
return make_response(jsonify({"success": True, "action": action}), 200)
@agents_ns.route("/remove_shared_agent")
class RemoveSharedAgent(Resource):
@api.doc(
params={"id": "ID of the shared agent"},
description="Remove a shared agent from the current user's shared list",
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "shared_publicly": True}
)
if not agent:
return make_response(
jsonify({"success": False, "message": "Shared agent not found"}),
404,
)
ensure_user_doc(user_id)
users_collection.update_one(
{"user_id": user_id},
{
"$pull": {
"agent_preferences.shared_with_me": agent_id,
"agent_preferences.pinned": agent_id,
}
},
)
return make_response(jsonify({"success": True, "action": "removed"}), 200)
except Exception as err:
current_app.logger.error(f"Error removing shared agent: {err}")
return make_response(
jsonify({"success": False, "message": "Server error"}), 500
)

View File

@@ -0,0 +1,254 @@
"""Agent management sharing functionality."""
import datetime
import secrets
from bson import DBRef
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import (
agents_collection,
db,
ensure_user_doc,
resolve_tool_details,
user_tools_collection,
users_collection,
)
from application.utils import generate_image_url
agents_sharing_ns = Namespace(
"agents", description="Agent management operations", path="/api"
)
@agents_sharing_ns.route("/shared_agent")
class SharedAgent(Resource):
@api.doc(
params={
"token": "Shared token of the agent",
},
description="Get a shared agent by token or ID",
)
def get(self):
shared_token = request.args.get("token")
if not shared_token:
return make_response(
jsonify({"success": False, "message": "Token or ID is required"}), 400
)
try:
query = {
"shared_publicly": True,
"shared_token": shared_token,
}
shared_agent = agents_collection.find_one(query)
if not shared_agent:
return make_response(
jsonify({"success": False, "message": "Shared agent not found"}),
404,
)
agent_id = str(shared_agent["_id"])
data = {
"id": agent_id,
"user": shared_agent.get("user", ""),
"name": shared_agent.get("name", ""),
"image": (
generate_image_url(shared_agent["image"])
if shared_agent.get("image")
else ""
),
"description": shared_agent.get("description", ""),
"source": (
str(source_doc["_id"])
if isinstance(shared_agent.get("source"), DBRef)
and (source_doc := db.dereference(shared_agent.get("source")))
else ""
),
"chunks": shared_agent.get("chunks", "0"),
"retriever": shared_agent.get("retriever", "classic"),
"prompt_id": shared_agent.get("prompt_id", "default"),
"tools": shared_agent.get("tools", []),
"tool_details": resolve_tool_details(shared_agent.get("tools", [])),
"agent_type": shared_agent.get("agent_type", ""),
"status": shared_agent.get("status", ""),
"json_schema": shared_agent.get("json_schema"),
"created_at": shared_agent.get("createdAt", ""),
"updated_at": shared_agent.get("updatedAt", ""),
"shared": shared_agent.get("shared_publicly", False),
"shared_token": shared_agent.get("shared_token", ""),
"shared_metadata": shared_agent.get("shared_metadata", {}),
}
if data["tools"]:
enriched_tools = []
for tool in data["tools"]:
tool_data = user_tools_collection.find_one({"_id": ObjectId(tool)})
if tool_data:
enriched_tools.append(tool_data.get("name", ""))
data["tools"] = enriched_tools
decoded_token = getattr(request, "decoded_token", None)
if decoded_token:
user_id = decoded_token.get("sub")
owner_id = shared_agent.get("user")
if user_id != owner_id:
ensure_user_doc(user_id)
users_collection.update_one(
{"user_id": user_id},
{"$addToSet": {"agent_preferences.shared_with_me": agent_id}},
)
return make_response(jsonify(data), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving shared agent: {err}")
return make_response(jsonify({"success": False}), 400)
@agents_sharing_ns.route("/shared_agents")
class SharedAgents(Resource):
@api.doc(description="Get shared agents explicitly shared with the user")
def get(self):
try:
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
user_doc = ensure_user_doc(user_id)
shared_with_ids = user_doc.get("agent_preferences", {}).get(
"shared_with_me", []
)
shared_object_ids = [ObjectId(id) for id in shared_with_ids]
shared_agents_cursor = agents_collection.find(
{"_id": {"$in": shared_object_ids}, "shared_publicly": True}
)
shared_agents = list(shared_agents_cursor)
found_ids_set = {str(agent["_id"]) for agent in shared_agents}
stale_ids = [id for id in shared_with_ids if id not in found_ids_set]
if stale_ids:
users_collection.update_one(
{"user_id": user_id},
{"$pullAll": {"agent_preferences.shared_with_me": stale_ids}},
)
pinned_ids = set(user_doc.get("agent_preferences", {}).get("pinned", []))
list_shared_agents = [
{
"id": str(agent["_id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"pinned": str(agent["_id"]) in pinned_ids,
"shared": agent.get("shared_publicly", False),
"shared_token": agent.get("shared_token", ""),
"shared_metadata": agent.get("shared_metadata", {}),
}
for agent in shared_agents
]
return make_response(jsonify(list_shared_agents), 200)
except Exception as err:
current_app.logger.error(f"Error retrieving shared agents: {err}")
return make_response(jsonify({"success": False}), 400)
@agents_sharing_ns.route("/share_agent")
class ShareAgent(Resource):
@api.expect(
api.model(
"ShareAgentModel",
{
"id": fields.String(required=True, description="ID of the agent"),
"shared": fields.Boolean(
required=True, description="Share or unshare the agent"
),
"username": fields.String(
required=False, description="Name of the user"
),
},
)
)
@api.doc(description="Share or unshare an agent")
def put(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
if not data:
return make_response(
jsonify({"success": False, "message": "Missing JSON body"}), 400
)
agent_id = data.get("id")
shared = data.get("shared")
username = data.get("username", "")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
if shared is None:
return make_response(
jsonify(
{
"success": False,
"message": "Shared parameter is required and must be true or false",
}
),
400,
)
try:
try:
agent_oid = ObjectId(agent_id)
except Exception:
return make_response(
jsonify({"success": False, "message": "Invalid agent ID"}), 400
)
agent = agents_collection.find_one({"_id": agent_oid, "user": user})
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
if shared:
shared_metadata = {
"shared_by": username,
"shared_at": datetime.datetime.now(datetime.timezone.utc),
}
shared_token = secrets.token_urlsafe(32)
agents_collection.update_one(
{"_id": agent_oid, "user": user},
{
"$set": {
"shared_publicly": shared,
"shared_metadata": shared_metadata,
"shared_token": shared_token,
}
},
)
else:
agents_collection.update_one(
{"_id": agent_oid, "user": user},
{"$set": {"shared_publicly": shared, "shared_token": None}},
{"$unset": {"shared_metadata": ""}},
)
except Exception as err:
current_app.logger.error(f"Error sharing/unsharing agent: {err}")
return make_response(jsonify({"success": False, "error": str(err)}), 400)
shared_token = shared_token if shared else None
return make_response(
jsonify({"success": True, "shared_token": shared_token}), 200
)

View File

@@ -0,0 +1,119 @@
"""Agent management webhook handlers."""
import secrets
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import Namespace, Resource
from application.api import api
from application.api.user.base import agents_collection, require_agent
from application.api.user.tasks import process_agent_webhook
from application.core.settings import settings
agents_webhooks_ns = Namespace(
"agents", description="Agent management operations", path="/api"
)
@agents_webhooks_ns.route("/agent_webhook")
class AgentWebhook(Resource):
@api.doc(
params={"id": "ID of the agent"},
description="Generate webhook URL for the agent",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": user}
)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
webhook_token = agent.get("incoming_webhook_token")
if not webhook_token:
webhook_token = secrets.token_urlsafe(32)
agents_collection.update_one(
{"_id": ObjectId(agent_id), "user": user},
{"$set": {"incoming_webhook_token": webhook_token}},
)
base_url = settings.API_URL.rstrip("/")
full_webhook_url = f"{base_url}/api/webhooks/agents/{webhook_token}"
except Exception as err:
current_app.logger.error(
f"Error generating webhook URL: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Error generating webhook URL"}),
400,
)
return make_response(
jsonify({"success": True, "webhook_url": full_webhook_url}), 200
)
@agents_webhooks_ns.route("/webhooks/agents/<string:webhook_token>")
class AgentWebhookListener(Resource):
method_decorators = [require_agent]
def _enqueue_webhook_task(self, agent_id_str, payload, source_method):
if not payload:
current_app.logger.warning(
f"Webhook ({source_method}) received for agent {agent_id_str} with empty payload."
)
current_app.logger.info(
f"Incoming {source_method} webhook for agent {agent_id_str}. Enqueuing task with payload: {payload}"
)
try:
task = process_agent_webhook.delay(
agent_id=agent_id_str,
payload=payload,
)
current_app.logger.info(
f"Task {task.id} enqueued for agent {agent_id_str} ({source_method})."
)
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
except Exception as err:
current_app.logger.error(
f"Error enqueuing webhook task ({source_method}) for agent {agent_id_str}: {err}",
exc_info=True,
)
return make_response(
jsonify({"success": False, "message": "Error processing webhook"}), 500
)
@api.doc(
description="Webhook listener for agent events (POST). Expects JSON payload, which is used to trigger processing.",
)
def post(self, webhook_token, agent, agent_id_str):
payload = request.get_json()
if payload is None:
return make_response(
jsonify(
{
"success": False,
"message": "Invalid or missing JSON data in request body",
}
),
400,
)
return self._enqueue_webhook_task(agent_id_str, payload, source_method="POST")
@api.doc(
description="Webhook listener for agent events (GET). Uses URL query parameters as payload to trigger processing.",
)
def get(self, webhook_token, agent, agent_id_str):
payload = request.args.to_dict(flat=True)
return self._enqueue_webhook_task(agent_id_str, payload, source_method="GET")

View File

@@ -0,0 +1,5 @@
"""Analytics module."""
from .routes import analytics_ns
__all__ = ["analytics_ns"]

View File

@@ -0,0 +1,540 @@
"""Analytics and reporting routes."""
import datetime
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import (
agents_collection,
conversations_collection,
generate_date_range,
generate_hourly_range,
generate_minute_range,
token_usage_collection,
user_logs_collection,
)
analytics_ns = Namespace(
"analytics", description="Analytics and reporting operations", path="/api"
)
@analytics_ns.route("/get_message_analytics")
class GetMessageAnalytics(Resource):
get_message_analytics_model = api.model(
"GetMessageAnalyticsModel",
{
"api_key_id": fields.String(required=False, description="API Key ID"),
"filter_option": fields.String(
required=False,
description="Filter option for analytics",
default="last_30_days",
enum=[
"last_hour",
"last_24_hour",
"last_7_days",
"last_15_days",
"last_30_days",
],
),
},
)
@api.expect(get_message_analytics_model)
@api.doc(description="Get message analytics based on filter option")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
except Exception as err:
current_app.logger.error(f"Error getting API key: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else 14 if filter_option == "last_15_days" else 29
)
else:
return make_response(
jsonify({"success": False, "message": "Invalid option"}), 400
)
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(
hour=23, minute=59, second=59, microsecond=999999
)
group_format = "%Y-%m-%d"
try:
match_stage = {
"$match": {
"user": user,
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
pipeline = [
match_stage,
{"$unwind": "$queries"},
{
"$match": {
"queries.timestamp": {"$gte": start_date, "$lte": end_date}
}
},
{
"$group": {
"_id": {
"$dateToString": {
"format": group_format,
"date": "$queries.timestamp",
}
},
"count": {"$sum": 1},
}
},
{"$sort": {"_id": 1}},
]
message_data = conversations_collection.aggregate(pipeline)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_messages = {interval: 0 for interval in intervals}
for entry in message_data:
daily_messages[entry["_id"]] = entry["count"]
except Exception as err:
current_app.logger.error(
f"Error getting message analytics: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify({"success": True, "messages": daily_messages}), 200
)
@analytics_ns.route("/get_token_analytics")
class GetTokenAnalytics(Resource):
get_token_analytics_model = api.model(
"GetTokenAnalyticsModel",
{
"api_key_id": fields.String(required=False, description="API Key ID"),
"filter_option": fields.String(
required=False,
description="Filter option for analytics",
default="last_30_days",
enum=[
"last_hour",
"last_24_hour",
"last_7_days",
"last_15_days",
"last_30_days",
],
),
},
)
@api.expect(get_token_analytics_model)
@api.doc(description="Get token analytics data")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
except Exception as err:
current_app.logger.error(f"Error getting API key: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
group_stage = {
"$group": {
"_id": {
"minute": {
"$dateToString": {
"format": group_format,
"date": "$timestamp",
}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
group_stage = {
"$group": {
"_id": {
"hour": {
"$dateToString": {
"format": group_format,
"date": "$timestamp",
}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else (14 if filter_option == "last_15_days" else 29)
)
else:
return make_response(
jsonify({"success": False, "message": "Invalid option"}), 400
)
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(
hour=23, minute=59, second=59, microsecond=999999
)
group_format = "%Y-%m-%d"
group_stage = {
"$group": {
"_id": {
"day": {
"$dateToString": {
"format": group_format,
"date": "$timestamp",
}
}
},
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
}
try:
match_stage = {
"$match": {
"user_id": user,
"timestamp": {"$gte": start_date, "$lte": end_date},
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
token_usage_data = token_usage_collection.aggregate(
[
match_stage,
group_stage,
{"$sort": {"_id": 1}},
]
)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_token_usage = {interval: 0 for interval in intervals}
for entry in token_usage_data:
if filter_option == "last_hour":
daily_token_usage[entry["_id"]["minute"]] = entry["total_tokens"]
elif filter_option == "last_24_hour":
daily_token_usage[entry["_id"]["hour"]] = entry["total_tokens"]
else:
daily_token_usage[entry["_id"]["day"]] = entry["total_tokens"]
except Exception as err:
current_app.logger.error(
f"Error getting token analytics: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify({"success": True, "token_usage": daily_token_usage}), 200
)
@analytics_ns.route("/get_feedback_analytics")
class GetFeedbackAnalytics(Resource):
get_feedback_analytics_model = api.model(
"GetFeedbackAnalyticsModel",
{
"api_key_id": fields.String(required=False, description="API Key ID"),
"filter_option": fields.String(
required=False,
description="Filter option for analytics",
default="last_30_days",
enum=[
"last_hour",
"last_24_hour",
"last_7_days",
"last_15_days",
"last_30_days",
],
),
},
)
@api.expect(get_feedback_analytics_model)
@api.doc(description="Get feedback analytics data")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
api_key_id = data.get("api_key_id")
filter_option = data.get("filter_option", "last_30_days")
try:
api_key = (
agents_collection.find_one({"_id": ObjectId(api_key_id), "user": user})[
"key"
]
if api_key_id
else None
)
except Exception as err:
current_app.logger.error(f"Error getting API key: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
end_date = datetime.datetime.now(datetime.timezone.utc)
if filter_option == "last_hour":
start_date = end_date - datetime.timedelta(hours=1)
group_format = "%Y-%m-%d %H:%M:00"
date_field = {
"$dateToString": {
"format": group_format,
"date": "$queries.feedback_timestamp",
}
}
elif filter_option == "last_24_hour":
start_date = end_date - datetime.timedelta(hours=24)
group_format = "%Y-%m-%d %H:00"
date_field = {
"$dateToString": {
"format": group_format,
"date": "$queries.feedback_timestamp",
}
}
else:
if filter_option in ["last_7_days", "last_15_days", "last_30_days"]:
filter_days = (
6
if filter_option == "last_7_days"
else (14 if filter_option == "last_15_days" else 29)
)
else:
return make_response(
jsonify({"success": False, "message": "Invalid option"}), 400
)
start_date = end_date - datetime.timedelta(days=filter_days)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end_date.replace(
hour=23, minute=59, second=59, microsecond=999999
)
group_format = "%Y-%m-%d"
date_field = {
"$dateToString": {
"format": group_format,
"date": "$queries.feedback_timestamp",
}
}
try:
match_stage = {
"$match": {
"queries.feedback_timestamp": {
"$gte": start_date,
"$lte": end_date,
},
"queries.feedback": {"$exists": True},
}
}
if api_key:
match_stage["$match"]["api_key"] = api_key
pipeline = [
match_stage,
{"$unwind": "$queries"},
{"$match": {"queries.feedback": {"$exists": True}}},
{
"$group": {
"_id": {"time": date_field, "feedback": "$queries.feedback"},
"count": {"$sum": 1},
}
},
{
"$group": {
"_id": "$_id.time",
"positive": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "LIKE"]},
"$count",
0,
]
}
},
"negative": {
"$sum": {
"$cond": [
{"$eq": ["$_id.feedback", "DISLIKE"]},
"$count",
0,
]
}
},
}
},
{"$sort": {"_id": 1}},
]
feedback_data = conversations_collection.aggregate(pipeline)
if filter_option == "last_hour":
intervals = generate_minute_range(start_date, end_date)
elif filter_option == "last_24_hour":
intervals = generate_hourly_range(start_date, end_date)
else:
intervals = generate_date_range(start_date, end_date)
daily_feedback = {
interval: {"positive": 0, "negative": 0} for interval in intervals
}
for entry in feedback_data:
daily_feedback[entry["_id"]] = {
"positive": entry["positive"],
"negative": entry["negative"],
}
except Exception as err:
current_app.logger.error(
f"Error getting feedback analytics: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify({"success": True, "feedback": daily_feedback}), 200
)
@analytics_ns.route("/get_user_logs")
class GetUserLogs(Resource):
get_user_logs_model = api.model(
"GetUserLogsModel",
{
"page": fields.Integer(
required=False,
description="Page number for pagination",
default=1,
),
"api_key_id": fields.String(required=False, description="API Key ID"),
"page_size": fields.Integer(
required=False,
description="Number of logs per page",
default=10,
),
},
)
@api.expect(get_user_logs_model)
@api.doc(description="Get user logs with pagination")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
page = int(data.get("page", 1))
api_key_id = data.get("api_key_id")
page_size = int(data.get("page_size", 10))
skip = (page - 1) * page_size
try:
api_key = (
agents_collection.find_one({"_id": ObjectId(api_key_id)})["key"]
if api_key_id
else None
)
except Exception as err:
current_app.logger.error(f"Error getting API key: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
query = {"user": user}
if api_key:
query = {"api_key": api_key}
items_cursor = (
user_logs_collection.find(query)
.sort("timestamp", -1)
.skip(skip)
.limit(page_size + 1)
)
items = list(items_cursor)
results = [
{
"id": str(item.get("_id")),
"action": item.get("action"),
"level": item.get("level"),
"user": item.get("user"),
"question": item.get("question"),
"sources": item.get("sources"),
"retriever_params": item.get("retriever_params"),
"timestamp": item.get("timestamp"),
}
for item in items[:page_size]
]
has_more = len(items) > page_size
return make_response(
jsonify(
{
"success": True,
"logs": results,
"page": page,
"page_size": page_size,
"has_more": has_more,
}
),
200,
)

View File

@@ -0,0 +1,5 @@
"""Attachments module."""
from .routes import attachments_ns
__all__ = ["attachments_ns"]

View File

@@ -0,0 +1,150 @@
"""File attachments and media routes."""
import os
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import agents_collection, storage
from application.api.user.tasks import store_attachment
from application.core.settings import settings
from application.tts.google_tts import GoogleTTS
from application.utils import safe_filename
attachments_ns = Namespace(
"attachments", description="File attachments and media operations", path="/api"
)
@attachments_ns.route("/store_attachment")
class StoreAttachment(Resource):
@api.expect(
api.model(
"AttachmentModel",
{
"file": fields.Raw(required=True, description="File to upload"),
"api_key": fields.String(
required=False, description="API key (optional)"
),
},
)
)
@api.doc(
description="Stores a single attachment without vectorization or training. Supports user or API key authentication."
)
def post(self):
decoded_token = getattr(request, "decoded_token", None)
api_key = request.form.get("api_key") or request.args.get("api_key")
file = request.files.get("file")
if not file or file.filename == "":
return make_response(
jsonify({"status": "error", "message": "Missing file"}),
400,
)
user = None
if decoded_token:
user = safe_filename(decoded_token.get("sub"))
elif api_key:
agent = agents_collection.find_one({"key": api_key})
if not agent:
return make_response(
jsonify({"success": False, "message": "Invalid API key"}), 401
)
user = safe_filename(agent.get("user"))
else:
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
try:
attachment_id = ObjectId()
original_filename = safe_filename(os.path.basename(file.filename))
relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}"
metadata = storage.save_file(file, relative_path)
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id),
"path": relative_path,
"metadata": metadata,
}
task = store_attachment.delay(file_info, user)
return make_response(
jsonify(
{
"success": True,
"task_id": task.id,
"message": "File uploaded successfully. Processing started.",
}
),
200,
)
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": str(err)}), 400)
@attachments_ns.route("/images/<path:image_path>")
class ServeImage(Resource):
@api.doc(description="Serve an image from storage")
def get(self, image_path):
try:
file_obj = storage.get_file(image_path)
extension = image_path.split(".")[-1].lower()
content_type = f"image/{extension}"
if extension == "jpg":
content_type = "image/jpeg"
response = make_response(file_obj.read())
response.headers.set("Content-Type", content_type)
response.headers.set("Cache-Control", "max-age=86400")
return response
except FileNotFoundError:
return make_response(
jsonify({"success": False, "message": "Image not found"}), 404
)
except Exception as e:
current_app.logger.error(f"Error serving image: {e}")
return make_response(
jsonify({"success": False, "message": "Error retrieving image"}), 500
)
@attachments_ns.route("/tts")
class TextToSpeech(Resource):
tts_model = api.model(
"TextToSpeechModel",
{
"text": fields.String(
required=True, description="Text to be synthesized as audio"
),
},
)
@api.expect(tts_model)
@api.doc(description="Synthesize audio speech from text")
def post(self):
data = request.get_json()
text = data["text"]
try:
tts_instance = GoogleTTS()
audio_base64, detected_language = tts_instance.text_to_speech(text)
return make_response(
jsonify(
{
"success": True,
"audio_base64": audio_base64,
"lang": detected_language,
}
),
200,
)
except Exception as err:
current_app.logger.error(f"Error synthesizing audio: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)

View File

@@ -0,0 +1,222 @@
"""
Shared utilities, database connections, and helper functions for user API routes.
"""
import datetime
import os
import uuid
from functools import wraps
from typing import Optional, Tuple
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, Response
from pymongo import ReturnDocument
from werkzeug.utils import secure_filename
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.storage.storage_creator import StorageCreator
from application.vectorstore.vector_creator import VectorCreator
storage = StorageCreator.get_storage()
mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME]
conversations_collection = db["conversations"]
sources_collection = db["sources"]
prompts_collection = db["prompts"]
feedback_collection = db["feedback"]
agents_collection = db["agents"]
token_usage_collection = db["token_usage"]
shared_conversations_collections = db["shared_conversations"]
users_collection = db["users"]
user_logs_collection = db["user_logs"]
user_tools_collection = db["user_tools"]
attachments_collection = db["attachments"]
try:
agents_collection.create_index(
[("shared", 1)],
name="shared_index",
background=True,
)
users_collection.create_index("user_id", unique=True)
except Exception as e:
print("Error creating indexes:", e)
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def generate_minute_range(start_date, end_date):
"""Generate a dictionary with minute-level time ranges."""
return {
(start_date + datetime.timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 60) + 1)
}
def generate_hourly_range(start_date, end_date):
"""Generate a dictionary with hourly time ranges."""
return {
(start_date + datetime.timedelta(hours=i)).strftime("%Y-%m-%d %H:00"): 0
for i in range(int((end_date - start_date).total_seconds() // 3600) + 1)
}
def generate_date_range(start_date, end_date):
"""Generate a dictionary with daily date ranges."""
return {
(start_date + datetime.timedelta(days=i)).strftime("%Y-%m-%d"): 0
for i in range((end_date - start_date).days + 1)
}
def ensure_user_doc(user_id):
"""
Ensure user document exists with proper agent preferences structure.
Args:
user_id: The user ID to ensure
Returns:
The user document
"""
default_prefs = {
"pinned": [],
"shared_with_me": [],
}
user_doc = users_collection.find_one_and_update(
{"user_id": user_id},
{"$setOnInsert": {"agent_preferences": default_prefs}},
upsert=True,
return_document=ReturnDocument.AFTER,
)
prefs = user_doc.get("agent_preferences", {})
updates = {}
if "pinned" not in prefs:
updates["agent_preferences.pinned"] = []
if "shared_with_me" not in prefs:
updates["agent_preferences.shared_with_me"] = []
if updates:
users_collection.update_one({"user_id": user_id}, {"$set": updates})
user_doc = users_collection.find_one({"user_id": user_id})
return user_doc
def resolve_tool_details(tool_ids):
"""
Resolve tool IDs to their details.
Args:
tool_ids: List of tool IDs
Returns:
List of tool details with id, name, and display_name
"""
tools = user_tools_collection.find(
{"_id": {"$in": [ObjectId(tid) for tid in tool_ids]}}
)
return [
{
"id": str(tool["_id"]),
"name": tool.get("name", ""),
"display_name": tool.get("displayName", tool.get("name", "")),
}
for tool in tools
]
def get_vector_store(source_id):
"""
Get the Vector Store for a given source ID.
Args:
source_id (str): source id of the document
Returns:
Vector store instance
"""
store = VectorCreator.create_vectorstore(
settings.VECTOR_STORE,
source_id=source_id,
embeddings_key=os.getenv("EMBEDDINGS_KEY"),
)
return store
def handle_image_upload(
request, existing_url: str, user: str, storage, base_path: str = "attachments/"
) -> Tuple[str, Optional[Response]]:
"""
Handle image file upload from request.
Args:
request: Flask request object
existing_url: Existing image URL (fallback)
user: User ID
storage: Storage instance
base_path: Base path for upload
Returns:
Tuple of (image_url, error_response)
"""
image_url = existing_url
if "image" in request.files:
file = request.files["image"]
if file.filename != "":
filename = secure_filename(file.filename)
upload_path = f"{settings.UPLOAD_FOLDER.rstrip('/')}/{user}/{base_path.rstrip('/')}/{uuid.uuid4()}_{filename}"
try:
storage.save_file(file, upload_path, storage_class="STANDARD")
image_url = upload_path
except Exception as e:
current_app.logger.error(f"Error uploading image: {e}")
return None, make_response(
jsonify({"success": False, "message": "Image upload failed"}),
400,
)
return image_url, None
def require_agent(func):
"""
Decorator to require valid agent webhook token.
Args:
func: Function to decorate
Returns:
Wrapped function
"""
@wraps(func)
def wrapper(*args, **kwargs):
webhook_token = kwargs.get("webhook_token")
if not webhook_token:
return make_response(
jsonify({"success": False, "message": "Webhook token missing"}), 400
)
agent = agents_collection.find_one(
{"incoming_webhook_token": webhook_token}, {"_id": 1}
)
if not agent:
current_app.logger.warning(
f"Webhook attempt with invalid token: {webhook_token}"
)
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
kwargs["agent"] = agent
kwargs["agent_id_str"] = str(agent["_id"])
return func(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,5 @@
"""Conversation management module."""
from .routes import conversations_ns
__all__ = ["conversations_ns"]

View File

@@ -0,0 +1,280 @@
"""Conversation management routes."""
import datetime
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import attachments_collection, conversations_collection
from application.utils import check_required_fields
conversations_ns = Namespace(
"conversations", description="Conversation management operations", path="/api"
)
@conversations_ns.route("/delete_conversation")
class DeleteConversation(Resource):
@api.doc(
description="Deletes a conversation by ID",
params={"id": "The ID of the conversation to delete"},
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
conversation_id = request.args.get("id")
if not conversation_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
conversations_collection.delete_one(
{"_id": ObjectId(conversation_id), "user": decoded_token["sub"]}
)
except Exception as err:
current_app.logger.error(
f"Error deleting conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/delete_all_conversations")
class DeleteAllConversations(Resource):
@api.doc(
description="Deletes all conversations for a specific user",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
conversations_collection.delete_many({"user": user_id})
except Exception as err:
current_app.logger.error(
f"Error deleting all conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/get_conversations")
class GetConversations(Resource):
@api.doc(
description="Retrieve a list of the latest 30 conversations (excluding API key conversations)",
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
try:
conversations = (
conversations_collection.find(
{
"$or": [
{"api_key": {"$exists": False}},
{"agent_id": {"$exists": True}},
],
"user": decoded_token.get("sub"),
}
)
.sort("date", -1)
.limit(30)
)
list_conversations = [
{
"id": str(conversation["_id"]),
"name": conversation["name"],
"agent_id": conversation.get("agent_id", None),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
}
for conversation in conversations
]
except Exception as err:
current_app.logger.error(
f"Error retrieving conversations: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_conversations), 200)
@conversations_ns.route("/get_single_conversation")
class GetSingleConversation(Resource):
@api.doc(
description="Retrieve a single conversation by ID",
params={"id": "The conversation ID"},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
conversation_id = request.args.get("id")
if not conversation_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
conversation = conversations_collection.find_one(
{"_id": ObjectId(conversation_id), "user": decoded_token.get("sub")}
)
if not conversation:
return make_response(jsonify({"status": "not found"}), 404)
# Process queries to include attachment names
queries = conversation["queries"]
for query in queries:
if "attachments" in query and query["attachments"]:
attachment_details = []
for attachment_id in query["attachments"]:
try:
attachment = attachments_collection.find_one(
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
except Exception as err:
current_app.logger.error(
f"Error retrieving conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
data = {
"queries": queries,
"agent_id": conversation.get("agent_id"),
"is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None),
}
return make_response(jsonify(data), 200)
@conversations_ns.route("/update_conversation_name")
class UpdateConversationName(Resource):
@api.expect(
api.model(
"UpdateConversationModel",
{
"id": fields.String(required=True, description="Conversation ID"),
"name": fields.String(
required=True, description="New name of the conversation"
),
},
)
)
@api.doc(
description="Updates the name of a conversation",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["id", "name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
conversations_collection.update_one(
{"_id": ObjectId(data["id"]), "user": decoded_token.get("sub")},
{"$set": {"name": data["name"]}},
)
except Exception as err:
current_app.logger.error(
f"Error updating conversation name: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@conversations_ns.route("/feedback")
class SubmitFeedback(Resource):
@api.expect(
api.model(
"FeedbackModel",
{
"question": fields.String(
required=False, description="The user question"
),
"answer": fields.String(required=False, description="The AI answer"),
"feedback": fields.String(required=True, description="User feedback"),
"question_index": fields.Integer(
required=True,
description="The question number in that particular conversation",
),
"conversation_id": fields.String(
required=True, description="id of the particular conversation"
),
"api_key": fields.String(description="Optional API key"),
},
)
)
@api.doc(
description="Submit feedback for a conversation",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["feedback", "conversation_id", "question_index"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
if data["feedback"] is None:
# Remove feedback and feedback_timestamp if feedback is null
conversations_collection.update_one(
{
"_id": ObjectId(data["conversation_id"]),
"user": decoded_token.get("sub"),
f"queries.{data['question_index']}": {"$exists": True},
},
{
"$unset": {
f"queries.{data['question_index']}.feedback": "",
f"queries.{data['question_index']}.feedback_timestamp": "",
}
},
)
else:
# Set feedback and feedback_timestamp if feedback has a value
conversations_collection.update_one(
{
"_id": ObjectId(data["conversation_id"]),
"user": decoded_token.get("sub"),
f"queries.{data['question_index']}": {"$exists": True},
},
{
"$set": {
f"queries.{data['question_index']}.feedback": data[
"feedback"
],
f"queries.{data['question_index']}.feedback_timestamp": datetime.datetime.now(
datetime.timezone.utc
),
}
},
)
except Exception as err:
current_app.logger.error(f"Error submitting feedback: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)

View File

@@ -0,0 +1,5 @@
"""Prompts module."""
from .routes import prompts_ns
__all__ = ["prompts_ns"]

View File

@@ -0,0 +1,191 @@
"""Prompt management routes."""
import os
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import current_dir, prompts_collection
from application.utils import check_required_fields
prompts_ns = Namespace(
"prompts", description="Prompt management operations", path="/api"
)
@prompts_ns.route("/create_prompt")
class CreatePrompt(Resource):
create_prompt_model = api.model(
"CreatePromptModel",
{
"content": fields.String(
required=True, description="Content of the prompt"
),
"name": fields.String(required=True, description="Name of the prompt"),
},
)
@api.expect(create_prompt_model)
@api.doc(description="Create a new prompt")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.get_json()
required_fields = ["content", "name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
user = decoded_token.get("sub")
try:
resp = prompts_collection.insert_one(
{
"name": data["name"],
"content": data["content"],
"user": user,
}
)
new_id = str(resp.inserted_id)
except Exception as err:
current_app.logger.error(f"Error creating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id}), 200)
@prompts_ns.route("/get_prompts")
class GetPrompts(Resource):
@api.doc(description="Get all prompts for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
prompts = prompts_collection.find({"user": user})
list_prompts = [
{"id": "default", "name": "default", "type": "public"},
{"id": "creative", "name": "creative", "type": "public"},
{"id": "strict", "name": "strict", "type": "public"},
]
for prompt in prompts:
list_prompts.append(
{
"id": str(prompt["_id"]),
"name": prompt["name"],
"type": "private",
}
)
except Exception as err:
current_app.logger.error(f"Error retrieving prompts: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_prompts), 200)
@prompts_ns.route("/get_single_prompt")
class GetSinglePrompt(Resource):
@api.doc(params={"id": "ID of the prompt"}, description="Get a single prompt by ID")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
prompt_id = request.args.get("id")
if not prompt_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
if prompt_id == "default":
with open(
os.path.join(current_dir, "prompts", "chat_combine_default.txt"),
"r",
) as f:
chat_combine_template = f.read()
return make_response(jsonify({"content": chat_combine_template}), 200)
elif prompt_id == "creative":
with open(
os.path.join(current_dir, "prompts", "chat_combine_creative.txt"),
"r",
) as f:
chat_reduce_creative = f.read()
return make_response(jsonify({"content": chat_reduce_creative}), 200)
elif prompt_id == "strict":
with open(
os.path.join(current_dir, "prompts", "chat_combine_strict.txt"), "r"
) as f:
chat_reduce_strict = f.read()
return make_response(jsonify({"content": chat_reduce_strict}), 200)
prompt = prompts_collection.find_one(
{"_id": ObjectId(prompt_id), "user": user}
)
except Exception as err:
current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"content": prompt["content"]}), 200)
@prompts_ns.route("/delete_prompt")
class DeletePrompt(Resource):
delete_prompt_model = api.model(
"DeletePromptModel",
{"id": fields.String(required=True, description="Prompt ID to delete")},
)
@api.expect(delete_prompt_model)
@api.doc(description="Delete a prompt by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
prompts_collection.delete_one({"_id": ObjectId(data["id"]), "user": user})
except Exception as err:
current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@prompts_ns.route("/update_prompt")
class UpdatePrompt(Resource):
update_prompt_model = api.model(
"UpdatePromptModel",
{
"id": fields.String(required=True, description="Prompt ID to update"),
"name": fields.String(required=True, description="New name of the prompt"),
"content": fields.String(
required=True, description="New content of the prompt"
),
},
)
@api.expect(update_prompt_model)
@api.doc(description="Update an existing prompt")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "name", "content"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
prompts_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": {"name": data["name"], "content": data["content"]}},
)
except Exception as err:
current_app.logger.error(f"Error updating prompt: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
"""Sharing module."""
from .routes import sharing_ns
__all__ = ["sharing_ns"]

View File

@@ -0,0 +1,301 @@
"""Conversation sharing routes."""
import uuid
from bson.binary import Binary, UuidRepresentation
from bson.dbref import DBRef
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, inputs, Namespace, Resource
from application.api import api
from application.api.user.base import (
agents_collection,
attachments_collection,
conversations_collection,
db,
shared_conversations_collections,
)
from application.utils import check_required_fields
sharing_ns = Namespace(
"sharing", description="Conversation sharing operations", path="/api"
)
@sharing_ns.route("/share")
class ShareConversation(Resource):
share_conversation_model = api.model(
"ShareConversationModel",
{
"conversation_id": fields.String(
required=True, description="Conversation ID"
),
"user": fields.String(description="User ID (optional)"),
"prompt_id": fields.String(description="Prompt ID (optional)"),
"chunks": fields.Integer(description="Chunks count (optional)"),
},
)
@api.expect(share_conversation_model)
@api.doc(description="Share a conversation")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["conversation_id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
is_promptable = request.args.get("isPromptable", type=inputs.boolean)
if is_promptable is None:
return make_response(
jsonify({"success": False, "message": "isPromptable is required"}), 400
)
conversation_id = data["conversation_id"]
try:
conversation = conversations_collection.find_one(
{"_id": ObjectId(conversation_id)}
)
if conversation is None:
return make_response(
jsonify(
{
"status": "error",
"message": "Conversation does not exist",
}
),
404,
)
current_n_queries = len(conversation["queries"])
explicit_binary = Binary.from_uuid(
uuid.uuid4(), UuidRepresentation.STANDARD
)
if is_promptable:
prompt_id = data.get("prompt_id", "default")
chunks = data.get("chunks", "2")
name = conversation["name"] + "(shared)"
new_api_key_data = {
"prompt_id": prompt_id,
"chunks": chunks,
"user": user,
}
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key_data["source"] = DBRef(
"sources", ObjectId(data["source"])
)
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
pre_existing_api_document = agents_collection.find_one(new_api_key_data)
if pre_existing_api_document:
api_uuid = pre_existing_api_document["key"]
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
"api_key": api_uuid,
}
)
if pre_existing is not None:
return make_response(
jsonify(
{
"success": True,
"identifier": str(pre_existing["uuid"].as_uuid()),
}
),
200,
)
else:
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
"api_key": api_uuid,
}
)
return make_response(
jsonify(
{
"success": True,
"identifier": str(explicit_binary.as_uuid()),
}
),
201,
)
else:
api_uuid = str(uuid.uuid4())
new_api_key_data["key"] = api_uuid
new_api_key_data["name"] = name
if "source" in data and ObjectId.is_valid(data["source"]):
new_api_key_data["source"] = DBRef(
"sources", ObjectId(data["source"])
)
if "retriever" in data:
new_api_key_data["retriever"] = data["retriever"]
agents_collection.insert_one(new_api_key_data)
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
"api_key": api_uuid,
}
)
return make_response(
jsonify(
{
"success": True,
"identifier": str(explicit_binary.as_uuid()),
}
),
201,
)
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
}
)
if pre_existing is not None:
return make_response(
jsonify(
{
"success": True,
"identifier": str(pre_existing["uuid"].as_uuid()),
}
),
200,
)
else:
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
}
)
return make_response(
jsonify(
{"success": True, "identifier": str(explicit_binary.as_uuid())}
),
201,
)
except Exception as err:
current_app.logger.error(
f"Error sharing conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
@sharing_ns.route("/shared_conversation/<string:identifier>")
class GetPubliclySharedConversations(Resource):
@api.doc(description="Get publicly shared conversations by identifier")
def get(self, identifier: str):
try:
query_uuid = Binary.from_uuid(
uuid.UUID(identifier), UuidRepresentation.STANDARD
)
shared = shared_conversations_collections.find_one({"uuid": query_uuid})
conversation_queries = []
if (
shared
and "conversation_id" in shared
and isinstance(shared["conversation_id"], DBRef)
):
conversation_ref = shared["conversation_id"]
conversation = db.dereference(conversation_ref)
if conversation is None:
return make_response(
jsonify(
{
"success": False,
"error": "might have broken url or the conversation does not exist",
}
),
404,
)
conversation_queries = conversation["queries"][
: (shared["first_n_queries"])
]
for query in conversation_queries:
if "attachments" in query and query["attachments"]:
attachment_details = []
for attachment_id in query["attachments"]:
try:
attachment = attachments_collection.find_one(
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
else:
return make_response(
jsonify(
{
"success": False,
"error": "might have broken url or the conversation does not exist",
}
),
404,
)
date = conversation["_id"].generation_time.isoformat()
res = {
"success": True,
"queries": conversation_queries,
"title": conversation["name"],
"timestamp": date,
}
if shared["isPromptable"] and "api_key" in shared:
res["api_key"] = shared["api_key"]
return make_response(jsonify(res), 200)
except Exception as err:
current_app.logger.error(
f"Error getting shared conversation: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)

View File

@@ -0,0 +1,7 @@
"""Sources module."""
from .chunks import sources_chunks_ns
from .routes import sources_ns
from .upload import sources_upload_ns
__all__ = ["sources_ns", "sources_chunks_ns", "sources_upload_ns"]

View File

@@ -0,0 +1,278 @@
"""Source document management chunk management."""
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import get_vector_store, sources_collection
from application.utils import check_required_fields, num_tokens_from_string
sources_chunks_ns = Namespace(
"sources", description="Source document management operations", path="/api"
)
@sources_chunks_ns.route("/get_chunks")
class GetChunks(Resource):
@api.doc(
description="Retrieves chunks from a document, optionally filtered by file path and search term",
params={
"id": "The document ID",
"page": "Page number for pagination",
"per_page": "Number of chunks per page",
"path": "Optional: Filter chunks by relative file path",
"search": "Optional: Search term to filter chunks by title or content",
},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
path = request.args.get("path")
search_term = request.args.get("search", "").strip().lower()
if not ObjectId.is_valid(doc_id):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
doc = sources_collection.find_one({"_id": ObjectId(doc_id), "user": user})
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
try:
store = get_vector_store(doc_id)
chunks = store.get_chunks()
filtered_chunks = []
for chunk in chunks:
metadata = chunk.get("metadata", {})
# Filter by path if provided
if path:
chunk_source = metadata.get("source", "")
# Check if the chunk's source matches the requested path
if not chunk_source or not chunk_source.endswith(path):
continue
# Filter by search term if provided
if search_term:
text_match = search_term in chunk.get("text", "").lower()
title_match = search_term in metadata.get("title", "").lower()
if not (text_match or title_match):
continue
filtered_chunks.append(chunk)
chunks = filtered_chunks
total_chunks = len(chunks)
start = (page - 1) * per_page
end = start + per_page
paginated_chunks = chunks[start:end]
return make_response(
jsonify(
{
"page": page,
"per_page": per_page,
"total": total_chunks,
"chunks": paginated_chunks,
"path": path if path else None,
"search": search_term if search_term else None,
}
),
200,
)
except Exception as e:
current_app.logger.error(f"Error getting chunks: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/add_chunk")
class AddChunk(Resource):
@api.expect(
api.model(
"AddChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"text": fields.String(required=True, description="Text of the chunk"),
"metadata": fields.Raw(
required=False,
description="Metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Adds a new chunk to the document",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "text"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
text = data.get("text")
metadata = data.get("metadata", {})
token_count = num_tokens_from_string(text)
metadata["token_count"] = token_count
if not ObjectId.is_valid(doc_id):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
doc = sources_collection.find_one({"_id": ObjectId(doc_id), "user": user})
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
try:
store = get_vector_store(doc_id)
chunk_id = store.add_chunk(text, metadata)
return make_response(
jsonify({"message": "Chunk added successfully", "chunk_id": chunk_id}),
201,
)
except Exception as e:
current_app.logger.error(f"Error adding chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/delete_chunk")
class DeleteChunk(Resource):
@api.doc(
description="Deletes a specific chunk from the document.",
params={"id": "The document ID", "chunk_id": "The ID of the chunk to delete"},
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
chunk_id = request.args.get("chunk_id")
if not ObjectId.is_valid(doc_id):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
doc = sources_collection.find_one({"_id": ObjectId(doc_id), "user": user})
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
try:
store = get_vector_store(doc_id)
deleted = store.delete_chunk(chunk_id)
if deleted:
return make_response(
jsonify({"message": "Chunk deleted successfully"}), 200
)
else:
return make_response(
jsonify({"message": "Chunk not found or could not be deleted"}),
404,
)
except Exception as e:
current_app.logger.error(f"Error deleting chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
@sources_chunks_ns.route("/update_chunk")
class UpdateChunk(Resource):
@api.expect(
api.model(
"UpdateChunkModel",
{
"id": fields.String(required=True, description="Document ID"),
"chunk_id": fields.String(
required=True, description="Chunk ID to update"
),
"text": fields.String(
required=False, description="New text of the chunk"
),
"metadata": fields.Raw(
required=False,
description="Updated metadata associated with the chunk",
),
},
)
)
@api.doc(
description="Updates an existing chunk in the document.",
)
def put(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "chunk_id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
doc_id = data.get("id")
chunk_id = data.get("chunk_id")
text = data.get("text")
metadata = data.get("metadata")
if text is not None:
token_count = num_tokens_from_string(text)
if metadata is None:
metadata = {}
metadata["token_count"] = token_count
if not ObjectId.is_valid(doc_id):
return make_response(jsonify({"error": "Invalid doc_id"}), 400)
doc = sources_collection.find_one({"_id": ObjectId(doc_id), "user": user})
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
try:
store = get_vector_store(doc_id)
chunks = store.get_chunks()
existing_chunk = next((c for c in chunks if c["doc_id"] == chunk_id), None)
if not existing_chunk:
return make_response(jsonify({"error": "Chunk not found"}), 404)
new_text = text if text is not None else existing_chunk["text"]
if metadata is not None:
new_metadata = existing_chunk["metadata"].copy()
new_metadata.update(metadata)
else:
new_metadata = existing_chunk["metadata"].copy()
if text is not None:
new_metadata["token_count"] = num_tokens_from_string(new_text)
try:
new_chunk_id = store.add_chunk(new_text, new_metadata)
deleted = store.delete_chunk(chunk_id)
if not deleted:
current_app.logger.warning(
f"Failed to delete old chunk {chunk_id}, but new chunk {new_chunk_id} was created"
)
return make_response(
jsonify(
{
"message": "Chunk updated successfully",
"chunk_id": new_chunk_id,
"original_chunk_id": chunk_id,
}
),
200,
)
except Exception as add_error:
current_app.logger.error(f"Failed to add updated chunk: {add_error}")
return make_response(
jsonify({"error": "Failed to update chunk - addition failed"}), 500
)
except Exception as e:
current_app.logger.error(f"Error updating chunk: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 500)

View File

@@ -0,0 +1,350 @@
"""Source document management routes."""
import json
import math
import os
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
from werkzeug.utils import secure_filename
from application.api import api
from application.api.user.base import sources_collection
from application.core.settings import settings
from application.storage.storage_creator import StorageCreator
from application.utils import check_required_fields
from application.vectorstore.vector_creator import VectorCreator
sources_ns = Namespace(
"sources", description="Source document management operations", path="/api"
)
@sources_ns.route("/sources")
class CombinedJson(Resource):
@api.doc(description="Provide JSON file with combined available indexes")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = [
{
"name": "Default",
"date": "default",
"model": settings.EMBEDDINGS_NAME,
"location": "remote",
"tokens": "",
"retriever": "classic",
}
]
try:
for index in sources_collection.find({"user": user}).sort("date", -1):
data.append(
{
"id": str(index["_id"]),
"name": index.get("name"),
"date": index.get("date"),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": index.get("tokens", ""),
"retriever": index.get("retriever", "classic"),
"syncFrequency": index.get("sync_frequency", ""),
"is_nested": bool(index.get("directory_structure")),
"type": index.get(
"type", "file"
), # Add type field with default "file"
}
)
except Exception as err:
current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(data), 200)
@sources_ns.route("/sources/paginated")
class PaginatedSources(Resource):
@api.doc(description="Get document with pagination, sorting and filtering")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
sort_field = request.args.get("sort", "date") # Default to 'date'
sort_order = request.args.get("order", "desc") # Default to 'desc'
page = int(request.args.get("page", 1)) # Default to 1
rows_per_page = int(request.args.get("rows", 10)) # Default to 10
# add .strip() to remove leading and trailing whitespaces
search_term = request.args.get(
"search", ""
).strip() # add search for filter documents
# Prepare query for filtering
query = {"user": user}
if search_term:
query["name"] = {
"$regex": search_term,
"$options": "i", # using case-insensitive search
}
total_documents = sources_collection.count_documents(query)
total_pages = max(1, math.ceil(total_documents / rows_per_page))
page = min(
max(1, page), total_pages
) # add this to make sure page inbound is within the range
sort_order = 1 if sort_order == "asc" else -1
skip = (page - 1) * rows_per_page
try:
documents = (
sources_collection.find(query)
.sort(sort_field, sort_order)
.skip(skip)
.limit(rows_per_page)
)
paginated_docs = []
for doc in documents:
doc_data = {
"id": str(doc["_id"]),
"name": doc.get("name", ""),
"date": doc.get("date", ""),
"model": settings.EMBEDDINGS_NAME,
"location": "local",
"tokens": doc.get("tokens", ""),
"retriever": doc.get("retriever", "classic"),
"syncFrequency": doc.get("sync_frequency", ""),
"isNested": bool(doc.get("directory_structure")),
"type": doc.get("type", "file"),
}
paginated_docs.append(doc_data)
response = {
"total": total_documents,
"totalPages": total_pages,
"currentPage": page,
"paginated": paginated_docs,
}
return make_response(jsonify(response), 200)
except Exception as err:
current_app.logger.error(
f"Error retrieving paginated sources: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
@sources_ns.route("/docs_check")
class CheckDocs(Resource):
check_docs_model = api.model(
"CheckDocsModel",
{"docs": fields.String(required=True, description="Document name")},
)
@api.expect(check_docs_model)
@api.doc(description="Check if document exists")
def post(self):
data = request.get_json()
required_fields = ["docs"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
vectorstore = "vectors/" + secure_filename(data["docs"])
if os.path.exists(vectorstore) or data["docs"] == "default":
return {"status": "exists"}, 200
except Exception as err:
current_app.logger.error(f"Error checking document: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"status": "not found"}), 404)
@sources_ns.route("/delete_by_ids")
class DeleteByIds(Resource):
@api.doc(
description="Deletes documents from the vector store by IDs",
params={"path": "Comma-separated list of IDs"},
)
def get(self):
ids = request.args.get("path")
if not ids:
return make_response(
jsonify({"success": False, "message": "Missing required fields"}), 400
)
try:
result = sources_collection.delete_index(ids=ids)
if result:
return make_response(jsonify({"success": True}), 200)
except Exception as err:
current_app.logger.error(f"Error deleting indexes: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": False}), 400)
@sources_ns.route("/delete_old")
class DeleteOldIndexes(Resource):
@api.doc(
description="Deletes old indexes and associated files",
params={"source_id": "The source ID to delete"},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
source_id = request.args.get("source_id")
if not source_id:
return make_response(
jsonify({"success": False, "message": "Missing required fields"}), 400
)
doc = sources_collection.find_one(
{"_id": ObjectId(source_id), "user": decoded_token.get("sub")}
)
if not doc:
return make_response(jsonify({"status": "not found"}), 404)
storage = StorageCreator.get_storage()
try:
# Delete vector index
if settings.VECTOR_STORE == "faiss":
index_path = f"indexes/{str(doc['_id'])}"
if storage.file_exists(f"{index_path}/index.faiss"):
storage.delete_file(f"{index_path}/index.faiss")
if storage.file_exists(f"{index_path}/index.pkl"):
storage.delete_file(f"{index_path}/index.pkl")
else:
vectorstore = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, source_id=str(doc["_id"])
)
vectorstore.delete_index()
if "file_path" in doc and doc["file_path"]:
file_path = doc["file_path"]
if storage.is_directory(file_path):
files = storage.list_files(file_path)
for f in files:
storage.delete_file(f)
else:
storage.delete_file(file_path)
except FileNotFoundError:
pass
except Exception as err:
current_app.logger.error(
f"Error deleting files and indexes: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
sources_collection.delete_one({"_id": ObjectId(source_id)})
return make_response(jsonify({"success": True}), 200)
@sources_ns.route("/combine")
class RedirectToSources(Resource):
@api.doc(
description="Redirects /api/combine to /api/sources for backward compatibility"
)
def get(self):
return redirect("/api/sources", code=301)
@sources_ns.route("/manage_sync")
class ManageSync(Resource):
manage_sync_model = api.model(
"ManageSyncModel",
{
"source_id": fields.String(required=True, description="Source ID"),
"sync_frequency": fields.String(
required=True,
description="Sync frequency (never, daily, weekly, monthly)",
),
},
)
@api.expect(manage_sync_model)
@api.doc(description="Manage sync frequency for sources")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["source_id", "sync_frequency"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
source_id = data["source_id"]
sync_frequency = data["sync_frequency"]
if sync_frequency not in ["never", "daily", "weekly", "monthly"]:
return make_response(
jsonify({"success": False, "message": "Invalid frequency"}), 400
)
update_data = {"$set": {"sync_frequency": sync_frequency}}
try:
sources_collection.update_one(
{
"_id": ObjectId(source_id),
"user": user,
},
update_data,
)
except Exception as err:
current_app.logger.error(
f"Error updating sync frequency: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@sources_ns.route("/directory_structure")
class DirectoryStructure(Resource):
@api.doc(
description="Get the directory structure for a document",
params={"id": "The document ID"},
)
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
doc_id = request.args.get("id")
if not doc_id:
return make_response(jsonify({"error": "Document ID is required"}), 400)
if not ObjectId.is_valid(doc_id):
return make_response(jsonify({"error": "Invalid document ID"}), 400)
try:
doc = sources_collection.find_one({"_id": ObjectId(doc_id), "user": user})
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
)
directory_structure = doc.get("directory_structure", {})
base_path = doc.get("file_path", "")
provider = None
remote_data = doc.get("remote_data")
try:
if isinstance(remote_data, str) and remote_data:
remote_data_obj = json.loads(remote_data)
provider = remote_data_obj.get("provider")
except Exception as e:
current_app.logger.warning(
f"Failed to parse remote_data for doc {doc_id}: {e}"
)
return make_response(
jsonify(
{
"success": True,
"directory_structure": directory_structure,
"base_path": base_path,
"provider": provider,
}
),
200,
)
except Exception as e:
current_app.logger.error(
f"Error retrieving directory structure: {e}", exc_info=True
)
return make_response(jsonify({"success": False, "error": str(e)}), 500)

View File

@@ -0,0 +1,572 @@
"""Source document management upload functionality."""
import json
import os
import tempfile
import zipfile
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.api.user.base import sources_collection
from application.api.user.tasks import ingest, ingest_connector_task, ingest_remote
from application.core.settings import settings
from application.parser.connectors.connector_creator import ConnectorCreator
from application.storage.storage_creator import StorageCreator
from application.utils import check_required_fields, safe_filename
sources_upload_ns = Namespace(
"sources", description="Source document management operations", path="/api"
)
@sources_upload_ns.route("/upload")
class UploadFile(Resource):
@api.expect(
api.model(
"UploadModel",
{
"user": fields.String(required=True, description="User ID"),
"name": fields.String(required=True, description="Job name"),
"file": fields.Raw(required=True, description="File(s) to upload"),
},
)
)
@api.doc(
description="Uploads a file to be vectorized and indexed",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.form
files = request.files.getlist("file")
required_fields = ["user", "name"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields or not files or all(file.filename == "" for file in files):
return make_response(
jsonify(
{
"status": "error",
"message": "Missing required fields or files",
}
),
400,
)
user = decoded_token.get("sub")
job_name = request.form["name"]
# Create safe versions for filesystem operations
safe_user = safe_filename(user)
dir_name = safe_filename(job_name)
base_path = f"{settings.UPLOAD_FOLDER}/{safe_user}/{dir_name}"
try:
storage = StorageCreator.get_storage()
for file in files:
original_filename = file.filename
safe_file = safe_filename(original_filename)
with tempfile.TemporaryDirectory() as temp_dir:
temp_file_path = os.path.join(temp_dir, safe_file)
file.save(temp_file_path)
if zipfile.is_zipfile(temp_file_path):
try:
with zipfile.ZipFile(temp_file_path, "r") as zip_ref:
zip_ref.extractall(path=temp_dir)
# Walk through extracted files and upload them
for root, _, files in os.walk(temp_dir):
for extracted_file in files:
if (
os.path.join(root, extracted_file)
== temp_file_path
):
continue
rel_path = os.path.relpath(
os.path.join(root, extracted_file), temp_dir
)
storage_path = f"{base_path}/{rel_path}"
with open(
os.path.join(root, extracted_file), "rb"
) as f:
storage.save_file(f, storage_path)
except Exception as e:
current_app.logger.error(
f"Error extracting zip: {e}", exc_info=True
)
# If zip extraction fails, save the original zip file
file_path = f"{base_path}/{safe_file}"
with open(temp_file_path, "rb") as f:
storage.save_file(f, file_path)
else:
# For non-zip files, save directly
file_path = f"{base_path}/{safe_file}"
with open(temp_file_path, "rb") as f:
storage.save_file(f, file_path)
task = ingest.delay(
settings.UPLOAD_FOLDER,
[
".rst",
".md",
".pdf",
".txt",
".docx",
".csv",
".epub",
".html",
".mdx",
".json",
".xlsx",
".pptx",
".png",
".jpg",
".jpeg",
],
job_name,
user,
file_path=base_path,
filename=dir_name,
)
except Exception as err:
current_app.logger.error(f"Error uploading file: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
@sources_upload_ns.route("/remote")
class UploadRemote(Resource):
@api.expect(
api.model(
"RemoteUploadModel",
{
"user": fields.String(required=True, description="User ID"),
"source": fields.String(
required=True, description="Source of the data"
),
"name": fields.String(required=True, description="Job name"),
"data": fields.String(required=True, description="Data to process"),
"repo_url": fields.String(description="GitHub repository URL"),
},
)
)
@api.doc(
description="Uploads remote source for vectorization",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.form
required_fields = ["user", "source", "name", "data"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = json.loads(data["data"])
source_data = None
if data["source"] == "github":
source_data = config.get("repo_url")
elif data["source"] in ["crawler", "url"]:
source_data = config.get("url")
elif data["source"] == "reddit":
source_data = config
elif data["source"] in ConnectorCreator.get_supported_connectors():
session_token = config.get("session_token")
if not session_token:
return make_response(
jsonify(
{
"success": False,
"error": f"Missing session_token in {data['source']} configuration",
}
),
400,
)
# Process file_ids
file_ids = config.get("file_ids", [])
if isinstance(file_ids, str):
file_ids = [id.strip() for id in file_ids.split(",") if id.strip()]
elif not isinstance(file_ids, list):
file_ids = []
# Process folder_ids
folder_ids = config.get("folder_ids", [])
if isinstance(folder_ids, str):
folder_ids = [
id.strip() for id in folder_ids.split(",") if id.strip()
]
elif not isinstance(folder_ids, list):
folder_ids = []
config["file_ids"] = file_ids
config["folder_ids"] = folder_ids
task = ingest_connector_task.delay(
job_name=data["name"],
user=decoded_token.get("sub"),
source_type=data["source"],
session_token=session_token,
file_ids=file_ids,
folder_ids=folder_ids,
recursive=config.get("recursive", False),
retriever=config.get("retriever", "classic"),
)
return make_response(
jsonify({"success": True, "task_id": task.id}), 200
)
task = ingest_remote.delay(
source_data=source_data,
job_name=data["name"],
user=decoded_token.get("sub"),
loader=data["source"],
)
except Exception as err:
current_app.logger.error(
f"Error uploading remote source: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
@sources_upload_ns.route("/manage_source_files")
class ManageSourceFiles(Resource):
@api.expect(
api.model(
"ManageSourceFilesModel",
{
"source_id": fields.String(
required=True, description="Source ID to modify"
),
"operation": fields.String(
required=True,
description="Operation: 'add', 'remove', or 'remove_directory'",
),
"file_paths": fields.List(
fields.String,
required=False,
description="File paths to remove (for remove operation)",
),
"directory_path": fields.String(
required=False,
description="Directory path to remove (for remove_directory operation)",
),
"file": fields.Raw(
required=False, description="Files to add (for add operation)"
),
"parent_dir": fields.String(
required=False,
description="Parent directory path relative to source root",
),
},
)
)
@api.doc(
description="Add files, remove files, or remove directories from an existing source",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(
jsonify({"success": False, "message": "Unauthorized"}), 401
)
user = decoded_token.get("sub")
source_id = request.form.get("source_id")
operation = request.form.get("operation")
if not source_id or not operation:
return make_response(
jsonify(
{
"success": False,
"message": "source_id and operation are required",
}
),
400,
)
if operation not in ["add", "remove", "remove_directory"]:
return make_response(
jsonify(
{
"success": False,
"message": "operation must be 'add', 'remove', or 'remove_directory'",
}
),
400,
)
try:
ObjectId(source_id)
except Exception:
return make_response(
jsonify({"success": False, "message": "Invalid source ID format"}), 400
)
try:
source = sources_collection.find_one(
{"_id": ObjectId(source_id), "user": user}
)
if not source:
return make_response(
jsonify(
{
"success": False,
"message": "Source not found or access denied",
}
),
404,
)
except Exception as err:
current_app.logger.error(f"Error finding source: {err}", exc_info=True)
return make_response(
jsonify({"success": False, "message": "Database error"}), 500
)
try:
storage = StorageCreator.get_storage()
source_file_path = source.get("file_path", "")
parent_dir = request.form.get("parent_dir", "")
if parent_dir and (parent_dir.startswith("/") or ".." in parent_dir):
return make_response(
jsonify(
{"success": False, "message": "Invalid parent directory path"}
),
400,
)
if operation == "add":
files = request.files.getlist("file")
if not files or all(file.filename == "" for file in files):
return make_response(
jsonify(
{
"success": False,
"message": "No files provided for add operation",
}
),
400,
)
added_files = []
target_dir = source_file_path
if parent_dir:
target_dir = f"{source_file_path}/{parent_dir}"
for file in files:
if file.filename:
safe_filename_str = safe_filename(file.filename)
file_path = f"{target_dir}/{safe_filename_str}"
# Save file to storage
storage.save_file(file, file_path)
added_files.append(safe_filename_str)
# Trigger re-ingestion pipeline
from application.api.user.tasks import reingest_source_task
task = reingest_source_task.delay(source_id=source_id, user=user)
return make_response(
jsonify(
{
"success": True,
"message": f"Added {len(added_files)} files",
"added_files": added_files,
"parent_dir": parent_dir,
"reingest_task_id": task.id,
}
),
200,
)
elif operation == "remove":
file_paths_str = request.form.get("file_paths")
if not file_paths_str:
return make_response(
jsonify(
{
"success": False,
"message": "file_paths required for remove operation",
}
),
400,
)
try:
file_paths = (
json.loads(file_paths_str)
if isinstance(file_paths_str, str)
else file_paths_str
)
except Exception:
return make_response(
jsonify(
{"success": False, "message": "Invalid file_paths format"}
),
400,
)
# Remove files from storage and directory structure
removed_files = []
for file_path in file_paths:
full_path = f"{source_file_path}/{file_path}"
# Remove from storage
if storage.file_exists(full_path):
storage.delete_file(full_path)
removed_files.append(file_path)
# Trigger re-ingestion pipeline
from application.api.user.tasks import reingest_source_task
task = reingest_source_task.delay(source_id=source_id, user=user)
return make_response(
jsonify(
{
"success": True,
"message": f"Removed {len(removed_files)} files",
"removed_files": removed_files,
"reingest_task_id": task.id,
}
),
200,
)
elif operation == "remove_directory":
directory_path = request.form.get("directory_path")
if not directory_path:
return make_response(
jsonify(
{
"success": False,
"message": "directory_path required for remove_directory operation",
}
),
400,
)
# Validate directory path (prevent path traversal)
if directory_path.startswith("/") or ".." in directory_path:
current_app.logger.warning(
f"Invalid directory path attempted for removal. "
f"User: {user}, Source ID: {source_id}, Directory path: {directory_path}"
)
return make_response(
jsonify(
{"success": False, "message": "Invalid directory path"}
),
400,
)
full_directory_path = (
f"{source_file_path}/{directory_path}"
if directory_path
else source_file_path
)
if not storage.is_directory(full_directory_path):
current_app.logger.warning(
f"Directory not found or is not a directory for removal. "
f"User: {user}, Source ID: {source_id}, Directory path: {directory_path}, "
f"Full path: {full_directory_path}"
)
return make_response(
jsonify(
{
"success": False,
"message": "Directory not found or is not a directory",
}
),
404,
)
success = storage.remove_directory(full_directory_path)
if not success:
current_app.logger.error(
f"Failed to remove directory from storage. "
f"User: {user}, Source ID: {source_id}, Directory path: {directory_path}, "
f"Full path: {full_directory_path}"
)
return make_response(
jsonify(
{"success": False, "message": "Failed to remove directory"}
),
500,
)
current_app.logger.info(
f"Successfully removed directory. "
f"User: {user}, Source ID: {source_id}, Directory path: {directory_path}, "
f"Full path: {full_directory_path}"
)
# Trigger re-ingestion pipeline
from application.api.user.tasks import reingest_source_task
task = reingest_source_task.delay(source_id=source_id, user=user)
return make_response(
jsonify(
{
"success": True,
"message": f"Successfully removed directory: {directory_path}",
"removed_directory": directory_path,
"reingest_task_id": task.id,
}
),
200,
)
except Exception as err:
error_context = f"operation={operation}, user={user}, source_id={source_id}"
if operation == "remove_directory":
directory_path = request.form.get("directory_path", "")
error_context += f", directory_path={directory_path}"
elif operation == "remove":
file_paths_str = request.form.get("file_paths", "")
error_context += f", file_paths={file_paths_str}"
elif operation == "add":
parent_dir = request.form.get("parent_dir", "")
error_context += f", parent_dir={parent_dir}"
current_app.logger.error(
f"Error managing source files: {err} ({error_context})", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Operation failed"}), 500
)
@sources_upload_ns.route("/task_status")
class TaskStatus(Resource):
task_status_model = api.model(
"TaskStatusModel",
{"task_id": fields.String(required=True, description="Task ID")},
)
@api.expect(task_status_model)
@api.doc(description="Get celery job status")
def get(self):
task_id = request.args.get("task_id")
if not task_id:
return make_response(
jsonify({"success": False, "message": "Task ID is required"}), 400
)
try:
from application.celery_init import celery
task = celery.AsyncResult(task_id)
task_meta = task.info
print(f"Task status: {task.status}")
if not isinstance(
task_meta, (dict, list, str, int, float, bool, type(None))
):
task_meta = str(task_meta) # Convert to a string representation
except Exception as err:
current_app.logger.error(f"Error getting task status: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"status": task.status, "result": task_meta}), 200)

View File

@@ -0,0 +1,6 @@
"""Tools module."""
from .mcp import tools_mcp_ns
from .routes import tools_ns
__all__ = ["tools_ns", "tools_mcp_ns"]

View File

@@ -0,0 +1,333 @@
"""Tool management MCP server integration."""
import json
from email.quoprimime import unquote
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
from application.agents.tools.mcp_tool import MCPOAuthManager, MCPTool
from application.api import api
from application.api.user.base import user_tools_collection
from application.cache import get_redis_instance
from application.security.encryption import encrypt_credentials
from application.utils import check_required_fields
tools_mcp_ns = Namespace("tools", description="Tool management operations", path="/api")
@tools_mcp_ns.route("/mcp_server/test")
class TestMCPServerConfig(Resource):
@api.expect(
api.model(
"MCPServerTestModel",
{
"config": fields.Raw(
required=True, description="MCP server configuration to test"
),
},
)
)
@api.doc(description="Test MCP server connection with provided configuration")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = data["config"]
auth_credentials = {}
auth_type = config.get("auth_type", "none")
if auth_type == "api_key" and "api_key" in config:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer" and "bearer_token" in config:
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if "username" in config:
auth_credentials["username"] = config["username"]
if "password" in config:
auth_credentials["password"] = config["password"]
test_config = config.copy()
test_config["auth_credentials"] = auth_credentials
mcp_tool = MCPTool(config=test_config, user_id=user)
result = mcp_tool.test_connection()
return make_response(jsonify(result), 200)
except Exception as e:
current_app.logger.error(f"Error testing MCP server: {e}", exc_info=True)
return make_response(
jsonify(
{"success": False, "error": f"Connection test failed: {str(e)}"}
),
500,
)
@tools_mcp_ns.route("/mcp_server/save")
class MCPServerSave(Resource):
@api.expect(
api.model(
"MCPServerSaveModel",
{
"id": fields.String(
required=False, description="Tool ID for updates (optional)"
),
"displayName": fields.String(
required=True, description="Display name for the MCP server"
),
"config": fields.Raw(
required=True, description="MCP server configuration"
),
"status": fields.Boolean(
required=False, default=True, description="Tool status"
),
},
)
)
@api.doc(description="Create or update MCP server with automatic tool discovery")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["displayName", "config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = data["config"]
auth_credentials = {}
auth_type = config.get("auth_type", "none")
if auth_type == "api_key":
if "api_key" in config and config["api_key"]:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config["api_key_header"]
elif auth_type == "bearer":
if "bearer_token" in config and config["bearer_token"]:
auth_credentials["bearer_token"] = config["bearer_token"]
elif auth_type == "basic":
if "username" in config and config["username"]:
auth_credentials["username"] = config["username"]
if "password" in config and config["password"]:
auth_credentials["password"] = config["password"]
mcp_config = config.copy()
mcp_config["auth_credentials"] = auth_credentials
if auth_type == "oauth":
if not config.get("oauth_task_id"):
return make_response(
jsonify(
{
"success": False,
"error": "Connection not authorized. Please complete the OAuth authorization first.",
}
),
400,
)
redis_client = get_redis_instance()
manager = MCPOAuthManager(redis_client)
result = manager.get_oauth_status(config["oauth_task_id"])
if not result.get("status") == "completed":
return make_response(
jsonify(
{
"success": False,
"error": "OAuth failed or not completed. Please try authorizing again.",
}
),
400,
)
actions_metadata = result.get("tools", [])
elif auth_type == "none" or auth_credentials:
mcp_tool = MCPTool(config=mcp_config, user_id=user)
mcp_tool.discover_tools()
actions_metadata = mcp_tool.get_actions_metadata()
else:
raise Exception(
"No valid credentials provided for the selected authentication type"
)
storage_config = config.copy()
if auth_credentials:
encrypted_credentials_string = encrypt_credentials(
auth_credentials, user
)
storage_config["encrypted_credentials"] = encrypted_credentials_string
for field in [
"api_key",
"bearer_token",
"username",
"password",
"api_key_header",
]:
storage_config.pop(field, None)
transformed_actions = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
if "properties" in action["parameters"]:
for param_name, param_details in action["parameters"][
"properties"
].items():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed_actions.append(action)
tool_data = {
"name": "mcp_tool",
"displayName": data["displayName"],
"customName": data["displayName"],
"description": f"MCP Server: {storage_config.get('server_url', 'Unknown')}",
"config": storage_config,
"actions": transformed_actions,
"status": data.get("status", True),
"user": user,
}
tool_id = data.get("id")
if tool_id:
result = user_tools_collection.update_one(
{"_id": ObjectId(tool_id), "user": user, "name": "mcp_tool"},
{"$set": {k: v for k, v in tool_data.items() if k != "user"}},
)
if result.matched_count == 0:
return make_response(
jsonify(
{
"success": False,
"error": "Tool not found or access denied",
}
),
404,
)
response_data = {
"success": True,
"id": tool_id,
"message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
else:
result = user_tools_collection.insert_one(tool_data)
tool_id = str(result.inserted_id)
response_data = {
"success": True,
"id": tool_id,
"message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.",
"tools_count": len(transformed_actions),
}
return make_response(jsonify(response_data), 200)
except Exception as e:
current_app.logger.error(f"Error saving MCP server: {e}", exc_info=True)
return make_response(
jsonify(
{"success": False, "error": f"Failed to save MCP server: {str(e)}"}
),
500,
)
@tools_mcp_ns.route("/mcp_server/callback")
class MCPOAuthCallback(Resource):
@api.expect(
api.model(
"MCPServerCallbackModel",
{
"code": fields.String(required=True, description="Authorization code"),
"state": fields.String(required=True, description="State parameter"),
"error": fields.String(
required=False, description="Error message (if any)"
),
},
)
)
@api.doc(
description="Handle OAuth callback by providing the authorization code and state"
)
def get(self):
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
return redirect(
f"/api/connectors/callback-status?status=error&message=OAuth+error:+{error}.+Please+try+again+and+make+sure+to+grant+all+requested+permissions,+including+offline+access.&provider=mcp_tool"
)
if not code or not state:
return redirect(
"/api/connectors/callback-status?status=error&message=Authorization+code+or+state+not+provided.+Please+complete+the+authorization+process+and+make+sure+to+grant+offline+access.&provider=mcp_tool"
)
try:
redis_client = get_redis_instance()
if not redis_client:
return redirect(
"/api/connectors/callback-status?status=error&message=Internal+server+error:+Redis+not+available.&provider=mcp_tool"
)
code = unquote(code)
manager = MCPOAuthManager(redis_client)
success = manager.handle_oauth_callback(state, code, error)
if success:
return redirect(
"/api/connectors/callback-status?status=success&message=Authorization+code+received+successfully.+You+can+close+this+window.&provider=mcp_tool"
)
else:
return redirect(
"/api/connectors/callback-status?status=error&message=OAuth+callback+failed.&provider=mcp_tool"
)
except Exception as e:
current_app.logger.error(
f"Error handling MCP OAuth callback: {str(e)}", exc_info=True
)
return redirect(
f"/api/connectors/callback-status?status=error&message=Internal+server+error:+{str(e)}.&provider=mcp_tool"
)
@tools_mcp_ns.route("/mcp_server/oauth_status/<string:task_id>")
class MCPOAuthStatus(Resource):
def get(self, task_id):
"""
Get current status of OAuth flow.
Frontend should poll this endpoint periodically.
"""
try:
redis_client = get_redis_instance()
status_key = f"mcp_oauth_status:{task_id}"
status_data = redis_client.get(status_key)
if status_data:
status = json.loads(status_data)
return make_response(
jsonify({"success": True, "task_id": task_id, **status})
)
else:
return make_response(
jsonify(
{
"success": False,
"error": "Task not found or expired",
"task_id": task_id,
}
),
404,
)
except Exception as e:
current_app.logger.error(
f"Error getting OAuth status for task {task_id}: {str(e)}"
)
return make_response(
jsonify({"success": False, "error": str(e), "task_id": task_id}), 500
)

View File

@@ -0,0 +1,415 @@
"""Tool management routes."""
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.agents.tools.tool_manager import ToolManager
from application.api import api
from application.api.user.base import user_tools_collection
from application.security.encryption import decrypt_credentials, encrypt_credentials
from application.utils import check_required_fields, validate_function_name
tool_config = {}
tool_manager = ToolManager(config=tool_config)
tools_ns = Namespace("tools", description="Tool management operations", path="/api")
@tools_ns.route("/available_tools")
class AvailableTools(Resource):
@api.doc(description="Get available tools for a user")
def get(self):
try:
tools_metadata = []
for tool_name, tool_instance in tool_manager.tools.items():
doc = tool_instance.__doc__.strip()
lines = doc.split("\n", 1)
name = lines[0].strip()
description = lines[1].strip() if len(lines) > 1 else ""
tools_metadata.append(
{
"name": tool_name,
"displayName": name,
"description": description,
"configRequirements": tool_instance.get_config_requirements(),
}
)
except Exception as err:
current_app.logger.error(
f"Error getting available tools: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "data": tools_metadata}), 200)
@tools_ns.route("/get_tools")
class GetTools(Resource):
@api.doc(description="Get tools created by a user")
def get(self):
try:
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
tools = user_tools_collection.find({"user": user})
user_tools = []
for tool in tools:
tool["id"] = str(tool["_id"])
tool.pop("_id")
user_tools.append(tool)
except Exception as err:
current_app.logger.error(f"Error getting user tools: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "tools": user_tools}), 200)
@tools_ns.route("/create_tool")
class CreateTool(Resource):
@api.expect(
api.model(
"CreateToolModel",
{
"name": fields.String(required=True, description="Name of the tool"),
"displayName": fields.String(
required=True, description="Display name for the tool"
),
"description": fields.String(
required=True, description="Tool description"
),
"config": fields.Raw(
required=True, description="Configuration of the tool"
),
"customName": fields.String(
required=False, description="Custom name for the tool"
),
"status": fields.Boolean(
required=True, description="Status of the tool"
),
},
)
)
@api.doc(description="Create a new tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = [
"name",
"displayName",
"description",
"config",
"status",
]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
tool_instance = tool_manager.tools.get(data["name"])
if not tool_instance:
return make_response(
jsonify({"success": False, "message": "Tool not found"}), 404
)
actions_metadata = tool_instance.get_actions_metadata()
transformed_actions = []
for action in actions_metadata:
action["active"] = True
if "parameters" in action:
if "properties" in action["parameters"]:
for param_name, param_details in action["parameters"][
"properties"
].items():
param_details["filled_by_llm"] = True
param_details["value"] = ""
transformed_actions.append(action)
except Exception as err:
current_app.logger.error(
f"Error getting tool actions: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
try:
new_tool = {
"user": user,
"name": data["name"],
"displayName": data["displayName"],
"description": data["description"],
"customName": data.get("customName", ""),
"actions": transformed_actions,
"config": data["config"],
"status": data["status"],
}
resp = user_tools_collection.insert_one(new_tool)
new_id = str(resp.inserted_id)
except Exception as err:
current_app.logger.error(f"Error creating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id}), 200)
@tools_ns.route("/update_tool")
class UpdateTool(Resource):
@api.expect(
api.model(
"UpdateToolModel",
{
"id": fields.String(required=True, description="Tool ID"),
"name": fields.String(description="Name of the tool"),
"displayName": fields.String(description="Display name for the tool"),
"customName": fields.String(description="Custom name for the tool"),
"description": fields.String(description="Tool description"),
"config": fields.Raw(description="Configuration of the tool"),
"actions": fields.List(
fields.Raw, description="Actions the tool can perform"
),
"status": fields.Boolean(description="Status of the tool"),
},
)
)
@api.doc(description="Update a tool by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
update_data = {}
if "name" in data:
update_data["name"] = data["name"]
if "displayName" in data:
update_data["displayName"] = data["displayName"]
if "customName" in data:
update_data["customName"] = data["customName"]
if "description" in data:
update_data["description"] = data["description"]
if "actions" in data:
update_data["actions"] = data["actions"]
if "config" in data:
if "actions" in data["config"]:
for action_name in list(data["config"]["actions"].keys()):
if not validate_function_name(action_name):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.",
"param": "tools[].function.name",
}
),
400,
)
tool_doc = user_tools_collection.find_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if tool_doc and tool_doc.get("name") == "mcp_tool":
config = data["config"]
existing_config = tool_doc.get("config", {})
storage_config = existing_config.copy()
storage_config.update(config)
existing_credentials = {}
if "encrypted_credentials" in existing_config:
existing_credentials = decrypt_credentials(
existing_config["encrypted_credentials"], user
)
auth_credentials = existing_credentials.copy()
auth_type = storage_config.get("auth_type", "none")
if auth_type == "api_key":
if "api_key" in config and config["api_key"]:
auth_credentials["api_key"] = config["api_key"]
if "api_key_header" in config:
auth_credentials["api_key_header"] = config[
"api_key_header"
]
elif auth_type == "bearer":
if "bearer_token" in config and config["bearer_token"]:
auth_credentials["bearer_token"] = config["bearer_token"]
elif "encrypted_token" in config and config["encrypted_token"]:
auth_credentials["bearer_token"] = config["encrypted_token"]
elif auth_type == "basic":
if "username" in config and config["username"]:
auth_credentials["username"] = config["username"]
if "password" in config and config["password"]:
auth_credentials["password"] = config["password"]
if auth_type != "none" and auth_credentials:
encrypted_credentials_string = encrypt_credentials(
auth_credentials, user
)
storage_config["encrypted_credentials"] = (
encrypted_credentials_string
)
elif auth_type == "none":
storage_config.pop("encrypted_credentials", None)
for field in [
"api_key",
"bearer_token",
"encrypted_token",
"username",
"password",
"api_key_header",
]:
storage_config.pop(field, None)
update_data["config"] = storage_config
else:
update_data["config"] = data["config"]
if "status" in data:
update_data["status"] = data["status"]
user_tools_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": update_data},
)
except Exception as err:
current_app.logger.error(f"Error updating tool: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_config")
class UpdateToolConfig(Resource):
@api.expect(
api.model(
"UpdateToolConfigModel",
{
"id": fields.String(required=True, description="Tool ID"),
"config": fields.Raw(
required=True, description="Configuration of the tool"
),
},
)
)
@api.doc(description="Update the configuration of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "config"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
user_tools_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": {"config": data["config"]}},
)
except Exception as err:
current_app.logger.error(
f"Error updating tool config: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_actions")
class UpdateToolActions(Resource):
@api.expect(
api.model(
"UpdateToolActionsModel",
{
"id": fields.String(required=True, description="Tool ID"),
"actions": fields.List(
fields.Raw,
required=True,
description="Actions the tool can perform",
),
},
)
)
@api.doc(description="Update the actions of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "actions"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
user_tools_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": {"actions": data["actions"]}},
)
except Exception as err:
current_app.logger.error(
f"Error updating tool actions: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/update_tool_status")
class UpdateToolStatus(Resource):
@api.expect(
api.model(
"UpdateToolStatusModel",
{
"id": fields.String(required=True, description="Tool ID"),
"status": fields.Boolean(
required=True, description="Status of the tool"
),
},
)
)
@api.doc(description="Update the status of a tool")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id", "status"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
user_tools_collection.update_one(
{"_id": ObjectId(data["id"]), "user": user},
{"$set": {"status": data["status"]}},
)
except Exception as err:
current_app.logger.error(
f"Error updating tool status: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@tools_ns.route("/delete_tool")
class DeleteTool(Resource):
@api.expect(
api.model(
"DeleteToolModel",
{"id": fields.String(required=True, description="Tool ID")},
)
)
@api.doc(description="Delete a tool by ID")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = request.get_json()
required_fields = ["id"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
result = user_tools_collection.delete_one(
{"_id": ObjectId(data["id"]), "user": user}
)
if result.deleted_count == 0:
return {"success": False, "message": "Tool not found"}, 404
except Exception as err:
current_app.logger.error(f"Error deleting tool: {err}", exc_info=True)
return {"success": False}, 400
return {"success": True}, 200

View File

@@ -41,10 +41,15 @@ class Settings(BaseSettings):
FALLBACK_LLM_API_KEY: Optional[str] = None # api key for fallback llm
# Google Drive integration
GOOGLE_CLIENT_ID: Optional[str] = None # Replace with your actual Google OAuth client ID
GOOGLE_CLIENT_SECRET: Optional[str] = None# Replace with your actual Google OAuth client secret
CONNECTOR_REDIRECT_BASE_URI: Optional[str] = "http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp)
GOOGLE_CLIENT_ID: Optional[str] = (
None # Replace with your actual Google OAuth client ID
)
GOOGLE_CLIENT_SECRET: Optional[str] = (
None # Replace with your actual Google OAuth client secret
)
CONNECTOR_REDIRECT_BASE_URI: Optional[str] = (
"http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp)
)
# LLM Cache
CACHE_REDIS_URL: str = "redis://localhost:6379/2"
@@ -118,6 +123,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

@@ -26,7 +26,7 @@ class LocalStorage(BaseStorage):
return path
return os.path.join(self.base_dir, path)
def save_file(self, file_data: BinaryIO, path: str) -> dict:
def save_file(self, file_data: BinaryIO, path: str, **kwargs) -> dict:
"""Save a file to local storage."""
full_path = self._get_full_path(path)

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

@@ -12,7 +12,7 @@
"chart.js": "^4.4.4",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^24.2.0",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.0.2",
"lodash": "^4.17.21",
"mermaid": "^11.6.0",
@@ -321,9 +321,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.3.tgz",
"integrity": "sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw==",
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -6217,9 +6217,9 @@
}
},
"node_modules/i18next": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
"integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==",
"version": "25.5.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.5.3.tgz",
"integrity": "sha512-joFqorDeQ6YpIXni944upwnuHBf5IoPMuqAchGVeQLdWC2JOjxgM9V8UGLhNIIH/Q8QleRxIi0BSRQehSrDLcg==",
"funding": [
{
"type": "individual",
@@ -6234,8 +6234,9 @@
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"

View File

@@ -23,7 +23,7 @@
"chart.js": "^4.4.4",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^24.2.0",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.0.2",
"lodash": "^4.17.21",
"mermaid": "^11.6.0",

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3">
<path d="M240-80q-33 0-56.5-23.5T160-160v-480q0-33 23.5-56.5T240-720h80v-80q0-17 11.5-28.5T360-840q17 0 28.5 11.5T400-800v80h40v-80q0-17 11.5-28.5T480-840q17 0 28.5 11.5T520-800v80h40v-80q0-17 11.5-28.5T600-840q17 0 28.5 11.5T640-800v80h80q33 0 56.5 23.5T800-640v480q0 33-23.5 56.5T720-80H240Zm0-80h480v-480H240v480Zm120-320v-80h240v80H360Zm0 120v-80h240v80H360Zm0 120v-80h160v80H360ZM240-160v-480 480Z"/>
</svg>

After

Width:  |  Height:  |  Size: 523 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M320-240h320v-80H320v80Zm0-160h320v-80H320v80ZM240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h320l240 240v480q0 33-23.5 56.5T720-80H240Zm280-520v-200H240v640h480v-440H520ZM240-800v200-200 640-640Z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -7,6 +7,7 @@ import Agents from './agents';
import SharedAgentGate from './agents/SharedAgentGate';
import ActionButtons from './components/ActionButtons';
import Spinner from './components/Spinner';
import UploadToast from './components/UploadToast';
import Conversation from './conversation/Conversation';
import { SharedConversation } from './conversation/SharedConversation';
import { useDarkTheme, useMediaQuery } from './hooks';
@@ -33,18 +34,19 @@ 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
className={`h-[calc(100dvh-64px)] overflow-auto lg:h-screen ${
className={`h-[calc(100dvh-64px)] overflow-auto transition-all duration-300 ease-in-out lg:h-screen ${
!(isMobile || isTablet)
? `ml-0 ${!navOpen ? 'lg:mx-auto' : 'lg:ml-72'}`
? `${navOpen ? 'lg:ml-72' : 'lg:ml-0'}`
: 'ml-0 lg:ml-16'
}`}
>
<Outlet />
</div>
<UploadToast />
</div>
);
}

View File

@@ -14,7 +14,7 @@ import Github from './assets/git_nav.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
import Pin from './assets/pin.svg';
import Robot from './assets/robot.svg';
import AgentImage from './components/AgentImage';
import SettingGear from './assets/settingGear.svg';
import Spark from './assets/spark.svg';
import SpinnerDark from './assets/spinner-dark.svg';
@@ -292,20 +292,26 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
useDefaultDocument();
return (
<>
{!navOpen && (
<div className="absolute top-3 left-3 z-20 hidden transition-all duration-25 lg:block">
{(isMobile || isTablet) && navOpen && (
<div
className="fixed inset-0 z-10 bg-black opacity-50 transition-opacity duration-300"
onClick={() => setNavOpen(false)}
/>
)}
{
<div className="absolute top-3 left-3 z-20 hidden transition-all duration-300 ease-in-out lg:block">
<div className="flex items-center gap-3">
<button
onClick={() => {
setNavOpen(!navOpen);
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={Expand}
alt="Toggle navigation menu"
className={`${
!navOpen ? 'rotate-180' : 'rotate-0'
} m-auto transition-all duration-200`}
className="m-auto transition-all duration-300 ease-in-out"
/>
</button>
{queries?.length > 0 && (
@@ -313,6 +319,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
onClick={() => {
newChat();
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={openNewChat}
@@ -326,12 +333,12 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div>
</div>
</div>
)}
}
<div
ref={navRef}
className={`${
!navOpen && '-ml-96 md:-ml-72'
} bg-lotion dark:border-r-purple-taupe dark:bg-chinese-black fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-20 dark:text-white`}
} bg-lotion dark:border-r-purple-taupe dark:bg-chinese-black fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-300 ease-in-out dark:text-white`}
>
<div
className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'}
@@ -345,7 +352,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}}
>
<a href="/" className="flex gap-1.5">
<img className="mb-2 h-10" src={DocsGPT3} alt="DocsGPT Logo" />
<img className="h-10" src={DocsGPT3} alt="DocsGPT Logo" />
<p className="my-auto text-2xl font-semibold">DocsGPT</p>
</a>
</div>
@@ -358,9 +365,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<img
src={Expand}
alt="Toggle navigation menu"
className={`${
!navOpen ? 'rotate-180' : 'rotate-0'
} m-auto transition-all duration-200`}
className="m-auto transition-all duration-300 ease-in-out hover:scale-110"
/>
</button>
</div>
@@ -419,12 +424,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
>
<div className="flex items-center gap-2">
<div className="flex w-6 justify-center">
<img
src={
agent.image && agent.image.trim() !== ''
? agent.image
: Robot
}
<AgentImage
src={agent.image}
alt="agent-logo"
className="h-6 w-6 rounded-full object-contain"
/>
@@ -576,7 +577,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</NavLink>
<NavLink
target="_blank"
to={'https://twitter.com/docsgptai'}
to={'https://x.com/docsgptai'}
className={
'rounded-full hover:bg-gray-100 dark:hover:bg-[#28292E]'
}
@@ -585,7 +586,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
src={Twitter}
width={20}
height={20}
alt="Follow us on Twitter"
alt="Follow us on X"
className="m-2 self-center filter dark:invert"
/>
</NavLink>

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

@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import userService from '../api/services/userService';
import Robot from '../assets/robot.svg';
import AgentImage from '../components/AgentImage';
import ThreeDots from '../assets/three-dots.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
@@ -82,8 +82,8 @@ export default function AgentCard({
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
<AgentImage
src={agent.image}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full object-contain"
/>

View File

@@ -49,7 +49,7 @@ export default function AgentLogs() {
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="text-eerie-black m-0 text-[40px] font-bold dark:text-white">
<h1 className="text-eerie-black m-0 text-[32px] font-bold md:text-[40px] dark:text-white">
Agent Logs
</h1>
</div>

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 () => {
@@ -506,7 +527,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="text-eerie-black m-0 text-[40px] font-bold dark:text-white">
<h1 className="text-eerie-black m-0 text-[32px] font-bold lg:text-[40px] dark:text-white">
{modeConfig[effectiveMode].heading}
</h1>
<div className="flex flex-wrap items-center gap-1">
@@ -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

@@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import userService from '../api/services/userService';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import Robot from '../assets/robot.svg';
import AgentImage from '../components/AgentImage';
import MessageInput from '../components/MessageInput';
import Spinner from '../components/Spinner';
import ConversationMessages from '../conversation/ConversationMessages';
@@ -152,12 +152,8 @@ export default function SharedAgent() {
return (
<div className="relative h-full w-full">
<div className="absolute top-5 left-4 hidden items-center gap-3 sm:flex">
<img
src={
sharedAgent.image && sharedAgent.image.trim() !== ''
? sharedAgent.image
: Robot
}
<AgentImage
src={sharedAgent.image}
alt="agent-logo"
className="h-6 w-6 rounded-full object-contain"
/>

View File

@@ -1,4 +1,4 @@
import Robot from '../assets/robot.svg';
import AgentImage from '../components/AgentImage';
import { Agent } from './types';
export default function SharedAgentCard({ agent }: { agent: Agent }) {
@@ -6,8 +6,8 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) {
<div className="border-dark-gray dark:border-grey flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<img
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
<AgentImage
src={agent.image}
className="h-full w-full rounded-full object-contain"
/>
</div>

View File

@@ -8,7 +8,7 @@ import Link from '../assets/link-gray.svg';
import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg';
import Trash from '../assets/red-trash.svg';
import Robot from '../assets/robot.svg';
import AgentImage from '../components/AgentImage';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
@@ -111,7 +111,7 @@ function AgentsList() {
}, [token]);
return (
<div className="p-4 md:p-12">
<h1 className="text-eerie-black mb-0 text-[40px] font-bold dark:text-[#E0E0E0]">
<h1 className="text-eerie-black mb-0 text-[32px] font-bold lg:text-[40px] dark:text-[#E0E0E0]">
Agents
</h1>
<p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
@@ -138,11 +138,7 @@ function AgentsList() {
</button>
<div className="w-full">
<div className="flex w-full items-center px-1">
<img
src={Robot}
alt="agent-logo"
className="h-7 w-7 rounded-full"
/>
<AgentImage className="h-7 w-7 rounded-full" />
</div>
<div className="mt-2">
<p
@@ -436,8 +432,8 @@ function AgentCard({
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
<AgentImage
src={agent.image}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full object-contain"
/>

View File

@@ -0,0 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5C17.523 2.5 22 6.977 22 12.5C22 18.023 17.523 22.5 12 22.5C6.477 22.5 2 18.023 2 12.5C2 6.977 6.477 2.5 12 2.5ZM15.22 9.47L10.75 13.94L8.78 11.97C8.63783 11.8375 8.44978 11.7654 8.25548 11.7688C8.06118 11.7723 7.87579 11.851 7.73838 11.9884C7.60097 12.1258 7.52225 12.3112 7.51883 12.5055C7.5154 12.6998 7.58752 12.8878 7.72 13.03L10.22 15.53C10.3606 15.6705 10.5512 15.7493 10.75 15.7493C10.9488 15.7493 11.1394 15.6705 11.28 15.53L16.28 10.53C16.3537 10.4613 16.4128 10.3785 16.4538 10.2865C16.4948 10.1945 16.5168 10.0952 16.5186 9.99452C16.5204 9.89382 16.5018 9.79379 16.4641 9.7004C16.4264 9.60701 16.3703 9.52218 16.299 9.45096C16.2278 9.37974 16.143 9.3236 16.0496 9.28588C15.9562 9.24816 15.8562 9.22963 15.7555 9.23141C15.6548 9.23318 15.5555 9.25523 15.4635 9.29622C15.3715 9.33721 15.2887 9.39631 15.22 9.47Z" fill="#0C9D35"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1.83989C16.5202 2.71758 17.7826 3.97997 18.6603 5.50017C19.538 7.02038 20 8.74483 20 10.5002C20 12.2556 19.5379 13.98 18.6602 15.5002C17.7825 17.0204 16.5201 18.2828 14.9999 19.1605C13.4797 20.0381 11.7552 20.5002 9.99984 20.5001C8.24446 20.5001 6.52002 20.038 4.99984 19.1603C3.47965 18.2826 2.21729 17.0202 1.33963 15.5C0.46198 13.9797 -4.45897e-05 12.2553 3.22765e-09 10.4999L0.00500012 10.1759C0.0610032 8.44888 0.563548 6.76585 1.46364 5.29089C2.36373 3.81592 3.63065 2.59934 5.14089 1.75977C6.65113 0.920205 8.35315 0.486289 10.081 0.50033C11.8089 0.514371 13.5036 0.97589 15 1.83989ZM10 13.4999C9.73478 13.4999 9.48043 13.6052 9.29289 13.7928C9.10536 13.9803 9 14.2347 9 14.4999V14.5099C9 14.7751 9.10536 15.0295 9.29289 15.217C9.48043 15.4045 9.73478 15.5099 10 15.5099C10.2652 15.5099 10.5196 15.4045 10.7071 15.217C10.8946 15.0295 11 14.7751 11 14.5099V14.4999C11 14.2347 10.8946 13.9803 10.7071 13.7928C10.5196 13.6052 10.2652 13.4999 10 13.4999ZM10 6.49989C9.73478 6.49989 9.48043 6.60525 9.29289 6.79279C9.10536 6.98032 9 7.23468 9 7.49989V11.4999C9 11.7651 9.10536 12.0195 9.29289 12.207C9.48043 12.3945 9.73478 12.4999 10 12.4999C10.2652 12.4999 10.5196 12.3945 10.7071 12.207C10.8946 12.0195 11 11.7651 11 11.4999V7.49989C11 7.23468 10.8946 6.98032 10.7071 6.79279C10.5196 6.60525 10.2652 6.49989 10 6.49989Z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react';
import Robot from '../assets/robot.svg';
type AgentImageProps = {
src?: string | null;
alt?: string;
className?: string;
fallbackSrc?: string;
};
export default function AgentImage({
src,
alt = 'agent',
className = '',
fallbackSrc = Robot,
}: AgentImageProps) {
const [currentSrc, setCurrentSrc] = useState(
src && src.trim() !== '' ? src : fallbackSrc,
);
useEffect(() => {
const newSrc = src && src.trim() !== '' ? src : fallbackSrc;
if (newSrc !== currentSrc) {
setCurrentSrc(newSrc);
}
}, [src, fallbackSrc]);
return (
<img
src={currentSrc}
alt={alt}
className={className}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
onError={() => {
if (currentSrc !== fallbackSrc) setCurrentSrc(fallbackSrc);
}}
/>
);
}

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

@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import { selectToken } from '../preferences/preferenceSlice';
@@ -24,6 +25,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
onDisconnect,
errorMessage,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [isDarkTheme] = useDarkTheme();
const completedRef = useRef(false);
@@ -47,12 +49,16 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
cleanup();
onSuccess({
session_token: event.data.session_token,
user_email: event.data.user_email || 'Connected User',
user_email:
event.data.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
});
} else if (errorProvider) {
completedRef.current = true;
cleanup();
onError(event.data.error || 'Authentication failed');
onError(
event.data.error || t('modals.uploadDoc.connectors.auth.authFailed'),
);
}
};
@@ -71,13 +77,15 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
if (!authResponse.ok) {
throw new Error(
`Failed to get authorization URL: ${authResponse.status}`,
`${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`,
);
}
const authData = await authResponse.json();
if (!authData.success || !authData.authorization_url) {
throw new Error(authData.error || 'Failed to get authorization URL');
throw new Error(
authData.error || t('modals.uploadDoc.connectors.auth.authUrlFailed'),
);
}
const authWindow = window.open(
@@ -86,9 +94,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
'width=500,height=600,scrollbars=yes,resizable=yes',
);
if (!authWindow) {
throw new Error(
'Failed to open authentication window. Please allow popups.',
);
throw new Error(t('modals.uploadDoc.connectors.auth.popupBlocked'));
}
window.addEventListener('message', handleAuthMessage as any);
@@ -98,28 +104,44 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
clearInterval(checkClosed);
window.removeEventListener('message', handleAuthMessage as any);
if (!completedRef.current) {
onError('Authentication was cancelled');
onError(t('modals.uploadDoc.connectors.auth.authCancelled'));
}
}
}, 1000);
intervalRef.current = checkClosed;
} catch (error) {
onError(error instanceof Error ? error.message : 'Authentication failed');
onError(
error instanceof Error
? error.message
: t('modals.uploadDoc.connectors.auth.authFailed'),
);
}
};
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,19 +149,26 @@ 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>
<span>
{t('modals.uploadDoc.connectors.auth.connectedAs', {
email: 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
{t('modals.uploadDoc.connectors.auth.disconnect')}
</button>
)}
</div>
@@ -162,4 +191,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

@@ -8,10 +8,6 @@ import CopyIcon from '../assets/copy.svg?react';
type CopyButtonProps = {
textToCopy: string;
bgColorLight?: string;
bgColorDark?: string;
hoverBgColorLight?: string;
hoverBgColorDark?: string;
iconSize?: string;
padding?: string;
showText?: boolean;
@@ -27,14 +23,11 @@ const DEFAULT_COPIED_DURATION = 2000;
const DEFAULT_BG_LIGHT = '#FFFFFF';
const DEFAULT_BG_DARK = 'transparent';
const DEFAULT_HOVER_BG_LIGHT = '#EEEEEE';
const DEFAULT_HOVER_BG_DARK = '#4A4A4A';
const DEFAULT_HOVER_BG_DARK = '#464152';
export default function CopyButton({
textToCopy,
bgColorLight = DEFAULT_BG_LIGHT,
bgColorDark = DEFAULT_BG_DARK,
hoverBgColorLight = DEFAULT_HOVER_BG_LIGHT,
hoverBgColorDark = DEFAULT_HOVER_BG_DARK,
iconSize = DEFAULT_ICON_SIZE,
padding = DEFAULT_PADDING,
showText = false,
@@ -50,9 +43,10 @@ export default function CopyButton({
const iconWrapperClasses = clsx(
'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out',
padding,
`bg-[${bgColorLight}] dark:bg-[${bgColorDark}]`,
`hover:bg-[${hoverBgColorLight}] dark:hover:bg-[${hoverBgColorDark}]`,
`bg-[${DEFAULT_BG_LIGHT}] dark:bg-[${DEFAULT_BG_DARK}]`,
{
[`hover:bg-[${DEFAULT_HOVER_BG_LIGHT}] dark:hover:bg-[${DEFAULT_HOVER_BG_DARK}]`]:
!isCopied,
'bg-green-100 dark:bg-green-900 hover:bg-green-100 dark:hover:bg-green-900':
isCopied,
},

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

@@ -1,9 +1,13 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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;
@@ -23,6 +27,7 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
token,
onSelectionChange,
}) => {
const { t } = useTranslation();
const [selectedFiles, setSelectedFiles] = useState<PickerFile[]>([]);
const [selectedFolders, setSelectedFolders] = useState<PickerFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -31,9 +36,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,25 +51,36 @@ 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);
setAuthError('Session expired. Please reconnect to Google Drive.');
setAuthError(
t('modals.uploadDoc.connectors.googleDrive.sessionExpired'),
);
setIsValidating(false);
return false;
}
const validateData = await validateResponse.json();
if (validateData.success) {
setUserEmail(validateData.user_email || 'Connected User');
setUserEmail(
validateData.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
setAccessToken(validateData.access_token || null);
@@ -72,13 +88,16 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
return true;
} else {
setIsConnected(false);
setAuthError(validateData.error || 'Session expired. Please reconnect your account.');
setAuthError(
validateData.error ||
t('modals.uploadDoc.connectors.googleDrive.sessionExpiredGeneric'),
);
setIsValidating(false);
return false;
}
} catch (error) {
console.error('Error validating session:', error);
setAuthError('Failed to validate session. Please reconnect.');
setAuthError(t('modals.uploadDoc.connectors.googleDrive.validateFailed'));
setIsConnected(false);
setIsValidating(false);
return false;
@@ -87,21 +106,21 @@ 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.');
setAuthError(t('modals.uploadDoc.connectors.googleDrive.noSession'));
setIsLoading(false);
return;
}
if (!accessToken) {
setAuthError('No access token available. Please reconnect to Google Drive.');
setAuthError(t('modals.uploadDoc.connectors.googleDrive.noAccessToken'));
setIsLoading(false);
return;
}
try {
const clientId: string = import.meta.env.VITE_GOOGLE_CLIENT_ID;
@@ -117,17 +136,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 +156,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,27 +172,33 @@ 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),
);
}
},
});
} catch (error) {
console.error('Error opening picker:', error);
setAuthError('Failed to open file picker. Please try again.');
setAuthError(t('modals.uploadDoc.connectors.googleDrive.pickerFailed'));
setIsLoading(false);
}
};
@@ -186,9 +212,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 +236,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>
);
@@ -240,9 +269,12 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
<>
<ConnectorAuth
provider="google_drive"
label="Connect to Google Drive"
label={t('modals.uploadDoc.connectors.googleDrive.connect')}
onSuccess={(data) => {
setUserEmail(data.user_email || 'Connected User');
setUserEmail(
data.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
@@ -262,42 +294,70 @@ 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">
<h3 className="text-sm font-medium">Selected Files</h3>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium">
{t('modals.uploadDoc.connectors.googleDrive.selectedFiles')}
</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'}
{isLoading
? t('modals.uploadDoc.connectors.googleDrive.loading')
: t(
'modals.uploadDoc.connectors.googleDrive.selectFiles',
)}
</button>
</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">
{t(
'modals.uploadDoc.connectors.googleDrive.noFilesSelected',
)}
</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">
{t('modals.uploadDoc.connectors.googleDrive.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={t(
'modals.uploadDoc.connectors.googleDrive.folderAlt',
)}
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
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
)}
</button>
</div>
))}
@@ -306,23 +366,40 @@ 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">
{t('modals.uploadDoc.connectors.googleDrive.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={t(
'modals.uploadDoc.connectors.googleDrive.fileAlt',
)}
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
{t(
'modals.uploadDoc.connectors.googleDrive.remove',
)}
</button>
</div>
))}

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

@@ -0,0 +1,229 @@
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { selectUploadTasks, dismissUploadTask } from '../upload/uploadSlice';
import ChevronDown from '../assets/chevron-down.svg';
import CheckCircleFilled from '../assets/check-circle-filled.svg';
import WarnIcon from '../assets/warn.svg';
const PROGRESS_RADIUS = 10;
const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS;
export default function UploadToast() {
const [collapsedTasks, setCollapsedTasks] = useState<Record<string, boolean>>(
{},
);
const toggleTaskCollapse = (taskId: string) => {
setCollapsedTasks((prev) => ({
...prev,
[taskId]: !prev[taskId],
}));
};
const { t } = useTranslation();
const dispatch = useDispatch();
const uploadTasks = useSelector(selectUploadTasks);
const getStatusHeading = (status: string) => {
switch (status) {
case 'preparing':
return t('modals.uploadDoc.progress.wait');
case 'uploading':
return t('modals.uploadDoc.progress.upload');
case 'training':
return t('modals.uploadDoc.progress.upload');
case 'completed':
return t('modals.uploadDoc.progress.completed');
case 'failed':
return t('attachments.uploadFailed');
default:
return t('modals.uploadDoc.progress.preparing');
}
};
return (
<div className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2">
{uploadTasks
.filter((task) => !task.dismissed)
.map((task) => {
const shouldShowProgress = [
'preparing',
'uploading',
'training',
].includes(task.status);
const rawProgress = Math.min(Math.max(task.progress ?? 0, 0), 100);
const formattedProgress = Math.round(rawProgress);
const progressOffset =
PROGRESS_CIRCUMFERENCE * (1 - rawProgress / 100);
const isCollapsed = collapsedTasks[task.id] ?? false;
return (
<div
key={task.id}
className={`w-[271px] overflow-hidden rounded-2xl border border-[#00000021] shadow-[0px_24px_48px_0px_#00000029] transition-all duration-300 ${
task.status === 'completed'
? 'bg-[#FBFBFB] dark:bg-[#26272E]'
: task.status === 'failed'
? 'bg-[#FBFBFB] dark:bg-[#26272E]'
: 'bg-[#FBFBFB] dark:bg-[#26272E]'
}`}
>
<div className="flex flex-col">
<div
className={`flex items-center justify-between px-4 py-3 ${
task.status !== 'failed'
? 'bg-[#FBF2FE] dark:bg-transparent'
: ''
}`}
>
<h3 className="font-inter text-[14px] leading-[16.5px] font-medium text-black dark:text-[#DCDCDC]">
{getStatusHeading(task.status)}
</h3>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => toggleTaskCollapse(task.id)}
aria-label={
isCollapsed
? t('modals.uploadDoc.progress.expandDetails')
: t('modals.uploadDoc.progress.collapseDetails')
}
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
>
<img
src={ChevronDown}
alt=""
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${
isCollapsed ? 'rotate-180' : ''
}`}
/>
</button>
<button
type="button"
onClick={() => dispatch(dismissUploadTask(task.id))}
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
aria-label={t('modals.uploadDoc.progress.dismiss')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
>
<path
d="M18 6L6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 6L18 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
<div
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isCollapsed ? '0fr' : '1fr' }}
>
<div
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="flex items-center justify-between px-5 py-3">
<p
className="font-inter max-w-[200px] truncate text-[13px] leading-[16.5px] font-normal text-black dark:text-[#B7BAB8]"
title={task.fileName}
>
{task.fileName}
</p>
<div className="flex items-center gap-2">
{shouldShowProgress && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="h-6 w-6 flex-shrink-0 text-[#7D54D1]"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={formattedProgress}
aria-label={t(
'modals.uploadDoc.progress.uploadProgress',
{
progress: formattedProgress,
},
)}
>
<circle
className="text-gray-300 dark:text-gray-700"
stroke="currentColor"
strokeWidth="2"
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
/>
<circle
className="text-[#7D54D1]"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={PROGRESS_CIRCUMFERENCE}
strokeDashoffset={progressOffset}
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
transform="rotate(-90 12 12)"
/>
</svg>
)}
{task.status === 'completed' && (
<img
src={CheckCircleFilled}
alt=""
className="h-6 w-6 flex-shrink-0"
aria-hidden="true"
/>
)}
{task.status === 'failed' && (
<img
src={WarnIcon}
alt=""
className="h-6 w-6 flex-shrink-0"
aria-hidden="true"
/>
)}
</div>
</div>
{task.status === 'failed' && task.errorMessage && (
<span className="block px-5 pb-3 text-xs text-red-500">
{task.errorMessage}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -188,7 +188,7 @@ const ConversationBubble = forwardRef<
setIsEditClicked(true);
setEditInputBox(message ?? '');
}}
className={`hover:bg-light-silver mt-3 flex h-fit shrink-0 cursor-pointer items-center rounded-full p-2 dark:hover:bg-[#35363B] ${isQuestionHovered || isEditClicked ? 'visible' : 'invisible'}`}
className={`hover:bg-light-silver mt-3 flex h-fit shrink-0 cursor-pointer items-center rounded-full p-2 pt-1.5 pl-1.5 dark:hover:bg-[#35363B] ${isQuestionHovered || isEditClicked ? 'visible' : 'invisible'}`}
>
<img src={Edit} alt="Edit" className="cursor-pointer" />
</button>
@@ -407,7 +407,7 @@ const ConversationBubble = forwardRef<
</p>
</div>
<div
className={`fade-in-bubble bg-gray-1000 dark:bg-gun-metal mr-5 flex max-w-full rounded-[28px] px-7 py-[18px] ${
className={`fade-in-bubble bg-gray-1000 dark:bg-gun-metal mr-5 flex max-w-full rounded-[18px] px-6 py-4.5 ${
type === 'ERROR'
? 'text-red-3000 dark:border-red-2000 relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal dark:text-white'
: 'flex-col rounded-3xl'

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

@@ -229,6 +229,9 @@
"uploadDoc": {
"label": "Upload new document",
"select": "Choose how to upload your document to DocsGPT",
"selectSource": "Select the way to add your source",
"selectedFiles": "Selected Files",
"noFilesSelected": "No files selected",
"file": "Upload from device",
"back": "Back",
"wait": "Please wait ...",
@@ -257,13 +260,74 @@
},
"progress": {
"upload": "Upload is in progress",
"training": "Training is in progress",
"completed": "Training completed",
"training": "Upload is in progress",
"completed": "Upload completed",
"wait": "This may take several minutes",
"tokenLimit": "Over the token limit, please consider uploading smaller document"
"preparing": "Preparing upload",
"tokenLimit": "Over the token limit, please consider uploading smaller document",
"expandDetails": "Expand upload details",
"collapseDetails": "Collapse upload details",
"dismiss": "Dismiss upload toast",
"uploadProgress": "Upload progress {{progress}}%",
"clear": "Clear"
},
"showAdvanced": "Show advanced options",
"hideAdvanced": "Hide advanced options"
"hideAdvanced": "Hide advanced options",
"ingestors": {
"local_file": {
"label": "Upload File",
"heading": "Upload new document"
},
"crawler": {
"label": "Crawler",
"heading": "Add content with Web Crawler"
},
"url": {
"label": "Link",
"heading": "Add content from URL"
},
"github": {
"label": "GitHub",
"heading": "Add content from GitHub"
},
"reddit": {
"label": "Reddit",
"heading": "Add content from Reddit"
},
"google_drive": {
"label": "Google Drive",
"heading": "Upload from Google Drive"
}
},
"connectors": {
"auth": {
"connectedUser": "Connected User",
"authFailed": "Authentication failed",
"authUrlFailed": "Failed to get authorization URL",
"popupBlocked": "Failed to open authentication window. Please allow popups.",
"authCancelled": "Authentication was cancelled",
"connectedAs": "Connected as {{email}}",
"disconnect": "Disconnect"
},
"googleDrive": {
"connect": "Connect to Google Drive",
"sessionExpired": "Session expired. Please reconnect to Google Drive.",
"sessionExpiredGeneric": "Session expired. Please reconnect your account.",
"validateFailed": "Failed to validate session. Please reconnect.",
"noSession": "No valid session found. Please reconnect to Google Drive.",
"noAccessToken": "No access token available. Please reconnect to Google Drive.",
"pickerFailed": "Failed to open file picker. Please try again.",
"selectedFiles": "Selected Files",
"selectFiles": "Select Files",
"loading": "Loading...",
"noFilesSelected": "No files or folders selected",
"folders": "Folders",
"files": "Files",
"remove": "Remove",
"folderAlt": "Folder",
"fileAlt": "File"
}
}
},
"createAPIKey": {
"label": "Create New API Key",

View File

@@ -192,6 +192,9 @@
"uploadDoc": {
"label": "Subir nuevo documento",
"select": "Elige cómo cargar tu documento en DocsGPT",
"selectSource": "Selecciona la forma de agregar tu fuente",
"selectedFiles": "Archivos Seleccionados",
"noFilesSelected": "No hay archivos seleccionados",
"file": "Subir desde el dispositivo",
"back": "Atrás",
"wait": "Por favor espera ...",
@@ -220,13 +223,74 @@
},
"progress": {
"upload": "Subida en progreso",
"training": "Entrenamiento en progreso",
"completed": "Entrenamiento completado",
"training": "Subida en progreso",
"completed": "Subida completada",
"wait": "Esto puede tardar varios minutos",
"tokenLimit": "Excede el límite de tokens, considere cargar un documento más pequeño"
"preparing": "Preparando subida",
"tokenLimit": "Excede el límite de tokens, considere cargar un documento más pequeño",
"expandDetails": "Expandir detalles de subida",
"collapseDetails": "Contraer detalles de subida",
"dismiss": "Descartar notificación de subida",
"uploadProgress": "Progreso de subida {{progress}}%",
"clear": "Limpiar"
},
"showAdvanced": "Mostrar opciones avanzadas",
"hideAdvanced": "Ocultar opciones avanzadas"
"hideAdvanced": "Ocultar opciones avanzadas",
"ingestors": {
"local_file": {
"label": "Subir archivo",
"heading": "Subir nuevo documento"
},
"crawler": {
"label": "Rastreador",
"heading": "Agregar contenido con rastreador web"
},
"url": {
"label": "Enlace",
"heading": "Agregar contenido desde URL"
},
"github": {
"label": "GitHub",
"heading": "Agregar contenido desde GitHub"
},
"reddit": {
"label": "Reddit",
"heading": "Agregar contenido desde Reddit"
},
"google_drive": {
"label": "Google Drive",
"heading": "Subir desde Google Drive"
}
},
"connectors": {
"auth": {
"connectedUser": "Usuario Conectado",
"authFailed": "Autenticación fallida",
"authUrlFailed": "Error al obtener la URL de autorización",
"popupBlocked": "Error al abrir la ventana de autenticación. Por favor, permita ventanas emergentes.",
"authCancelled": "Autenticación cancelada",
"connectedAs": "Conectado como {{email}}",
"disconnect": "Desconectar"
},
"googleDrive": {
"connect": "Conectar a Google Drive",
"sessionExpired": "Sesión expirada. Por favor, reconecte a Google Drive.",
"sessionExpiredGeneric": "Sesión expirada. Por favor, reconecte su cuenta.",
"validateFailed": "Error al validar la sesión. Por favor, reconecte.",
"noSession": "No se encontró una sesión válida. Por favor, reconecte a Google Drive.",
"noAccessToken": "No hay token de acceso disponible. Por favor, reconecte a Google Drive.",
"pickerFailed": "Error al abrir el selector de archivos. Por favor, inténtelo de nuevo.",
"selectedFiles": "Archivos Seleccionados",
"selectFiles": "Seleccionar Archivos",
"loading": "Cargando...",
"noFilesSelected": "No hay archivos o carpetas seleccionados",
"folders": "Carpetas",
"files": "Archivos",
"remove": "Eliminar",
"folderAlt": "Carpeta",
"fileAlt": "Archivo"
}
}
},
"createAPIKey": {
"label": "Crear Nueva Clave de API",

View File

@@ -192,6 +192,9 @@
"uploadDoc": {
"label": "新しい文書をアップロードする",
"select": "ドキュメントを DocsGPT にアップロードする方法を選択します",
"selectSource": "ソースを追加する方法を選択してください",
"selectedFiles": "選択されたファイル",
"noFilesSelected": "ファイルが選択されていません",
"file": "デバイスからアップロード",
"back": "戻る",
"wait": "お待ちください ...",
@@ -220,13 +223,74 @@
},
"progress": {
"upload": "アップロード中",
"training": "トレーニング中",
"completed": "トレーニング完了",
"training": "アップロード中",
"completed": "アップロード完了",
"wait": "数分かかる場合があります",
"tokenLimit": "トークン制限を超えています。より小さいドキュメントをアップロードしてください"
"preparing": "アップロードを準備中",
"tokenLimit": "トークン制限を超えています。より小さいドキュメントをアップロードしてください",
"expandDetails": "アップロードの詳細を展開",
"collapseDetails": "アップロードの詳細を折りたたむ",
"dismiss": "アップロード通知を閉じる",
"uploadProgress": "アップロード進行状況 {{progress}}%",
"clear": "クリア"
},
"showAdvanced": "詳細オプションを表示",
"hideAdvanced": "詳細オプションを非表示"
"hideAdvanced": "詳細オプションを非表示",
"ingestors": {
"local_file": {
"label": "ファイルをアップロード",
"heading": "新しいドキュメントをアップロード"
},
"crawler": {
"label": "クローラー",
"heading": "Webクローラーでコンテンツを追加"
},
"url": {
"label": "リンク",
"heading": "URLからコンテンツを追加"
},
"github": {
"label": "GitHub",
"heading": "GitHubからコンテンツを追加"
},
"reddit": {
"label": "Reddit",
"heading": "Redditからコンテンツを追加"
},
"google_drive": {
"label": "Google Drive",
"heading": "Google Driveからアップロード"
}
},
"connectors": {
"auth": {
"connectedUser": "接続されたユーザー",
"authFailed": "認証に失敗しました",
"authUrlFailed": "認証URLの取得に失敗しました",
"popupBlocked": "認証ウィンドウを開けませんでした。ポップアップを許可してください。",
"authCancelled": "認証がキャンセルされました",
"connectedAs": "{{email}}として接続",
"disconnect": "切断"
},
"googleDrive": {
"connect": "Google Driveに接続",
"sessionExpired": "セッションが期限切れです。Google Driveに再接続してください。",
"sessionExpiredGeneric": "セッションが期限切れです。アカウントに再接続してください。",
"validateFailed": "セッションの検証に失敗しました。再接続してください。",
"noSession": "有効なセッションが見つかりません。Google Driveに再接続してください。",
"noAccessToken": "アクセストークンが利用できません。Google Driveに再接続してください。",
"pickerFailed": "ファイルピッカーを開けませんでした。もう一度お試しください。",
"selectedFiles": "選択されたファイル",
"selectFiles": "ファイルを選択",
"loading": "読み込み中...",
"noFilesSelected": "ファイルまたはフォルダが選択されていません",
"folders": "フォルダ",
"files": "ファイル",
"remove": "削除",
"folderAlt": "フォルダ",
"fileAlt": "ファイル"
}
}
},
"createAPIKey": {
"label": "新しいAPIキーを作成",

View File

@@ -192,6 +192,9 @@
"uploadDoc": {
"label": "Загрузить новый документ",
"select": "Выберите способ загрузки документа в DocsGPT",
"selectSource": "Выберите способ добавления источника",
"selectedFiles": "Выбранные файлы",
"noFilesSelected": "Файлы не выбраны",
"file": "Загрузить с устройства",
"back": "Назад",
"wait": "Пожалуйста, подождите...",
@@ -220,13 +223,74 @@
},
"progress": {
"upload": "Идет загрузка",
"training": "Идет обучение",
"completed": "Обучение завершено",
"training": "Идет загрузка",
"completed": "Загрузка завершена",
"wait": "Это может занять несколько минут",
"tokenLimit": "Превышен лимит токенов, рассмотрите возможность загрузки документа меньшего размера"
"preparing": "Подготовка загрузки",
"tokenLimit": "Превышен лимит токенов, рассмотрите возможность загрузки документа меньшего размера",
"expandDetails": "Развернуть детали загрузки",
"collapseDetails": "Свернуть детали загрузки",
"dismiss": "Закрыть уведомление о загрузке",
"uploadProgress": "Прогресс загрузки {{progress}}%",
"clear": "Очистить"
},
"showAdvanced": "Показать расширенные настройки",
"hideAdvanced": "Скрыть расширенные настройки"
"hideAdvanced": "Скрыть расширенные настройки",
"ingestors": {
"local_file": {
"label": "Загрузить файл",
"heading": "Загрузить новый документ"
},
"crawler": {
"label": "Краулер",
"heading": "Добавить контент с помощью веб-краулера"
},
"url": {
"label": "Ссылка",
"heading": "Добавить контент из URL"
},
"github": {
"label": "GitHub",
"heading": "Добавить контент из GitHub"
},
"reddit": {
"label": "Reddit",
"heading": "Добавить контент из Reddit"
},
"google_drive": {
"label": "Google Drive",
"heading": "Загрузить из Google Drive"
}
},
"connectors": {
"auth": {
"connectedUser": "Подключенный пользователь",
"authFailed": "Ошибка аутентификации",
"authUrlFailed": "Не удалось получить URL авторизации",
"popupBlocked": "Не удалось открыть окно аутентификации. Пожалуйста, разрешите всплывающие окна.",
"authCancelled": "Аутентификация отменена",
"connectedAs": "Подключен как {{email}}",
"disconnect": "Отключить"
},
"googleDrive": {
"connect": "Подключиться к Google Drive",
"sessionExpired": "Сеанс истек. Пожалуйста, переподключитесь к Google Drive.",
"sessionExpiredGeneric": "Сеанс истек. Пожалуйста, переподключите свою учетную запись.",
"validateFailed": "Не удалось проверить сеанс. Пожалуйста, переподключитесь.",
"noSession": "Действительный сеанс не найден. Пожалуйста, переподключитесь к Google Drive.",
"noAccessToken": "Токен доступа недоступен. Пожалуйста, переподключитесь к Google Drive.",
"pickerFailed": "Не удалось открыть средство выбора файлов. Пожалуйста, попробуйте еще раз.",
"selectedFiles": "Выбранные файлы",
"selectFiles": "Выбрать файлы",
"loading": "Загрузка...",
"noFilesSelected": "Файлы или папки не выбраны",
"folders": "Папки",
"files": "Файлы",
"remove": "Удалить",
"folderAlt": "Папка",
"fileAlt": "Файл"
}
}
},
"createAPIKey": {
"label": "Создать новый API ключ",

View File

@@ -192,6 +192,9 @@
"uploadDoc": {
"label": "上傳新文件",
"select": "選擇如何將文件上傳到 DocsGPT",
"selectSource": "選擇新增來源的方式",
"selectedFiles": "已選擇的檔案",
"noFilesSelected": "未選擇檔案",
"file": "從檔案",
"remote": "遠端",
"back": "返回",
@@ -220,13 +223,74 @@
},
"progress": {
"upload": "正在上傳",
"training": "正在訓練",
"completed": "訓練完成",
"training": "正在上傳",
"completed": "上傳完成",
"wait": "這可能需要幾分鐘",
"tokenLimit": "超出令牌限制,請考慮上傳較小的文檔"
"preparing": "準備上傳",
"tokenLimit": "超出令牌限制,請考慮上傳較小的文檔",
"expandDetails": "展開上傳詳情",
"collapseDetails": "摺疊上傳詳情",
"dismiss": "關閉上傳通知",
"uploadProgress": "上傳進度 {{progress}}%",
"clear": "清除"
},
"showAdvanced": "顯示進階選項",
"hideAdvanced": "隱藏進階選項"
"hideAdvanced": "隱藏進階選項",
"ingestors": {
"local_file": {
"label": "上傳檔案",
"heading": "上傳新文檔"
},
"crawler": {
"label": "爬蟲",
"heading": "使用網路爬蟲新增內容"
},
"url": {
"label": "連結",
"heading": "從URL新增內容"
},
"github": {
"label": "GitHub",
"heading": "從GitHub新增內容"
},
"reddit": {
"label": "Reddit",
"heading": "從Reddit新增內容"
},
"google_drive": {
"label": "Google Drive",
"heading": "從Google Drive上傳"
}
},
"connectors": {
"auth": {
"connectedUser": "已連接使用者",
"authFailed": "驗證失敗",
"authUrlFailed": "取得授權URL失敗",
"popupBlocked": "無法開啟驗證視窗。請允許彈出視窗。",
"authCancelled": "驗證已取消",
"connectedAs": "已連接為 {{email}}",
"disconnect": "中斷連接"
},
"googleDrive": {
"connect": "連接到 Google Drive",
"sessionExpired": "工作階段已過期。請重新連接到 Google Drive。",
"sessionExpiredGeneric": "工作階段已過期。請重新連接您的帳戶。",
"validateFailed": "驗證工作階段失敗。請重新連接。",
"noSession": "未找到有效工作階段。請重新連接到 Google Drive。",
"noAccessToken": "存取權杖不可用。請重新連接到 Google Drive。",
"pickerFailed": "無法開啟檔案選擇器。請重試。",
"selectedFiles": "已選擇的檔案",
"selectFiles": "選擇檔案",
"loading": "載入中...",
"noFilesSelected": "未選擇檔案或資料夾",
"folders": "資料夾",
"files": "檔案",
"remove": "移除",
"folderAlt": "資料夾",
"fileAlt": "檔案"
}
}
},
"createAPIKey": {
"label": "建立新的 API 金鑰",

View File

@@ -192,6 +192,9 @@
"uploadDoc": {
"label": "上传新文档",
"select": "选择如何将文档上传到 DocsGPT",
"selectSource": "选择添加源的方式",
"selectedFiles": "已选择的文件",
"noFilesSelected": "未选择文件",
"file": "从设备上传",
"back": "后退",
"wait": "请稍等 ...",
@@ -220,13 +223,74 @@
},
"progress": {
"upload": "正在上传",
"training": "正在训练",
"completed": "训练完成",
"training": "正在上传",
"completed": "上传完成",
"wait": "这可能需要几分钟",
"tokenLimit": "超出令牌限制,请考虑上传较小的文档"
"preparing": "准备上传",
"tokenLimit": "超出令牌限制,请考虑上传较小的文档",
"expandDetails": "展开上传详情",
"collapseDetails": "折叠上传详情",
"dismiss": "关闭上传通知",
"uploadProgress": "上传进度 {{progress}}%",
"clear": "清除"
},
"showAdvanced": "显示高级选项",
"hideAdvanced": "隐藏高级选项"
"hideAdvanced": "隐藏高级选项",
"ingestors": {
"local_file": {
"label": "上传文件",
"heading": "上传新文档"
},
"crawler": {
"label": "爬虫",
"heading": "使用网络爬虫添加内容"
},
"url": {
"label": "链接",
"heading": "从URL添加内容"
},
"github": {
"label": "GitHub",
"heading": "从GitHub添加内容"
},
"reddit": {
"label": "Reddit",
"heading": "从Reddit添加内容"
},
"google_drive": {
"label": "Google Drive",
"heading": "从Google Drive上传"
}
},
"connectors": {
"auth": {
"connectedUser": "已连接用户",
"authFailed": "身份验证失败",
"authUrlFailed": "获取授权URL失败",
"popupBlocked": "无法打开身份验证窗口。请允许弹出窗口。",
"authCancelled": "身份验证已取消",
"connectedAs": "已连接为 {{email}}",
"disconnect": "断开连接"
},
"googleDrive": {
"connect": "连接到 Google Drive",
"sessionExpired": "会话已过期。请重新连接到 Google Drive。",
"sessionExpiredGeneric": "会话已过期。请重新连接您的账户。",
"validateFailed": "验证会话失败。请重新连接。",
"noSession": "未找到有效会话。请重新连接到 Google Drive。",
"noAccessToken": "访问令牌不可用。请重新连接到 Google Drive。",
"pickerFailed": "无法打开文件选择器。请重试。",
"selectedFiles": "已选择的文件",
"selectFiles": "选择文件",
"loading": "加载中...",
"noFilesSelected": "未选择文件或文件夹",
"folders": "文件夹",
"files": "文件",
"remove": "删除",
"folderAlt": "文件夹",
"fileAlt": "文件"
}
}
},
"createAPIKey": {
"label": "创建新的 API 密钥",

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

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { nanoid } from '@reduxjs/toolkit';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -9,7 +10,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 +19,17 @@ 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 { addUploadTask, updateUploadTask } from './uploadSlice';
import {FilePicker} from '../components/FilePicker';
import { FormField, IngestorConfig, IngestorType } from './types/ingestor';
import { FilePicker } from '../components/FilePicker';
import GoogleDrivePicker from '../components/GoogleDrivePicker';
import ChevronRight from '../assets/chevron-right.svg';
@@ -46,7 +50,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 +59,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,20 +191,20 @@ 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
{t('modals.uploadDoc.choose')}
</span>
</div>
<div className="mt-4 max-w-full">
<p className="text-eerie-black dark:text-light-gray mb-[14px] text-[14px] font-medium">
Selected Files
{t('modals.uploadDoc.selectedFiles')}
</p>
<div className="max-w-full overflow-hidden">
{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}
@@ -211,7 +212,7 @@ function Upload({
))}
{files.length === 0 && (
<p className="text-gray-6000 dark:text-light-gray text-[14px]">
No files selected
{t('modals.uploadDoc.noFilesSelected')}
</p>
)}
</div>
@@ -222,7 +223,10 @@ function Upload({
return (
<FilePicker
key={field.name}
onSelectionChange={(selectedFileIds: string[], selectedFolderIds: string[] = []) => {
onSelectionChange={(
selectedFileIds: string[],
selectedFolderIds: string[] = [],
) => {
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
@@ -236,7 +240,10 @@ function Upload({
return (
<GoogleDrivePicker
key={field.name}
onSelectionChange={(selectedFileIds: string[], selectedFolderIds: string[] = []) => {
onSelectionChange={(
selectedFileIds: string[],
selectedFolderIds: string[] = [],
) => {
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
@@ -255,227 +262,156 @@ function Upload({
config: {},
}));
const [progress, setProgress] = useState<{
type: 'UPLOAD' | 'TRAINING';
percentage: number;
taskId?: string;
failed?: boolean;
}>();
const { t } = useTranslation();
const setTimeoutRef = useRef<number | null>(null);
const dispatch = useDispatch();
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(() => {
if (setTimeoutRef.current) {
clearTimeout(setTimeoutRef.current);
}
const resetUploaderState = useCallback(() => {
setIngestor({ type: null, name: '', config: {} });
setfiles([]);
setSelectedFiles([]);
setSelectedFolders([]);
setShowAdvancedOptions(false);
}, []);
function ProgressBar({ progressPercent }: { progressPercent: number }) {
return (
<div className="my-8 flex h-full w-full items-center justify-center">
<div className="relative h-32 w-32 rounded-full">
<div className="absolute inset-0 rounded-full shadow-[0_0_10px_2px_rgba(0,0,0,0.3)_inset] dark:shadow-[0_0_10px_2px_rgba(0,0,0,0.3)_inset]"></div>
<div
className={`absolute inset-0 rounded-full ${progressPercent === 100 ? 'bg-linear-to-r from-white to-gray-400 shadow-xl shadow-lime-300/50 dark:bg-linear-to-br dark:from-gray-500 dark:to-gray-300 dark:shadow-lime-300/50' : 'shadow-[0_4px_0_#7D54D1] dark:shadow-[0_4px_0_#7D54D1]'}`}
style={{
animation: `${progressPercent === 100 ? 'none' : 'rotate 2s linear infinite'}`,
}}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold">{progressPercent}%</span>
</div>
<style>
{`@keyframes rotate {
0% { transform: rotate(0deg); }
100%{ transform: rotate(360deg); }
}`}
</style>
</div>
</div>
);
}
const handleTaskFailure = useCallback(
(clientTaskId: string, errorMessage?: string) => {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
status: 'failed',
errorMessage: errorMessage || t('attachments.uploadFailed'),
},
}),
);
},
[dispatch, t],
);
function Progress({
title,
isCancellable = false,
isFailed = false,
isTraining = false,
}: {
title: string;
isCancellable?: boolean;
isFailed?: boolean;
isTraining?: boolean;
}) {
return (
<div className="text-gray-2000 dark:text-bright-gray mt-5 flex flex-col items-center gap-2">
<p className="text-gra text-xl tracking-[0.15px]">
{isTraining &&
(progress?.percentage === 100
? t('modals.uploadDoc.progress.completed')
: title)}
{!isTraining && title}
</p>
<p className="text-sm">{t('modals.uploadDoc.progress.wait')}</p>
<p className={`ml-5 text-xl text-red-400 ${isFailed ? '' : 'hidden'}`}>
{t('modals.uploadDoc.progress.tokenLimit')}
</p>
{/* <p className="mt-10 text-2xl">{progress?.percentage || 0}%</p> */}
<ProgressBar progressPercent={progress?.percentage || 0} />
{isTraining &&
(progress?.percentage === 100 ? (
<button
onClick={() => {
setIngestor({ type: null, name: '', config: {} });
setfiles([]);
setProgress(undefined);
setModalState('INACTIVE');
}}
className="h-[42px] cursor-pointer rounded-3xl bg-[#7D54D1] px-[28px] py-[6px] text-sm text-white shadow-lg hover:bg-[#6F3FD1]"
>
{t('modals.uploadDoc.start')}
</button>
) : (
<button
className="ml-2 h-[42px] cursor-pointer rounded-3xl bg-[#7D54D14D] px-[28px] py-[6px] text-sm text-white shadow-lg"
disabled
>
{t('modals.uploadDoc.wait')}
</button>
))}
</div>
);
}
const trackTraining = useCallback(
(backendTaskId: string, clientTaskId: string) => {
let timeoutId: number | null = null;
function UploadProgress() {
return <Progress title={t('modals.uploadDoc.progress.upload')}></Progress>;
}
function TrainingProgress() {
const dispatch = useDispatch();
useEffect(() => {
let timeoutID: number | undefined;
if ((progress?.percentage ?? 0) < 100) {
timeoutID = setTimeout(() => {
userService
.getTaskStatus(progress?.taskId as string, null)
.then((data) => data.json())
.then((data) => {
if (data.status == 'SUCCESS') {
if (data.result.limited === true) {
getDocs(token).then((data) => {
dispatch(setSourceDocs(data));
dispatch(
setSelectedDocs(
Array.isArray(data) &&
data?.find(
(d: Doc) => d.type?.toLowerCase() === 'local',
),
));
});
setProgress(
(progress) =>
progress && {
...progress,
percentage: 100,
failed: true,
},
);
} else {
getDocs(token).then((data) => {
dispatch(setSourceDocs(data));
const docIds = new Set(
(Array.isArray(sourceDocs) &&
sourceDocs?.map((doc: Doc) =>
doc.id ? doc.id : null,
)) ||
[],
);
if (data && Array.isArray(data)) {
data.map((updatedDoc: Doc) => {
if (updatedDoc.id && !docIds.has(updatedDoc.id)) {
// Select the doc not present in the intersection of current Docs and fetched data
dispatch(setSelectedDocs(updatedDoc));
return;
}
});
}
});
setProgress(
(progress) =>
progress && {
...progress,
percentage: 100,
failed: false,
},
);
setIngestor({ type: null, name: '', config: {} });
setfiles([]);
setProgress(undefined);
setModalState('INACTIVE');
onSuccessfulUpload?.();
}
} else if (data.status == 'PROGRESS') {
setProgress(
(progress) =>
progress && {
...progress,
percentage: data.result.current,
},
);
const poll = () => {
userService
.getTaskStatus(backendTaskId, null)
.then((response) => response.json())
.then(async (data) => {
if (data.status === 'SUCCESS') {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
});
}, 5000);
}
// cleanup
return () => {
if (timeoutID !== undefined) {
clearTimeout(timeoutID);
}
const docs = await getDocs(token);
dispatch(setSourceDocs(docs));
if (Array.isArray(docs)) {
const existingDocIds = new Set(
(Array.isArray(sourceDocs) ? sourceDocs : [])
.map((doc: Doc) => doc?.id)
.filter((id): id is string => Boolean(id)),
);
const newDoc = docs.find(
(doc: Doc) => doc.id && !existingDocIds.has(doc.id),
);
if (newDoc) {
dispatch(setSelectedDocs([newDoc]));
}
}
if (data.result?.limited) {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
status: 'failed',
progress: 100,
errorMessage: t('modals.uploadDoc.progress.tokenLimit'),
},
}),
);
} else {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
status: 'completed',
progress: 100,
errorMessage: undefined,
},
}),
);
onSuccessfulUpload?.();
}
} else if (data.status === 'FAILURE') {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
handleTaskFailure(clientTaskId, data.result?.message);
} else if (data.status === 'PROGRESS') {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
status: 'training',
progress: Math.min(100, data.result?.current ?? 0),
},
}),
);
timeoutId = window.setTimeout(poll, 5000);
} else {
timeoutId = window.setTimeout(poll, 5000);
}
})
.catch(() => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
handleTaskFailure(clientTaskId);
});
};
}, [progress, dispatch]);
return (
<Progress
title={t('modals.uploadDoc.progress.training')}
isCancellable={progress?.percentage === 100}
isFailed={progress?.failed === true}
isTraining={true}
></Progress>
);
}
const onDrop = useCallback((acceptedFiles: File[]) => {
setfiles(acceptedFiles);
setIngestor(prev => ({ ...prev, name: acceptedFiles[0]?.name || '' }));
timeoutId = window.setTimeout(poll, 3000);
},
[dispatch, handleTaskFailure, onSuccessfulUpload, sourceDocs, t, token],
);
// 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 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],
);
const doNothing = () => undefined;
const uploadFile = () => {
const uploadFile = (clientTaskId: string) => {
const formData = new FormData();
files.forEach((file) => {
formData.append('file', file);
@@ -483,44 +419,106 @@ function Upload({
formData.append('name', ingestor.name);
formData.append('user', 'local');
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { status: 'uploading', progress: 0 },
}),
);
xhr.upload.addEventListener('progress', (event) => {
const progress = +((event.loaded / event.total) * 100).toFixed(2);
setProgress({ type: 'UPLOAD', percentage: progress });
if (!event.lengthComputable) return;
const progressPercentage = Number(
((event.loaded / event.total) * 100).toFixed(2),
);
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { progress: progressPercentage },
}),
);
});
xhr.onload = () => {
const { task_id } = JSON.parse(xhr.responseText);
setTimeoutRef.current = setTimeout(() => {
setProgress({ type: 'TRAINING', percentage: 0, taskId: task_id });
}, 3000);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const parsed = JSON.parse(xhr.responseText) as { task_id?: string };
if (parsed.task_id) {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
taskId: parsed.task_id,
status: 'training',
progress: 0,
},
}),
);
trackTraining(parsed.task_id, clientTaskId);
} else {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.();
}
} catch (error) {
handleTaskFailure(clientTaskId);
}
} else {
handleTaskFailure(clientTaskId, xhr.statusText || undefined);
}
};
xhr.open('POST', `${apiHost + '/api/upload'}`);
xhr.onerror = () => {
handleTaskFailure(clientTaskId);
};
xhr.open('POST', `${apiHost}/api/upload`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
};
const uploadRemote = () => {
if (!ingestor.type) return;
const uploadRemote = (clientTaskId: string) => {
if (!ingestor.type) {
handleTaskFailure(clientTaskId);
return;
}
const formData = new FormData();
formData.append('name', ingestor.name);
formData.append('user', 'local');
formData.append('source', ingestor.type as string);
let configData: any = {};
const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType);
if (!ingestorSchema) return;
if (!ingestorSchema) {
handleTaskFailure(clientTaskId);
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',
);
let configData: Record<string, unknown> = { ...ingestor.config };
if (hasLocalFilePicker) {
files.forEach((file) => {
formData.append('file', file);
});
configData = { ...ingestor.config };
} else if (hasRemoteFilePicker || hasGoogleDrivePicker) {
const sessionToken = getSessionToken(ingestor.type as string);
configData = {
@@ -529,42 +527,121 @@ function Upload({
file_ids: selectedFiles,
folder_ids: selectedFolders,
};
} else {
configData = { ...ingestor.config };
}
formData.append('data', JSON.stringify(configData));
const apiHost: string = import.meta.env.VITE_API_HOST;
const endpoint =
ingestor.type === 'local_file'
? `${apiHost}/api/upload`
: `${apiHost}/api/remote`;
const xhr = new XMLHttpRequest();
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { status: 'uploading', progress: 0 },
}),
);
xhr.upload.addEventListener('progress', (event: ProgressEvent) => {
if (event.lengthComputable) {
const progressPercentage = +(
(event.loaded / event.total) *
100
).toFixed(2);
setProgress({ type: 'UPLOAD', percentage: progressPercentage });
}
if (!event.lengthComputable) return;
const progressPercentage = Number(
((event.loaded / event.total) * 100).toFixed(2),
);
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { progress: progressPercentage },
}),
);
});
xhr.onload = () => {
const response = JSON.parse(xhr.responseText) as { task_id: string };
setTimeoutRef.current = window.setTimeout(() => {
setProgress({
type: 'TRAINING',
percentage: 0,
taskId: response.task_id,
});
}, 3000);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText) as { task_id?: string };
if (response.task_id) {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: {
taskId: response.task_id,
status: 'training',
progress: 0,
},
}),
);
trackTraining(response.task_id, clientTaskId);
} else {
dispatch(
updateUploadTask({
id: clientTaskId,
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.();
}
} catch (error) {
handleTaskFailure(clientTaskId);
}
} else {
handleTaskFailure(clientTaskId, xhr.statusText || undefined);
}
};
const endpoint = ingestor.type === 'local_file' ? `${apiHost}/api/upload` : `${apiHost}/api/remote`;
xhr.onerror = () => {
handleTaskFailure(clientTaskId);
};
xhr.open('POST', endpoint);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
};
const handleClose = useCallback(() => {
resetUploaderState();
setModalState('INACTIVE');
close();
}, [close, resetUploaderState, setModalState]);
const handleUpload = () => {
if (!ingestor.type) return;
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 displayName =
ingestor.name?.trim() || files[0]?.name || t('modals.uploadDoc.label');
const clientTaskId = nanoid();
dispatch(
addUploadTask({
id: clientTaskId,
fileName: displayName,
progress: 0,
status: 'preparing',
}),
);
if (hasLocalFilePicker) {
uploadFile(clientTaskId);
} else {
uploadRemote(clientTaskId);
}
handleClose();
};
const { getRootProps, getInputProps } = useDropzone({
onDrop,
@@ -604,12 +681,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 +706,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,27 +773,29 @@ 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">
{option.label}
<p className="font-inter self-start text-[13px] leading-[18px] font-semibold">
{t(`modals.uploadDoc.ingestors.${option.value}.label`)}
</p>
</div>
</div>
@@ -714,21 +803,19 @@ function Upload({
</div>
);
};
let view;
if (progress?.type === 'UPLOAD') {
view = <UploadProgress></UploadProgress>;
} else if (progress?.type === 'TRAINING') {
view = <TrainingProgress></TrainingProgress>;
} else {
view = (
return (
<WrapperModal
close={handleClose}
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"
>
<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]">
Select the way to add your source
<p className="font-inter text-left text-[20px] leading-[28px] font-semibold tracking-[0.15px] text-[#18181B] dark:text-[#ECECF1]">
{t('modals.uploadDoc.selectSource')}
</p>
)}
{activeTab && (
<>
{!ingestor.type && renderIngestorSelection()}
@@ -736,18 +823,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>
<span>{t('modals.uploadDoc.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 &&
t(`modals.uploadDoc.ingestors.${ingestor.type}.heading`)}
</h2>
<Input
@@ -761,7 +849,7 @@ function Upload({
}));
}}
borderVariant="thin"
placeholder="Name"
placeholder={t('modals.uploadDoc.name')}
required={true}
labelBgClassName="bg-white dark:bg-charleston-green-2"
className="w-full"
@@ -769,37 +857,26 @@ 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">
{activeTab && ingestor.type && (
<button
onClick={() => {
if (!ingestor.type) return;
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');
if (hasLocalFilePicker) {
uploadFile();
} else {
uploadRemote();
}
}}
onClick={handleUpload}
disabled={isUploadDisabled()}
className={`rounded-3xl px-4 py-2 text-[14px] font-medium ${
isUploadDisabled()
@@ -812,26 +889,6 @@ function Upload({
)}
</div>
</div>
);
}
return (
<WrapperModal
isPerformingTask={progress !== undefined && progress.percentage < 100}
close={() => {
close();
setIngestor({ type: null, name: '', config: {} });
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"
contentClassName="max-h-[80vh] sm:max-h-none"
>
{view}
</WrapperModal>
);
}

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

@@ -10,12 +10,31 @@ export interface Attachment {
token_count?: number;
}
export type UploadTaskStatus =
| 'preparing'
| 'uploading'
| 'training'
| 'completed'
| 'failed';
export interface UploadTask {
id: string;
fileName: string;
progress: number;
status: UploadTaskStatus;
taskId?: string;
errorMessage?: string;
dismissed?: boolean;
}
interface UploadState {
attachments: Attachment[];
tasks: UploadTask[];
}
const initialState: UploadState = {
attachments: [],
tasks: [],
};
export const uploadSlice = createSlice({
@@ -52,6 +71,49 @@ export const uploadSlice = createSlice({
(att) => att.status === 'uploading' || att.status === 'processing',
);
},
addUploadTask: (state, action: PayloadAction<UploadTask>) => {
state.tasks.unshift(action.payload);
},
updateUploadTask: (
state,
action: PayloadAction<{
id: string;
updates: Partial<UploadTask>;
}>,
) => {
const index = state.tasks.findIndex(
(task) => task.id === action.payload.id,
);
if (index !== -1) {
const updates = action.payload.updates;
// When task completes or fails, set dismissed to false to notify user
if (updates.status === 'completed' || updates.status === 'failed') {
state.tasks[index] = {
...state.tasks[index],
...updates,
dismissed: false,
};
} else {
state.tasks[index] = {
...state.tasks[index],
...updates,
};
}
}
},
dismissUploadTask: (state, action: PayloadAction<string>) => {
const index = state.tasks.findIndex((task) => task.id === action.payload);
if (index !== -1) {
state.tasks[index] = {
...state.tasks[index],
dismissed: true,
};
}
},
removeUploadTask: (state, action: PayloadAction<string>) => {
state.tasks = state.tasks.filter((task) => task.id !== action.payload);
},
},
});
@@ -60,10 +122,15 @@ export const {
updateAttachment,
removeAttachment,
clearAttachments,
addUploadTask,
updateUploadTask,
dismissUploadTask,
removeUploadTask,
} = uploadSlice.actions;
export const selectAttachments = (state: RootState) => state.upload.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.upload.attachments.filter((att) => att.status === 'completed');
export const selectUploadTasks = (state: RootState) => state.upload.tasks;
export default uploadSlice.reducer;

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

765
tests/test_memory_tool.py Normal file
View File

@@ -0,0 +1,765 @@
import pytest
from application.agents.tools.memory import MemoryTool
from application.core.settings import settings
@pytest.fixture
def memory_tool(monkeypatch) -> MemoryTool:
"""Provide a MemoryTool with a fake Mongo collection and fixed user_id."""
class FakeCollection:
def __init__(self) -> None:
self.docs = {} # path -> document
def insert_one(self, doc):
user_id = doc.get("user_id")
tool_id = doc.get("tool_id")
path = doc.get("path")
key = f"{user_id}:{tool_id}:{path}"
# Add _id to document if not present
if "_id" not in doc:
doc["_id"] = key
self.docs[key] = doc
return type("res", (), {"inserted_id": key})
def update_one(self, q, u, upsert=False):
# Handle query by _id
if "_id" in q:
doc_id = q["_id"]
if doc_id not in self.docs:
return type("res", (), {"modified_count": 0})
if "$set" in u:
old_doc = self.docs[doc_id].copy()
old_doc.update(u["$set"])
# If path changed, update the dictionary key
if "path" in u["$set"]:
new_path = u["$set"]["path"]
user_id = old_doc.get("user_id")
tool_id = old_doc.get("tool_id")
new_key = f"{user_id}:{tool_id}:{new_path}"
# Remove old key and add with new key
del self.docs[doc_id]
old_doc["_id"] = new_key
self.docs[new_key] = old_doc
else:
self.docs[doc_id] = old_doc
return type("res", (), {"modified_count": 1})
# Handle query by user_id, tool_id, path
user_id = q.get("user_id")
tool_id = q.get("tool_id")
path = q.get("path")
key = f"{user_id}:{tool_id}:{path}"
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "path": path, "content": "", "_id": key}
if "$set" in u:
self.docs[key].update(u["$set"])
return type("res", (), {"modified_count": 1})
def find_one(self, q, projection=None):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
path = q.get("path")
if path:
key = f"{user_id}:{tool_id}:{path}"
return self.docs.get(key)
return None
def find(self, q, projection=None):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
results = []
# Handle regex queries for directory listing
if "path" in q and isinstance(q["path"], dict) and "$regex" in q["path"]:
regex_pattern = q["path"]["$regex"]
# Remove regex escape characters and ^ anchor for simple matching
pattern = regex_pattern.replace("\\", "").lstrip("^")
for key, doc in self.docs.items():
if doc.get("user_id") == user_id and doc.get("tool_id") == tool_id:
doc_path = doc.get("path", "")
if doc_path.startswith(pattern):
results.append(doc)
else:
for key, doc in self.docs.items():
if doc.get("user_id") == user_id and doc.get("tool_id") == tool_id:
results.append(doc)
return results
def delete_one(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
path = q.get("path")
key = f"{user_id}:{tool_id}:{path}"
if key in self.docs:
del self.docs[key]
return type("res", (), {"deleted_count": 1})
return type("res", (), {"deleted_count": 0})
def delete_many(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
deleted = 0
# Handle regex queries for directory deletion
if "path" in q and isinstance(q["path"], dict) and "$regex" in q["path"]:
regex_pattern = q["path"]["$regex"]
pattern = regex_pattern.replace("\\", "").lstrip("^")
keys_to_delete = []
for key, doc in self.docs.items():
if doc.get("user_id") == user_id and doc.get("tool_id") == tool_id:
doc_path = doc.get("path", "")
if doc_path.startswith(pattern):
keys_to_delete.append(key)
for key in keys_to_delete:
del self.docs[key]
deleted += 1
else:
# Delete all for user and tool
keys_to_delete = [
key for key, doc in self.docs.items()
if doc.get("user_id") == user_id and doc.get("tool_id") == tool_id
]
for key in keys_to_delete:
del self.docs[key]
deleted += 1
return type("res", (), {"deleted_count": deleted})
fake_collection = FakeCollection()
fake_db = {"memories": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Return tool with a fixed tool_id for consistency in tests
return MemoryTool({"tool_id": "test_tool_id"}, user_id="test_user")
def test_init_without_user_id():
"""Should fail gracefully if no user_id is provided."""
memory_tool = MemoryTool(tool_config={})
result = memory_tool.execute_action("view", path="/")
assert "user_id" in result.lower()
def test_view_empty_directory(memory_tool: MemoryTool) -> None:
"""Should show empty directory when no files exist."""
result = memory_tool.execute_action("view", path="/")
assert "empty" in result.lower()
def test_create_and_view_file(memory_tool: MemoryTool) -> None:
"""Test creating a file and viewing it."""
# Create a file
result = memory_tool.execute_action(
"create",
path="/notes.txt",
file_text="Hello world"
)
assert "created" in result.lower()
# View the file
result = memory_tool.execute_action("view", path="/notes.txt")
assert "Hello world" in result
def test_create_overwrite_file(memory_tool: MemoryTool) -> None:
"""Test that create overwrites existing files."""
# Create initial file
memory_tool.execute_action(
"create",
path="/test.txt",
file_text="Original content"
)
# Overwrite
memory_tool.execute_action(
"create",
path="/test.txt",
file_text="New content"
)
# Verify overwrite
result = memory_tool.execute_action("view", path="/test.txt")
assert "New content" in result
assert "Original content" not in result
def test_view_directory_with_files(memory_tool: MemoryTool) -> None:
"""Test viewing directory contents."""
# Create multiple files
memory_tool.execute_action(
"create",
path="/file1.txt",
file_text="Content 1"
)
memory_tool.execute_action(
"create",
path="/file2.txt",
file_text="Content 2"
)
memory_tool.execute_action(
"create",
path="/subdir/file3.txt",
file_text="Content 3"
)
# View directory
result = memory_tool.execute_action("view", path="/")
assert "file1.txt" in result
assert "file2.txt" in result
assert "subdir/file3.txt" in result
def test_view_file_with_line_range(memory_tool: MemoryTool) -> None:
"""Test viewing specific lines from a file."""
# Create a multiline file
content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
memory_tool.execute_action(
"create",
path="/multiline.txt",
file_text=content
)
# View lines 2-4
result = memory_tool.execute_action(
"view",
path="/multiline.txt",
view_range=[2, 4]
)
assert "Line 2" in result
assert "Line 3" in result
assert "Line 4" in result
assert "Line 1" not in result
assert "Line 5" not in result
def test_str_replace(memory_tool: MemoryTool) -> None:
"""Test string replacement in a file."""
# Create a file
memory_tool.execute_action(
"create",
path="/replace.txt",
file_text="Hello world, hello universe"
)
# Replace text
result = memory_tool.execute_action(
"str_replace",
path="/replace.txt",
old_str="hello",
new_str="hi"
)
assert "updated" in result.lower()
# Verify replacement
content = memory_tool.execute_action("view", path="/replace.txt")
assert "hi world, hi universe" in content
def test_str_replace_not_found(memory_tool: MemoryTool) -> None:
"""Test string replacement when string not found."""
memory_tool.execute_action(
"create",
path="/test.txt",
file_text="Hello world"
)
result = memory_tool.execute_action(
"str_replace",
path="/test.txt",
old_str="goodbye",
new_str="hi"
)
assert "not found" in result.lower()
def test_insert_line(memory_tool: MemoryTool) -> None:
"""Test inserting text at a line number."""
# Create a multiline file
memory_tool.execute_action(
"create",
path="/insert.txt",
file_text="Line 1\nLine 2\nLine 3"
)
# Insert at line 2
result = memory_tool.execute_action(
"insert",
path="/insert.txt",
insert_line=2,
insert_text="Inserted line"
)
assert "inserted" in result.lower()
# Verify insertion
content = memory_tool.execute_action("view", path="/insert.txt")
lines = content.split("\n")
assert lines[1] == "Inserted line"
assert lines[2] == "Line 2"
def test_insert_invalid_line(memory_tool: MemoryTool) -> None:
"""Test inserting at an invalid line number."""
memory_tool.execute_action(
"create",
path="/test.txt",
file_text="Line 1\nLine 2"
)
result = memory_tool.execute_action(
"insert",
path="/test.txt",
insert_line=100,
insert_text="Text"
)
assert "invalid" in result.lower()
def test_delete_file(memory_tool: MemoryTool) -> None:
"""Test deleting a file."""
# Create a file
memory_tool.execute_action(
"create",
path="/delete_me.txt",
file_text="Content"
)
# Delete it
result = memory_tool.execute_action("delete", path="/delete_me.txt")
assert "deleted" in result.lower()
# Verify it's gone
result = memory_tool.execute_action("view", path="/delete_me.txt")
assert "not found" in result.lower()
def test_delete_nonexistent_file(memory_tool: MemoryTool) -> None:
"""Test deleting a file that doesn't exist."""
result = memory_tool.execute_action("delete", path="/nonexistent.txt")
assert "not found" in result.lower()
def test_delete_directory(memory_tool: MemoryTool) -> None:
"""Test deleting a directory with files."""
# Create files in a directory
memory_tool.execute_action(
"create",
path="/subdir/file1.txt",
file_text="Content 1"
)
memory_tool.execute_action(
"create",
path="/subdir/file2.txt",
file_text="Content 2"
)
# Delete the directory
result = memory_tool.execute_action("delete", path="/subdir/")
assert "deleted" in result.lower()
# Verify files are gone
result = memory_tool.execute_action("view", path="/subdir/file1.txt")
assert "not found" in result.lower()
def test_rename_file(memory_tool: MemoryTool) -> None:
"""Test renaming a file."""
# Create a file
memory_tool.execute_action(
"create",
path="/old_name.txt",
file_text="Content"
)
# Rename it
result = memory_tool.execute_action(
"rename",
old_path="/old_name.txt",
new_path="/new_name.txt"
)
assert "renamed" in result.lower()
# Verify old path doesn't exist
result = memory_tool.execute_action("view", path="/old_name.txt")
assert "not found" in result.lower()
# Verify new path exists
result = memory_tool.execute_action("view", path="/new_name.txt")
assert "Content" in result
def test_rename_nonexistent_file(memory_tool: MemoryTool) -> None:
"""Test renaming a file that doesn't exist."""
result = memory_tool.execute_action(
"rename",
old_path="/nonexistent.txt",
new_path="/new.txt"
)
assert "not found" in result.lower()
def test_rename_to_existing_file(memory_tool: MemoryTool) -> None:
"""Test renaming to a path that already exists."""
# Create two files
memory_tool.execute_action(
"create",
path="/file1.txt",
file_text="Content 1"
)
memory_tool.execute_action(
"create",
path="/file2.txt",
file_text="Content 2"
)
# Try to rename file1 to file2
result = memory_tool.execute_action(
"rename",
old_path="/file1.txt",
new_path="/file2.txt"
)
assert "already exists" in result.lower()
def test_path_traversal_protection(memory_tool: MemoryTool) -> None:
"""Test that directory traversal attacks are prevented."""
# Try various path traversal attempts
invalid_paths = [
"/../secrets.txt",
"/../../etc/passwd",
"..//file.txt",
"/subdir/../../outside.txt",
]
for path in invalid_paths:
result = memory_tool.execute_action(
"create",
path=path,
file_text="malicious content"
)
assert "invalid path" in result.lower()
def test_path_must_start_with_slash(memory_tool: MemoryTool) -> None:
"""Test that paths work with or without leading slash (auto-normalized)."""
# These paths should all work now (auto-prepended with /)
valid_paths = [
"etc/passwd", # Auto-prepended with /
"home/user/file.txt", # Auto-prepended with /
"file.txt", # Auto-prepended with /
]
for path in valid_paths:
result = memory_tool.execute_action(
"create",
path=path,
file_text="content"
)
assert "created" in result.lower()
# Verify the file can be accessed with or without leading slash
view_result = memory_tool.execute_action("view", path=path)
assert "content" in view_result
def test_cannot_create_directory_as_file(memory_tool: MemoryTool) -> None:
"""Test that you cannot create a file at a directory path."""
result = memory_tool.execute_action(
"create",
path="/",
file_text="content"
)
assert "cannot create a file at directory path" in result.lower()
def test_get_actions_metadata(memory_tool: MemoryTool) -> None:
"""Test that action metadata is properly defined."""
metadata = memory_tool.get_actions_metadata()
# Check that all expected actions are defined
action_names = [action["name"] for action in metadata]
assert "view" in action_names
assert "create" in action_names
assert "str_replace" in action_names
assert "insert" in action_names
assert "delete" in action_names
assert "rename" in action_names
# Check that each action has required fields
for action in metadata:
assert "name" in action
assert "description" in action
assert "parameters" in action
def test_memory_tool_isolation(monkeypatch) -> None:
"""Test that different memory tool instances have isolated memories."""
# Create fake collection
class FakeCollection:
def __init__(self) -> None:
self.docs = {}
def insert_one(self, doc):
user_id = doc.get("user_id")
tool_id = doc.get("tool_id")
path = doc.get("path")
key = f"{user_id}:{tool_id}:{path}"
self.docs[key] = doc
return type("res", (), {"inserted_id": key})
def update_one(self, q, u, upsert=False):
# Handle query by _id
if "_id" in q:
doc_id = q["_id"]
if doc_id not in self.docs:
return type("res", (), {"modified_count": 0})
if "$set" in u:
old_doc = self.docs[doc_id].copy()
old_doc.update(u["$set"])
# If path changed, update the dictionary key
if "path" in u["$set"]:
new_path = u["$set"]["path"]
user_id = old_doc.get("user_id")
tool_id = old_doc.get("tool_id")
new_key = f"{user_id}:{tool_id}:{new_path}"
# Remove old key and add with new key
del self.docs[doc_id]
old_doc["_id"] = new_key
self.docs[new_key] = old_doc
else:
self.docs[doc_id] = old_doc
return type("res", (), {"modified_count": 1})
# Handle query by user_id, tool_id, path
user_id = q.get("user_id")
tool_id = q.get("tool_id")
path = q.get("path")
key = f"{user_id}:{tool_id}:{path}"
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "path": path, "content": "", "_id": key}
if "$set" in u:
self.docs[key].update(u["$set"])
return type("res", (), {"modified_count": 1})
def find_one(self, q, projection=None):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
path = q.get("path")
if path:
key = f"{user_id}:{tool_id}:{path}"
return self.docs.get(key)
return None
fake_collection = FakeCollection()
fake_db = {"memories": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Create two memory tools with different tool_ids for the same user
tool1 = MemoryTool({"tool_id": "tool_1"}, user_id="test_user")
tool2 = MemoryTool({"tool_id": "tool_2"}, user_id="test_user")
# Create a file in tool1
tool1.execute_action("create", path="/file.txt", file_text="Content from tool 1")
# Create a file with the same path in tool2
tool2.execute_action("create", path="/file.txt", file_text="Content from tool 2")
# Verify that each tool sees only its own content
result1 = tool1.execute_action("view", path="/file.txt")
result2 = tool2.execute_action("view", path="/file.txt")
assert "Content from tool 1" in result1
assert "Content from tool 2" not in result1
assert "Content from tool 2" in result2
assert "Content from tool 1" not in result2
def test_memory_tool_auto_generates_tool_id(monkeypatch) -> None:
"""Test that tool_id defaults to 'default_{user_id}' for persistence."""
class FakeCollection:
def __init__(self) -> None:
self.docs = {}
def update_one(self, q, u, upsert=False):
return type("res", (), {"modified_count": 1})
fake_collection = FakeCollection()
fake_db = {"memories": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Create two tools without providing tool_id for the same user
tool1 = MemoryTool({}, user_id="test_user")
tool2 = MemoryTool({}, user_id="test_user")
# Both should have the same default tool_id for persistence
assert tool1.tool_id == "default_test_user"
assert tool2.tool_id == "default_test_user"
assert tool1.tool_id == tool2.tool_id
# Different users should have different tool_ids
tool3 = MemoryTool({}, user_id="another_user")
assert tool3.tool_id == "default_another_user"
assert tool3.tool_id != tool1.tool_id
def test_paths_without_leading_slash(memory_tool) -> None:
"""Test that paths without leading slash work correctly."""
# Create file without leading slash
result = memory_tool.execute_action("create", path="cat_breeds.txt", file_text="- Korat\n- Chartreux\n- British Shorthair\n- Nebelung")
assert "created" in result.lower()
# View file without leading slash
view_result = memory_tool.execute_action("view", path="cat_breeds.txt")
assert "Korat" in view_result
assert "Chartreux" in view_result
# View same file with leading slash (should work the same)
view_result2 = memory_tool.execute_action("view", path="/cat_breeds.txt")
assert "Korat" in view_result2
# Test str_replace without leading slash
replace_result = memory_tool.execute_action("str_replace", path="cat_breeds.txt", old_str="Korat", new_str="Maine Coon")
assert "updated" in replace_result.lower()
# Test nested path without leading slash
nested_result = memory_tool.execute_action("create", path="projects/tasks.txt", file_text="Task 1\nTask 2")
assert "created" in nested_result.lower()
view_nested = memory_tool.execute_action("view", path="projects/tasks.txt")
assert "Task 1" in view_nested
def test_rename_directory(memory_tool: MemoryTool) -> None:
"""Test renaming a directory with files."""
# Create files in a directory
memory_tool.execute_action(
"create",
path="/docs/file1.txt",
file_text="Content 1"
)
memory_tool.execute_action(
"create",
path="/docs/sub/file2.txt",
file_text="Content 2"
)
# Rename directory (with trailing slash)
result = memory_tool.execute_action(
"rename",
old_path="/docs/",
new_path="/archive/"
)
assert "renamed" in result.lower()
assert "2 files" in result.lower()
# Verify old paths don't exist
result = memory_tool.execute_action("view", path="/docs/file1.txt")
assert "not found" in result.lower()
# Verify new paths exist
result = memory_tool.execute_action("view", path="/archive/file1.txt")
assert "Content 1" in result
result = memory_tool.execute_action("view", path="/archive/sub/file2.txt")
assert "Content 2" in result
def test_rename_directory_without_trailing_slash(memory_tool: MemoryTool) -> None:
"""Test renaming a directory when new path is missing trailing slash."""
# Create files in a directory
memory_tool.execute_action(
"create",
path="/docs/file1.txt",
file_text="Content 1"
)
memory_tool.execute_action(
"create",
path="/docs/sub/file2.txt",
file_text="Content 2"
)
# Rename directory - old path has slash, new path doesn't
result = memory_tool.execute_action(
"rename",
old_path="/docs/",
new_path="/archive" # Missing trailing slash
)
assert "renamed" in result.lower()
# Verify paths are correct (not corrupted like "/archivesub/file2.txt")
result = memory_tool.execute_action("view", path="/archive/file1.txt")
assert "Content 1" in result
result = memory_tool.execute_action("view", path="/archive/sub/file2.txt")
assert "Content 2" in result
# Verify corrupted path doesn't exist
result = memory_tool.execute_action("view", path="/archivesub/file2.txt")
assert "not found" in result.lower()
def test_view_file_line_numbers(memory_tool: MemoryTool) -> None:
"""Test that view_range displays correct line numbers."""
# Create a multiline file
content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
memory_tool.execute_action(
"create",
path="/numbered.txt",
file_text=content
)
# View lines 2-4
result = memory_tool.execute_action(
"view",
path="/numbered.txt",
view_range=[2, 4]
)
# Check that line numbers are correct (should be 2, 3, 4 not 3, 4, 5)
assert "2: Line 2" in result
assert "3: Line 3" in result
assert "4: Line 4" in result
assert "1: Line 1" not in result
assert "5: Line 5" not in result
# Verify no off-by-one error
assert "3: Line 2" not in result # Wrong line number
assert "4: Line 3" not in result # Wrong line number
assert "5: Line 4" not in result # Wrong line number

223
tests/test_notes_tool.py Normal file
View File

@@ -0,0 +1,223 @@
import pytest
from application.agents.tools.notes import NotesTool
from application.core.settings import settings
@pytest.fixture
def notes_tool(monkeypatch) -> NotesTool:
"""Provide a NotesTool with a fake Mongo collection and fixed user_id."""
class FakeCollection:
def __init__(self) -> None:
self.docs = {} # key: user_id:tool_id -> doc
def update_one(self, q, u, upsert=False):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
# emulate single-note storage with optional upsert
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
if "$set" in u and "note" in u["$set"]:
self.docs[key]["note"] = u["$set"]["note"]
return type("res", (), {"modified_count": 1})
def find_one(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
return self.docs.get(key)
def delete_one(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
if key in self.docs:
del self.docs[key]
return type("res", (), {"deleted_count": 1})
return type("res", (), {"deleted_count": 0})
fake_collection = FakeCollection()
fake_db = {"notes": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
# Patch MongoDB client globally for the tool
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Return tool with a fixed tool_id for consistency in tests
return NotesTool({"tool_id": "test_tool_id"}, user_id="test_user")
def test_view(notes_tool: NotesTool) -> None:
# Manually insert a note to test retrieval
notes_tool.collection.update_one(
{"user_id": "test_user", "tool_id": "test_tool_id"},
{"$set": {"note": "hello"}},
upsert=True
)
assert "hello" in notes_tool.execute_action("view")
def test_overwrite_and_delete(notes_tool: NotesTool) -> None:
# Overwrite creates a new note
assert "saved" in notes_tool.execute_action("overwrite", text="first").lower()
assert "first" in notes_tool.execute_action("view")
# Overwrite replaces existing note
assert "saved" in notes_tool.execute_action("overwrite", text="second").lower()
assert "second" in notes_tool.execute_action("view")
assert "deleted" in notes_tool.execute_action("delete").lower()
assert "no note" in notes_tool.execute_action("view").lower()
def test_init_without_user_id(monkeypatch):
"""Should fail gracefully if no user_id is provided."""
notes_tool = NotesTool(tool_config={})
result = notes_tool.execute_action("view")
assert "user_id" in str(result).lower()
def test_view_not_found(notes_tool: NotesTool) -> None:
"""Should return 'No note found.' when no note exists"""
result = notes_tool.execute_action("view")
assert "no note found" in result.lower()
def test_str_replace(notes_tool: NotesTool) -> None:
"""Test string replacement in note"""
# Create a note
notes_tool.execute_action("overwrite", text="Hello world, hello universe")
# Replace text
result = notes_tool.execute_action("str_replace", old_str="hello", new_str="hi")
assert "updated" in result.lower()
# Verify replacement
note = notes_tool.execute_action("view")
assert "hi world, hi universe" in note.lower()
def test_str_replace_not_found(notes_tool: NotesTool) -> None:
"""Test string replacement when string not found"""
notes_tool.execute_action("overwrite", text="Hello world")
result = notes_tool.execute_action("str_replace", old_str="goodbye", new_str="hi")
assert "not found" in result.lower()
def test_insert_line(notes_tool: NotesTool) -> None:
"""Test inserting text at a line number"""
# Create a multiline note
notes_tool.execute_action("overwrite", text="Line 1\nLine 2\nLine 3")
# Insert at line 2
result = notes_tool.execute_action("insert", line_number=2, text="Inserted line")
assert "inserted" in result.lower()
# Verify insertion
note = notes_tool.execute_action("view")
lines = note.split("\n")
assert lines[1] == "Inserted line"
assert lines[2] == "Line 2"
def test_delete_nonexistent_note(monkeypatch):
class FakeResult:
deleted_count = 0
class FakeCollection:
def delete_one(self, *args, **kwargs):
return FakeResult()
monkeypatch.setattr(
"application.core.mongo_db.MongoDB.get_client",
lambda: {"docsgpt": {"notes": FakeCollection()}}
)
notes_tool = NotesTool(tool_config={}, user_id="user123")
result = notes_tool.execute_action("delete")
assert "no note found" in result.lower()
def test_notes_tool_isolation(monkeypatch) -> None:
"""Test that different notes tool instances have isolated notes."""
class FakeCollection:
def __init__(self) -> None:
self.docs = {}
def update_one(self, q, u, upsert=False):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
if key not in self.docs and not upsert:
return type("res", (), {"modified_count": 0})
if key not in self.docs and upsert:
self.docs[key] = {"user_id": user_id, "tool_id": tool_id, "note": ""}
if "$set" in u and "note" in u["$set"]:
self.docs[key]["note"] = u["$set"]["note"]
return type("res", (), {"modified_count": 1})
def find_one(self, q):
user_id = q.get("user_id")
tool_id = q.get("tool_id")
key = f"{user_id}:{tool_id}"
return self.docs.get(key)
fake_collection = FakeCollection()
fake_db = {"notes": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Create two notes tools with different tool_ids for the same user
tool1 = NotesTool({"tool_id": "tool_1"}, user_id="test_user")
tool2 = NotesTool({"tool_id": "tool_2"}, user_id="test_user")
# Create a note in tool1
tool1.execute_action("overwrite", text="Content from tool 1")
# Create a note in tool2
tool2.execute_action("overwrite", text="Content from tool 2")
# Verify that each tool sees only its own content
result1 = tool1.execute_action("view")
result2 = tool2.execute_action("view")
assert "Content from tool 1" in result1
assert "Content from tool 2" not in result1
assert "Content from tool 2" in result2
assert "Content from tool 1" not in result2
def test_notes_tool_auto_generates_tool_id(monkeypatch) -> None:
"""Test that tool_id defaults to 'default_{user_id}' for persistence."""
class FakeCollection:
def __init__(self) -> None:
self.docs = {}
def update_one(self, q, u, upsert=False):
return type("res", (), {"modified_count": 1})
fake_collection = FakeCollection()
fake_db = {"notes": fake_collection}
fake_client = {settings.MONGO_DB_NAME: fake_db}
monkeypatch.setattr("application.core.mongo_db.MongoDB.get_client", lambda: fake_client)
# Create two tools without providing tool_id for the same user
tool1 = NotesTool({}, user_id="test_user")
tool2 = NotesTool({}, user_id="test_user")
# Both should have the same default tool_id for persistence
assert tool1.tool_id == "default_test_user"
assert tool2.tool_id == "default_test_user"
assert tool1.tool_id == tool2.tool_id
# Different users should have different tool_ids
tool3 = NotesTool({}, user_id="another_user")
assert tool3.tool_id == "default_another_user"
assert tool3.tool_id != tool1.tool_id

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"