Compare commits

..

8 Commits

Author SHA1 Message Date
Manish Madan
7c46d8a094 Merge pull request #2029 from abfeb8/main
feat: add Microsoft Entra ID integration
2025-10-15 13:38:48 +05:30
Abhishek Malviya
065939302b Merge pull request #1 from abfeb8/feature/auth-fe-impl
feat: add SharePoint integration with session validation and UI components
2025-10-10 15:51:44 +05:30
Abhishek Malviya
5fa87db9e7 Merge branch 'arc53:main' into main 2025-10-10 15:44:40 +05:30
Abhishek Malviya
cc54cea783 feat: add SharePoint integration with session validation and UI components 2025-10-10 15:15:38 +05:30
Abhishek Malviya
d9f0072112 refactor: remove MICROSOFT_REDIRECT_URI and update SharePointAuth to use CONNECTOR_REDIRECT_BASE_URI 2025-10-09 10:36:12 +05:30
Abhishek Malviya
2b73c0c9a0 feat: add init for Share Point connector module 2025-10-08 10:34:38 +05:30
Abhishek Malviya
da62133d21 Merge branch 'main' into main 2025-10-08 09:40:34 +05:30
Abhishek Malviya
8edb6dcf2a feat: add Microsoft Entra ID integration
- Updated .env-template and settings.py for Microsoft Entra ID configuration.
- Enhanced ConnectorsCallback to support SharePoint authentication.
- Introduced SharePointAuth and SharePointLoader classes.
- Added required dependencies in requirements.txt.
2025-10-07 15:23:32 +05:30
144 changed files with 5043 additions and 11718 deletions

View File

@@ -6,4 +6,17 @@ VITE_API_STREAMING=true
OPENAI_API_BASE=
OPENAI_API_VERSION=
AZURE_DEPLOYMENT_NAME=
AZURE_EMBEDDINGS_DEPLOYMENT_NAME=
AZURE_EMBEDDINGS_DEPLOYMENT_NAME=
#Azure AD Application (client) ID
MICROSOFT_CLIENT_ID=your-azure-ad-client-id
#Azure AD Application client secret
MICROSOFT_CLIENT_SECRET=your-azure-ad-client-secret
#Azure AD Tenant ID (or 'common' for multi-tenant)
MICROSOFT_TENANT_ID=your-azure-ad-tenant-id
#If you are using a Microsoft Entra ID tenant,
#configure the AUTHORITY variable as
#"https://login.microsoftonline.com/TENANT_GUID"
#or "https://login.microsoftonline.com/contoso.onmicrosoft.com".
#Alternatively, use "https://login.microsoftonline.com/common" for multi-tenant app.
MICROSOFT_AUTHORITY=https://{tenentId}.ciamlogin.com/{tenentId}

View File

@@ -13,11 +13,7 @@ updates:
directory: "/frontend" # Location of package manifests
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/extensions/react-widget"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
interval: "daily"

View File

@@ -1,11 +0,0 @@
extends: spelling
level: warning
message: "Did you really mean '%s'?"
ignore:
- "**/node_modules/**"
- "**/dist/**"
- "**/build/**"
- "**/coverage/**"
- "**/public/**"
- "**/static/**"
vocab: DocsGPT

View File

@@ -1,46 +0,0 @@
Ollama
Qdrant
Milvus
Chatwoot
Nextra
VSCode
npm
LLMs
APIs
Groq
SGLang
LMDeploy
OAuth
Vite
LLM
JSONPath
UIs
configs
uncomment
qdrant
vectorstore
docsgpt
llm
GPUs
kubectl
Lightsail
enqueues
chatbot
VSCode's
Shareability
feedbacks
automations
Premade
Signup
Repo
repo
env
URl
agentic
llama_cpp
parsable
SDKs
boolean
bool
hardcode
EOL

View File

@@ -1,26 +0,0 @@
name: Vale Documentation Linter
on:
pull_request:
paths:
- 'docs/**/*.md'
- 'docs/**/*.mdx'
- '**/*.md'
- '.vale.ini'
- '.github/styles/**'
jobs:
vale:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Vale linter
uses: errata-ai/vale-action@v2
with:
files: docs
fail_on_error: false
version: 3.0.5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -3,7 +3,6 @@ __pycache__/
*.py[cod]
*$py.class
experiments
# C extensions
*.so
*.next

View File

@@ -1,5 +0,0 @@
MinAlertLevel = warning
StylesPath = .github/styles
[*.{md,mdx}]
BasedOnStyles = DocsGPT

View File

@@ -147,5 +147,5 @@ Here's a step-by-step guide on how to contribute to DocsGPT:
Thank you for considering contributing to DocsGPT! 🙏
## Questions/collaboration
Feel free to join our [Discord](https://discord.gg/vN7YFfdMpj). We're very friendly and welcoming to new contributors, so don't hesitate to reach out.
Feel free to join our [Discord](https://discord.gg/n5BX8dh8rU). We're very friendly and welcoming to new contributors, so don't hesitate to reach out.
# Thank you so much for considering to contributing DocsGPT!🙏

View File

@@ -3,7 +3,6 @@
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! 🤩.
<img width="1331" height="678" alt="hacktoberfest-mocks-preview" src="https://github.com/user-attachments/assets/633f6377-38db-48f5-b519-a8b3855a9eb4" />
Fill in [this form](https://forms.gle/Npaba4n9Epfyx56S8
) after your PR was merged please
@@ -32,7 +31,7 @@ Non-Code Contributions:
- 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/vN7YFfdMpj) server. We're here to help newcomers, so don't hesitate to jump in! Join us [here](https://discord.gg/vN7YFfdMpj).
- 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.

View File

@@ -16,10 +16,10 @@
<a href="https://github.com/arc53/DocsGPT">![link to main GitHub showing Forks number](https://img.shields.io/github/forks/arc53/docsgpt?style=social)</a>
<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/vN7YFfdMpj">![link to discord](https://img.shields.io/discord/1070046503302877216)</a>
<a href="https://discord.gg/n5BX8dh8rU">![link to discord](https://img.shields.io/discord/1070046503302877216)</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/vN7YFfdMpj">💬 Discord</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>
<a href="https://docs.docsgpt.cloud/">📖 Documentation</a><a href="https://github.com/arc53/DocsGPT/blob/main/CONTRIBUTING.md">👫 Contribute</a><a href="https://blog.docsgpt.cloud/">🗞 Blog</a>
<br>

View File

@@ -12,6 +12,7 @@ from application.core.settings import settings
from application.llm.handlers.handler_creator import LLMHandlerCreator
from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext
from application.retriever.base import BaseRetriever
logger = logging.getLogger(__name__)
@@ -26,7 +27,6 @@ class BaseAgent(ABC):
user_api_key: Optional[str] = None,
prompt: str = "",
chat_history: Optional[List[Dict]] = None,
retrieved_docs: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]] = None,
json_schema: Optional[Dict] = None,
@@ -53,7 +53,6 @@ class BaseAgent(ABC):
user_api_key=user_api_key,
decoded_token=decoded_token,
)
self.retrieved_docs = retrieved_docs or []
self.llm_handler = LLMHandlerCreator.create_handler(
llm_name if llm_name else "default"
)
@@ -66,13 +65,13 @@ class BaseAgent(ABC):
@log_activity()
def gen(
self, query: str, log_context: LogContext = None
self, query: str, retriever: BaseRetriever, log_context: LogContext = None
) -> Generator[Dict, None, None]:
yield from self._gen_inner(query, log_context)
yield from self._gen_inner(query, retriever, log_context)
@abstractmethod
def _gen_inner(
self, query: str, log_context: LogContext
self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]:
pass
@@ -151,7 +150,6 @@ class BaseAgent(ABC):
call_id = getattr(call, "id", None) or str(uuid.uuid4())
# Check if parsing failed
if tool_id is None or action_name is None:
error_message = f"Error: Failed to parse LLM tool call. Tool name: {getattr(call, 'name', 'unknown')}"
logger.error(error_message)
@@ -166,14 +164,13 @@ class BaseAgent(ABC):
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
self.tool_calls.append(tool_call_data)
return "Failed to parse tool call.", call_id
# Check if tool_id exists in available tools
# Check if tool_id exists in available tools
if tool_id not in tools_dict:
error_message = f"Error: Tool ID '{tool_id}' extracted from LLM call not found in available tools_dict. Available IDs: {list(tools_dict.keys())}"
logger.error(error_message)
# Return error result
tool_call_data = {
"tool_name": "unknown",
"call_id": call_id,
@@ -184,6 +181,7 @@ class BaseAgent(ABC):
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
self.tool_calls.append(tool_call_data)
return f"Tool with ID {tool_id} not found.", call_id
tool_call_data = {
"tool_name": tools_dict[tool_id]["name"],
"call_id": call_id,
@@ -225,7 +223,6 @@ class BaseAgent(ABC):
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"],
@@ -237,8 +234,8 @@ class BaseAgent(ABC):
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=tool_config,
@@ -279,14 +276,24 @@ class BaseAgent(ABC):
self,
system_prompt: str,
query: str,
retrieved_data: List[Dict],
) -> List[Dict]:
"""Build messages using pre-rendered system prompt"""
messages = [{"role": "system", "content": system_prompt}]
docs_with_filenames = []
for doc in retrieved_data:
filename = doc.get("filename") or doc.get("title") or doc.get("source")
if filename:
chunk_header = str(filename)
docs_with_filenames.append(f"{chunk_header}\n{doc['text']}")
else:
docs_with_filenames.append(doc["text"])
docs_together = "\n\n".join(docs_with_filenames)
p_chat_combine = system_prompt.replace("{summaries}", docs_together)
messages_combine = [{"role": "system", "content": p_chat_combine}]
for i in self.chat_history:
if "prompt" in i and "response" in i:
messages.append({"role": "user", "content": i["prompt"]})
messages.append({"role": "assistant", "content": i["response"]})
messages_combine.append({"role": "user", "content": i["prompt"]})
messages_combine.append({"role": "assistant", "content": i["response"]})
if "tool_calls" in i:
for tool_call in i["tool_calls"]:
call_id = tool_call.get("call_id") or str(uuid.uuid4())
@@ -306,14 +313,26 @@ class BaseAgent(ABC):
}
}
messages.append(
messages_combine.append(
{"role": "assistant", "content": [function_call_dict]}
)
messages.append(
messages_combine.append(
{"role": "tool", "content": [function_response_dict]}
)
messages.append({"role": "user", "content": query})
return messages
messages_combine.append({"role": "user", "content": query})
return messages_combine
def _retriever_search(
self,
retriever: BaseRetriever,
query: str,
log_context: Optional[LogContext] = None,
) -> List[Dict]:
retrieved_data = retriever.search(query)
if log_context:
data = build_stack_data(retriever, exclude_attributes=["llm"])
log_context.stacks.append({"component": "retriever", "data": data})
return retrieved_data
def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None):
gen_kwargs = {"model": self.gpt_model, "messages": messages}
@@ -324,6 +343,7 @@ class BaseAgent(ABC):
and self.tools
):
gen_kwargs["tools"] = self.tools
if (
self.json_schema
and hasattr(self.llm, "_supports_structured_output")
@@ -337,6 +357,7 @@ class BaseAgent(ABC):
gen_kwargs["response_format"] = structured_format
elif self.llm_name == "google":
gen_kwargs["response_schema"] = structured_format
resp = self.llm.gen_stream(**gen_kwargs)
if log_context:

View File

@@ -1,20 +1,32 @@
import logging
from typing import Dict, Generator
from application.agents.base import BaseAgent
from application.logging import LogContext
from application.retriever.base import BaseRetriever
import logging
logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent):
"""A simplified agent with clear execution flow"""
"""A simplified agent with clear execution flow.
Usage:
1. Processes a query through retrieval
2. Sets up available tools
3. Generates responses using LLM
4. Handles tool interactions if needed
5. Returns standardized outputs
Easy to extend by overriding specific steps.
"""
def _gen_inner(
self, query: str, log_context: LogContext
self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Core generator function for ClassicAgent execution flow"""
# Step 1: Retrieve relevant data
retrieved_data = self._retriever_search(retriever, query, log_context)
# Step 2: Prepare tools
tools_dict = (
self._get_user_tools(self.user)
if not self.user_api_key
@@ -22,16 +34,20 @@ class ClassicAgent(BaseAgent):
)
self._prepare_tools(tools_dict)
messages = self._build_messages(self.prompt, query)
# Step 3: Build and process messages
messages = self._build_messages(self.prompt, query, retrieved_data)
llm_response = self._llm_gen(messages, log_context)
# Step 4: Handle the response
yield from self._handle_response(
llm_response, tools_dict, messages, log_context
)
yield {"sources": self.retrieved_docs}
# Step 5: Return metadata
yield {"sources": retrieved_data}
yield {"tool_calls": self._get_truncated_tool_calls()}
# Log tool calls for debugging
log_context.stacks.append(
{"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}}
)

View File

@@ -1,238 +1,284 @@
import logging
import os
from typing import Any, Dict, Generator, List
from typing import Dict, Generator, List, Any
import logging
from application.agents.base import BaseAgent
from application.logging import build_stack_data, LogContext
from application.retriever.base import BaseRetriever
logger = logging.getLogger(__name__)
MAX_ITERATIONS_REASONING = 10
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
with open(
os.path.join(current_dir, "application/prompts", "react_planning_prompt.txt"), "r"
) as f:
PLANNING_PROMPT_TEMPLATE = f.read()
planning_prompt_template = f.read()
with open(
os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"), "r"
os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"),
"r",
) as f:
FINAL_PROMPT_TEMPLATE = f.read()
final_prompt_template = f.read()
MAX_ITERATIONS_REASONING = 10
class ReActAgent(BaseAgent):
"""
Research and Action (ReAct) Agent - Advanced reasoning agent with iterative planning.
Implements a think-act-observe loop for complex problem-solving:
1. Creates a strategic plan based on the query
2. Executes tools and gathers observations
3. Iteratively refines approach until satisfied
4. Synthesizes final answer from all observations
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plan: str = ""
self.observations: List[str] = []
def _gen_inner(
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Execute ReAct reasoning loop with planning, action, and observation cycles"""
def _extract_content_from_llm_response(self, resp: Any) -> str:
"""
Helper to extract string content from various LLM response types.
Handles strings, message objects (OpenAI-like), and streams.
Adapt stream handling for your specific LLM client if not OpenAI.
"""
collected_content = []
if isinstance(resp, str):
collected_content.append(resp)
elif ( # OpenAI non-streaming or Anthropic non-streaming (older SDK style)
hasattr(resp, "message")
and hasattr(resp.message, "content")
and resp.message.content is not None
):
collected_content.append(resp.message.content)
elif ( # OpenAI non-streaming (Pydantic model), Anthropic new SDK non-streaming
hasattr(resp, "choices")
and resp.choices
and hasattr(resp.choices[0], "message")
and hasattr(resp.choices[0].message, "content")
and resp.choices[0].message.content is not None
):
collected_content.append(resp.choices[0].message.content) # OpenAI
elif ( # Anthropic new SDK non-streaming content block
hasattr(resp, "content")
and isinstance(resp.content, list)
and resp.content
and hasattr(resp.content[0], "text")
):
collected_content.append(resp.content[0].text) # Anthropic
else:
# Assume resp is a stream if not a recognized object
chunk = None
try:
for (
chunk
) in (
resp
): # This will fail if resp is not iterable (e.g. a non-streaming response object)
content_piece = ""
# OpenAI-like stream
if (
hasattr(chunk, "choices")
and len(chunk.choices) > 0
and hasattr(chunk.choices[0], "delta")
and hasattr(chunk.choices[0].delta, "content")
and chunk.choices[0].delta.content is not None
):
content_piece = chunk.choices[0].delta.content
# Anthropic-like stream (ContentBlockDelta)
elif (
hasattr(chunk, "type")
and chunk.type == "content_block_delta"
and hasattr(chunk, "delta")
and hasattr(chunk.delta, "text")
):
content_piece = chunk.delta.text
elif isinstance(chunk, str): # Simplest case: stream of strings
content_piece = chunk
self._reset_state()
tools_dict = (
self._get_tools(self.user_api_key)
if self.user_api_key
else self._get_user_tools(self.user)
)
self._prepare_tools(tools_dict)
for iteration in range(1, MAX_ITERATIONS_REASONING + 1):
yield {"thought": f"Reasoning... (iteration {iteration})\n\n"}
yield from self._planning_phase(query, log_context)
if not self.plan:
logger.warning(
f"ReActAgent: No plan generated in iteration {iteration}"
if content_piece:
collected_content.append(content_piece)
except (
TypeError
): # If resp is not iterable (e.g. a final response object that wasn't caught above)
logger.debug(
f"Response type {type(resp)} could not be iterated as a stream. It might be a non-streaming object not handled by specific checks."
)
except Exception as e:
logger.error(
f"Error processing potential stream chunk: {e}, chunk was: {getattr(chunk, '__dict__', chunk) if chunk is not None else 'N/A'}"
)
break
self.observations.append(f"Plan (iteration {iteration}): {self.plan}")
satisfied = yield from self._execution_phase(query, tools_dict, log_context)
return "".join(collected_content)
if satisfied:
logger.info("ReActAgent: Goal satisfied, stopping reasoning loop")
break
yield from self._synthesis_phase(query, log_context)
def _reset_state(self):
"""Reset agent state for new query"""
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]:
# Reset state for this generation call
self.plan = ""
self.observations = []
retrieved_data = self._retriever_search(retriever, query, log_context)
def _planning_phase(
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Generate strategic plan for query"""
logger.info("ReActAgent: Creating plan...")
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._prepare_tools(tools_dict)
plan_prompt = self._build_planning_prompt(query)
messages = [{"role": "user", "content": plan_prompt}]
docs_together = "\n".join([doc["text"] for doc in retrieved_data])
iterating_reasoning = 0
while iterating_reasoning < MAX_ITERATIONS_REASONING:
iterating_reasoning += 1
# 1. Create Plan
logger.info("ReActAgent: Creating plan...")
plan_stream = self._create_plan(query, docs_together, log_context)
current_plan_parts = []
yield {"thought": f"Reasoning... (iteration {iterating_reasoning})\n\n"}
for line_chunk in plan_stream:
current_plan_parts.append(line_chunk)
yield {"thought": line_chunk}
self.plan = "".join(current_plan_parts)
if self.plan:
self.observations.append(
f"Plan: {self.plan} Iteration: {iterating_reasoning}"
)
plan_stream = self.llm.gen_stream(
max_obs_len = 20000
obs_str = "\n".join(self.observations)
if len(obs_str) > max_obs_len:
obs_str = obs_str[:max_obs_len] + "\n...[observations truncated]"
execution_prompt_str = (
(self.prompt or "")
+ f"\n\nFollow this plan:\n{self.plan}"
+ f"\n\nObservations:\n{obs_str}"
+ f"\n\nIf there is enough data to complete user query '{query}', Respond with 'SATISFIED' only. Otherwise, continue. Dont Menstion 'SATISFIED' in your response if you are not ready. "
)
messages = self._build_messages(execution_prompt_str, query, retrieved_data)
resp_from_llm_gen = self._llm_gen(messages, log_context)
initial_llm_thought_content = self._extract_content_from_llm_response(
resp_from_llm_gen
)
if initial_llm_thought_content:
self.observations.append(
f"Initial thought/response: {initial_llm_thought_content}"
)
else:
logger.info(
"ReActAgent: Initial LLM response (before handler) had no textual content (might be only tool calls)."
)
resp_after_handler = self._llm_handler(
resp_from_llm_gen, tools_dict, messages, log_context
)
for (
tool_call_info
) in (
self.tool_calls
): # Iterate over self.tool_calls populated by _llm_handler
observation_string = (
f"Executed Action: Tool '{tool_call_info.get('tool_name', 'N/A')}' "
f"with arguments '{tool_call_info.get('arguments', '{}')}'. Result: '{str(tool_call_info.get('result', ''))[:200]}...'"
)
self.observations.append(observation_string)
content_after_handler = self._extract_content_from_llm_response(
resp_after_handler
)
if content_after_handler:
self.observations.append(
f"Response after tool execution: {content_after_handler}"
)
else:
logger.info(
"ReActAgent: LLM response after handler had no textual content."
)
if log_context:
log_context.stacks.append(
{
"component": "agent_tool_calls",
"data": {"tool_calls": self.tool_calls.copy()},
}
)
yield {"sources": retrieved_data}
display_tool_calls = []
for tc in self.tool_calls:
cleaned_tc = tc.copy()
if len(str(cleaned_tc.get("result", ""))) > 50:
cleaned_tc["result"] = str(cleaned_tc["result"])[:50] + "..."
display_tool_calls.append(cleaned_tc)
if display_tool_calls:
yield {"tool_calls": display_tool_calls}
if "SATISFIED" in content_after_handler:
logger.info(
"ReActAgent: LLM satisfied with the plan and data. Stopping reasoning."
)
break
# 3. Create Final Answer based on all observations
final_answer_stream = self._create_final_answer(
query, self.observations, log_context
)
for answer_chunk in final_answer_stream:
yield {"answer": answer_chunk}
logger.info("ReActAgent: Finished generating final answer.")
def _create_plan(
self, query: str, docs_data: str, log_context: LogContext = None
) -> Generator[str, None, None]:
plan_prompt_filled = planning_prompt_template.replace("{query}", query)
if "{summaries}" in plan_prompt_filled:
summaries = docs_data if docs_data else "No documents retrieved."
plan_prompt_filled = plan_prompt_filled.replace("{summaries}", summaries)
plan_prompt_filled = plan_prompt_filled.replace("{prompt}", self.prompt or "")
plan_prompt_filled = plan_prompt_filled.replace(
"{observations}", "\n".join(self.observations)
)
messages = [{"role": "user", "content": plan_prompt_filled}]
plan_stream_from_llm = self.llm.gen_stream(
model=self.gpt_model,
messages=messages,
tools=self.tools if self.tools else None,
tools=getattr(self, "tools", None), # Use self.tools
)
if log_context:
data = build_stack_data(self.llm)
log_context.stacks.append({"component": "planning_llm", "data": data})
for chunk in plan_stream_from_llm:
content_piece = self._extract_content_from_llm_response(chunk)
if content_piece:
yield content_piece
def _create_final_answer(
self, query: str, observations: List[str], log_context: LogContext = None
) -> Generator[str, None, None]:
observation_string = "\n".join(observations)
max_obs_len = 10000
if len(observation_string) > max_obs_len:
observation_string = (
observation_string[:max_obs_len] + "\n...[observations truncated]"
)
logger.warning(
"ReActAgent: Truncated observations for final answer prompt due to length."
)
final_answer_prompt_filled = final_prompt_template.format(
query=query, observations=observation_string
)
if log_context:
log_context.stacks.append(
{"component": "planning_llm", "data": build_stack_data(self.llm)}
)
plan_parts = []
for chunk in plan_stream:
content = self._extract_content(chunk)
if content:
plan_parts.append(content)
yield {"thought": content}
self.plan = "".join(plan_parts)
messages = [{"role": "user", "content": final_answer_prompt_filled}]
def _execution_phase(
self, query: str, tools_dict: Dict, log_context: LogContext
) -> Generator[bool, None, None]:
"""Execute plan with tool calls and observations"""
execution_prompt = self._build_execution_prompt(query)
messages = self._build_messages(execution_prompt, query)
llm_response = self._llm_gen(messages, log_context)
initial_content = self._extract_content(llm_response)
if initial_content:
self.observations.append(f"Initial response: {initial_content}")
processed_response = self._llm_handler(
llm_response, tools_dict, messages, log_context
)
for tool_call in self.tool_calls:
observation = (
f"Executed: {tool_call.get('tool_name', 'Unknown')} "
f"with args {tool_call.get('arguments', {})}. "
f"Result: {str(tool_call.get('result', ''))[:200]}"
)
self.observations.append(observation)
final_content = self._extract_content(processed_response)
if final_content:
self.observations.append(f"Response after tools: {final_content}")
if log_context:
log_context.stacks.append(
{
"component": "agent_tool_calls",
"data": {"tool_calls": self.tool_calls.copy()},
}
)
yield {"sources": self.retrieved_docs}
yield {"tool_calls": self._get_truncated_tool_calls()}
return "SATISFIED" in (final_content or "")
def _synthesis_phase(
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Synthesize final answer from all observations"""
logger.info("ReActAgent: Generating final answer...")
final_prompt = self._build_final_answer_prompt(query)
messages = [{"role": "user", "content": final_prompt}]
final_stream = self.llm.gen_stream(
# Final answer should synthesize, not call tools.
final_answer_stream_from_llm = self.llm.gen_stream(
model=self.gpt_model, messages=messages, tools=None
)
if log_context:
log_context.stacks.append(
{"component": "final_answer_llm", "data": build_stack_data(self.llm)}
)
for chunk in final_stream:
content = self._extract_content(chunk)
if content:
yield {"answer": content}
data = build_stack_data(self.llm)
log_context.stacks.append({"component": "final_answer_llm", "data": data})
def _build_planning_prompt(self, query: str) -> str:
"""Build planning phase prompt"""
prompt = PLANNING_PROMPT_TEMPLATE.replace("{query}", query)
prompt = prompt.replace("{prompt}", self.prompt or "")
prompt = prompt.replace("{summaries}", "")
prompt = prompt.replace("{observations}", "\n".join(self.observations))
return prompt
def _build_execution_prompt(self, query: str) -> str:
"""Build execution phase prompt with plan and observations"""
observations_str = "\n".join(self.observations)
if len(observations_str) > 20000:
observations_str = observations_str[:20000] + "\n...[truncated]"
return (
f"{self.prompt or ''}\n\n"
f"Follow this plan:\n{self.plan}\n\n"
f"Observations:\n{observations_str}\n\n"
f"If sufficient data exists to answer '{query}', respond with 'SATISFIED'. "
f"Otherwise, continue executing the plan."
)
def _build_final_answer_prompt(self, query: str) -> str:
"""Build final synthesis prompt"""
observations_str = "\n".join(self.observations)
if len(observations_str) > 10000:
observations_str = observations_str[:10000] + "\n...[truncated]"
logger.warning("ReActAgent: Observations truncated for final answer")
return FINAL_PROMPT_TEMPLATE.format(query=query, observations=observations_str)
def _extract_content(self, response: Any) -> str:
"""Extract text content from various LLM response formats"""
if not response:
return ""
collected = []
if isinstance(response, str):
return response
if hasattr(response, "message") and hasattr(response.message, "content"):
if response.message.content:
return response.message.content
if hasattr(response, "choices") and response.choices:
if hasattr(response.choices[0], "message"):
content = response.choices[0].message.content
if content:
return content
if hasattr(response, "content") and isinstance(response.content, list):
if response.content and hasattr(response.content[0], "text"):
return response.content[0].text
try:
for chunk in response:
content_piece = ""
if hasattr(chunk, "choices") and chunk.choices:
if hasattr(chunk.choices[0], "delta"):
delta_content = chunk.choices[0].delta.content
if delta_content:
content_piece = delta_content
elif hasattr(chunk, "type") and chunk.type == "content_block_delta":
if hasattr(chunk, "delta") and hasattr(chunk.delta, "text"):
content_piece = chunk.delta.text
elif isinstance(chunk, str):
content_piece = chunk
if content_piece:
collected.append(content_piece)
except (TypeError, AttributeError):
logger.debug(
f"Response not iterable or unexpected format: {type(response)}"
)
except Exception as e:
logger.error(f"Error extracting content: {e}")
return "".join(collected)
for chunk in final_answer_stream_from_llm:
content_piece = self._extract_content_from_llm_response(chunk)
if content_piece:
yield content_piece

View File

@@ -1,321 +0,0 @@
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 TodoListTool(Tool):
"""Todo List
Manages todo items for users. Supports creating, viewing, updating, and deleting todos.
"""
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 todo list tool instance (from user_tools._id)
This ensures each user's tool configuration has isolated todos
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["todos"]
# -----------------------------
# Action implementations
# -----------------------------
def execute_action(self, action_name: str, **kwargs: Any) -> str:
"""Execute an action by name.
Args:
action_name: One of list, create, get, update, complete, delete.
**kwargs: Parameters for the action.
Returns:
A human-readable string result.
"""
if not self.user_id:
return "Error: TodoListTool requires a valid user_id."
if action_name == "list":
return self._list()
if action_name == "create":
return self._create(kwargs.get("title", ""))
if action_name == "get":
return self._get(kwargs.get("todo_id"))
if action_name == "update":
return self._update(
kwargs.get("todo_id"),
kwargs.get("title", "")
)
if action_name == "complete":
return self._complete(kwargs.get("todo_id"))
if action_name == "delete":
return self._delete(kwargs.get("todo_id"))
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": "list",
"description": "List all todos for the user.",
"parameters": {"type": "object", "properties": {}},
},
{
"name": "create",
"description": "Create a new todo item.",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Title of the todo item."
}
},
"required": ["title"],
},
},
{
"name": "get",
"description": "Get a specific todo by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to retrieve."
}
},
"required": ["todo_id"],
},
},
{
"name": "update",
"description": "Update a todo's title by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to update."
},
"title": {
"type": "string",
"description": "The new title for the todo."
}
},
"required": ["todo_id", "title"],
},
},
{
"name": "complete",
"description": "Mark a todo as completed.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to mark as completed."
}
},
"required": ["todo_id"],
},
},
{
"name": "delete",
"description": "Delete a specific todo by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to delete."
}
},
"required": ["todo_id"],
},
},
]
def get_config_requirements(self) -> Dict[str, Any]:
"""Return configuration requirements."""
return {}
# -----------------------------
# Internal helpers
# -----------------------------
def _coerce_todo_id(self, value: Optional[Any]) -> Optional[int]:
"""Convert todo identifiers to sequential integers."""
if value is None:
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
numeric_value = int(stripped)
return numeric_value if numeric_value > 0 else None
return None
def _get_next_todo_id(self) -> int:
"""Get the next sequential todo_id for this user and tool.
Returns a simple integer (1, 2, 3, ...) scoped to this user/tool.
With 5-10 todos max, scanning is negligible.
"""
# Find all todos for this user/tool and get their IDs
todos = list(self.collection.find(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"todo_id": 1}
))
# Find the maximum todo_id
max_id = 0
for todo in todos:
todo_id = self._coerce_todo_id(todo.get("todo_id"))
if todo_id is not None:
max_id = max(max_id, todo_id)
return max_id + 1
def _list(self) -> str:
"""List all todos for the user."""
cursor = self.collection.find({"user_id": self.user_id, "tool_id": self.tool_id})
todos = list(cursor)
if not todos:
return "No todos found."
result_lines = ["Todos:"]
for doc in todos:
todo_id = doc.get("todo_id")
title = doc.get("title", "Untitled")
status = doc.get("status", "open")
line = f"[{todo_id}] {title} ({status})"
result_lines.append(line)
return "\n".join(result_lines)
def _create(self, title: str) -> str:
"""Create a new todo item."""
title = (title or "").strip()
if not title:
return "Error: Title is required."
now = datetime.now()
todo_id = self._get_next_todo_id()
doc = {
"todo_id": todo_id,
"user_id": self.user_id,
"tool_id": self.tool_id,
"title": title,
"status": "open",
"created_at": now,
"updated_at": now,
}
self.collection.insert_one(doc)
return f"Todo created with ID {todo_id}: {title}"
def _get(self, todo_id: Optional[Any]) -> str:
"""Get a specific todo by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
doc = self.collection.find_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
title = doc.get("title", "Untitled")
status = doc.get("status", "open")
result = f"Todo [{parsed_todo_id}]:\nTitle: {title}\nStatus: {status}"
return result
def _update(self, todo_id: Optional[Any], title: str) -> str:
"""Update a todo's title by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
title = (title or "").strip()
if not title:
return "Error: Title is required."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"title": title, "updated_at": datetime.now()}}
)
if result.matched_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} updated to: {title}"
def _complete(self, todo_id: Optional[Any]) -> str:
"""Mark a todo as completed."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"status": "completed", "updated_at": datetime.now()}}
)
if result.matched_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} marked as completed."
def _delete(self, todo_id: Optional[Any]) -> str:
"""Delete a specific todo by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.delete_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
if result.deleted_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} deleted."

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 in {"mcp_tool", "notes", "memory", "todo_list"} 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 in {"mcp_tool", "memory", "todo_list"} 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

@@ -54,10 +54,6 @@ class AnswerResource(Resource, BaseAnswerResource):
default=True,
description="Whether to save the conversation",
),
"passthrough": fields.Raw(
required=False,
description="Dynamic parameters to inject into prompt template",
),
},
)
@@ -73,17 +69,8 @@ class AnswerResource(Resource, BaseAnswerResource):
processor.initialize()
if not processor.decoded_token:
return make_response({"error": "Unauthorized"}, 401)
docs_together, docs_list = processor.pre_fetch_docs(
data.get("question", "")
)
tools_data = processor.pre_fetch_tools()
agent = processor.create_agent(
docs_together=docs_together,
docs=docs_list,
tools_data=tools_data,
)
agent = processor.create_agent()
retriever = processor.create_retriever()
if error := self.check_usage(processor.agent_config):
return error
@@ -91,6 +78,7 @@ class AnswerResource(Resource, BaseAnswerResource):
stream = self.complete_stream(
question=data["question"],
agent=agent,
retriever=retriever,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,

View File

@@ -3,7 +3,7 @@ import json
import logging
from typing import Any, Dict, Generator, List, Optional
from flask import jsonify, make_response, Response
from flask import Response, make_response, jsonify
from flask_restx import Namespace
from application.api.answer.services.conversation_service import ConversationService
@@ -41,7 +41,9 @@ class BaseAnswerResource:
return missing_fields
return None
def check_usage(self, agent_config: Dict) -> Optional[Response]:
def check_usage(
self, agent_config: Dict
) -> Optional[Response]:
"""Check if there is a usage limit and if it is exceeded
Args:
@@ -49,40 +51,30 @@ class BaseAnswerResource:
Returns:
None or Response if either of limits exceeded.
"""
api_key = agent_config.get("user_api_key")
if not api_key:
return None
agents_collection = self.db["agents"]
agent = agents_collection.find_one({"key": api_key})
if not agent:
return make_response(
jsonify({"success": False, "message": "Invalid API key."}), 401
jsonify(
{
"success": False,
"message": "Invalid API key."
}
),
401
)
limited_token_mode_raw = agent.get("limited_token_mode", False)
limited_request_mode_raw = agent.get("limited_request_mode", False)
limited_token_mode = (
limited_token_mode_raw
if isinstance(limited_token_mode_raw, bool)
else limited_token_mode_raw == "True"
)
limited_request_mode = (
limited_request_mode_raw
if isinstance(limited_request_mode_raw, bool)
else limited_request_mode_raw == "True"
)
token_limit = int(
agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"])
)
request_limit = int(
agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"])
)
limited_token_mode = agent.get("limited_token_mode", False)
limited_request_mode = agent.get("limited_request_mode", False)
token_limit = int(agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]))
request_limit = int(agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]))
token_usage_collection = self.db["token_usage"]
@@ -91,20 +83,18 @@ class BaseAnswerResource:
match_query = {
"timestamp": {"$gte": start_date, "$lte": end_date},
"api_key": api_key,
"api_key": api_key
}
if limited_token_mode:
token_pipeline = [
{"$match": match_query},
{
"$group": {
"_id": None,
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
"total_tokens": {"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}}
}
},
}
]
token_result = list(token_usage_collection.aggregate(token_pipeline))
daily_token_usage = token_result[0]["total_tokens"] if token_result else 0
@@ -118,33 +108,26 @@ class BaseAnswerResource:
if not limited_token_mode and not limited_request_mode:
return None
elif limited_token_mode and token_limit > daily_token_usage:
return None
elif limited_request_mode and request_limit > daily_request_usage:
return None
token_exceeded = (
limited_token_mode and token_limit > 0 and daily_token_usage >= token_limit
return make_response(
jsonify(
{
"success": False,
"message": "Exceeding usage limit, please try again later."
}
),
429, # too many requests
)
request_exceeded = (
limited_request_mode
and request_limit > 0
and daily_request_usage >= request_limit
)
if token_exceeded or request_exceeded:
return make_response(
jsonify(
{
"success": False,
"message": "Exceeding usage limit, please try again later.",
}
),
429,
)
return None
def complete_stream(
self,
question: str,
agent: Any,
retriever: Any,
conversation_id: Optional[str],
user_api_key: Optional[str],
decoded_token: Dict[str, Any],
@@ -173,7 +156,6 @@ class BaseAnswerResource:
agent_id: ID of agent used
is_shared_usage: Flag for shared agent usage
shared_token: Token for shared agent
retrieved_docs: Pre-fetched documents for sources (optional)
Yields:
Server-sent event strings
@@ -184,7 +166,7 @@ class BaseAnswerResource:
schema_info = None
structured_chunks = []
for line in agent.gen(query=question):
for line in agent.gen(query=question, retriever=retriever):
if "answer" in line:
response_full += str(line["answer"])
if line.get("structured"):
@@ -265,6 +247,7 @@ class BaseAnswerResource:
data = json.dumps(id_data)
yield f"data: {data}\n\n"
retriever_params = retriever.get_params()
log_data = {
"action": "stream_answer",
"level": "info",
@@ -273,6 +256,7 @@ class BaseAnswerResource:
"question": question,
"response": response_full,
"sources": source_log_docs,
"retriever_params": retriever_params,
"attachments": attachment_ids,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
@@ -280,19 +264,24 @@ class BaseAnswerResource:
log_data["structured_output"] = True
if schema_info:
log_data["schema"] = schema_info
# Clean up text fields to be no longer than 10000 characters
# clean up text fields to be no longer than 10000 characters
for key, value in log_data.items():
if isinstance(value, str) and len(value) > 10000:
log_data[key] = value[:10000]
self.user_logs_collection.insert_one(log_data)
# End of stream
data = json.dumps({"type": "end"})
yield f"data: {data}\n\n"
except GeneratorExit:
logger.info(f"Stream aborted by client for question: {question[:50]}... ")
# Save partial response
# Client aborted the connection
logger.info(
f"Stream aborted by client for question: {question[:50]}... "
)
# Save partial response to database before exiting
if should_save_conversation and response_full:
try:
if isNoneDoc:
@@ -322,9 +311,7 @@ class BaseAnswerResource:
attachment_ids=attachment_ids,
)
except Exception as e:
logger.error(
f"Error saving partial response: {str(e)}", exc_info=True
)
logger.error(f"Error saving partial response: {str(e)}", exc_info=True)
raise
except Exception as e:
logger.error(f"Error in stream: {str(e)}", exc_info=True)

View File

@@ -60,10 +60,6 @@ class StreamResource(Resource, BaseAnswerResource):
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
),
"passthrough": fields.Raw(
required=False,
description="Dynamic parameters to inject into prompt template",
),
},
)
@@ -77,20 +73,17 @@ class StreamResource(Resource, BaseAnswerResource):
processor = StreamProcessor(data, decoded_token)
try:
processor.initialize()
docs_together, docs_list = processor.pre_fetch_docs(data["question"])
tools_data = processor.pre_fetch_tools()
agent = processor.create_agent(
docs_together=docs_together, docs=docs_list, tools_data=tools_data
)
agent = processor.create_agent()
retriever = processor.create_retriever()
if error := self.check_usage(processor.agent_config):
return error
return Response(
self.complete_stream(
question=data["question"],
agent=agent,
retriever=retriever,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,

View File

@@ -133,9 +133,10 @@ class ConversationService:
messages_summary = [
{
"role": "system",
"content": "You are a helpful assistant that creates concise conversation titles. "
"Summarize conversations in 3 words or less using the same language as the user.",
"role": "assistant",
"content": "Summarise following conversation in no more than 3 "
"words, respond ONLY with the summary, use the same "
"language as the user query",
},
{
"role": "user",

View File

@@ -1,97 +0,0 @@
import logging
from typing import Any, Dict, Optional
from application.templates.namespaces import NamespaceManager
from application.templates.template_engine import TemplateEngine, TemplateRenderError
logger = logging.getLogger(__name__)
class PromptRenderer:
"""Service for rendering prompts with dynamic context using namespaces"""
def __init__(self):
self.template_engine = TemplateEngine()
self.namespace_manager = NamespaceManager()
def render_prompt(
self,
prompt_content: str,
user_id: Optional[str] = None,
request_id: Optional[str] = None,
passthrough_data: Optional[Dict[str, Any]] = None,
docs: Optional[list] = None,
docs_together: Optional[str] = None,
tools_data: Optional[Dict[str, Any]] = None,
**kwargs,
) -> str:
"""
Render prompt with full context from all namespaces.
Args:
prompt_content: Raw prompt template string
user_id: Current user identifier
request_id: Unique request identifier
passthrough_data: Parameters from web request
docs: RAG retrieved documents
docs_together: Concatenated document content
tools_data: Pre-fetched tool results organized by tool name
**kwargs: Additional parameters for namespace builders
Returns:
Rendered prompt string with all variables substituted
Raises:
TemplateRenderError: If template rendering fails
"""
if not prompt_content:
return ""
uses_template = self._uses_template_syntax(prompt_content)
if not uses_template:
return self._apply_legacy_substitutions(prompt_content, docs_together)
try:
context = self.namespace_manager.build_context(
user_id=user_id,
request_id=request_id,
passthrough_data=passthrough_data,
docs=docs,
docs_together=docs_together,
tools_data=tools_data,
**kwargs,
)
return self.template_engine.render(prompt_content, context)
except TemplateRenderError:
raise
except Exception as e:
error_msg = f"Prompt rendering failed: {str(e)}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
def _uses_template_syntax(self, prompt_content: str) -> bool:
"""Check if prompt uses Jinja2 template syntax"""
return "{{" in prompt_content and "}}" in prompt_content
def _apply_legacy_substitutions(
self, prompt_content: str, docs_together: Optional[str] = None
) -> str:
"""
Apply backward-compatible substitutions for old prompt format.
Handles legacy {summaries} and {query} placeholders during transition period.
"""
if docs_together:
prompt_content = prompt_content.replace("{summaries}", docs_together)
return prompt_content
def validate_template(self, prompt_content: str) -> bool:
"""Validate prompt template syntax"""
return self.template_engine.validate_template(prompt_content)
def extract_variables(self, prompt_content: str) -> set[str]:
"""Extract all variable names from prompt template"""
return self.template_engine.extract_variables(prompt_content)

View File

@@ -3,7 +3,7 @@ import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional, Set
from typing import Any, Dict, Optional
from bson.dbref import DBRef
@@ -11,15 +11,10 @@ from bson.objectid import ObjectId
from application.agents.agent_creator import AgentCreator
from application.api.answer.services.conversation_service import ConversationService
from application.api.answer.services.prompt_renderer import PromptRenderer
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.retriever.retriever_creator import RetrieverCreator
from application.utils import (
calculate_doc_token_budget,
get_gpt_model,
limit_chat_history,
)
from application.utils import get_gpt_model, limit_chat_history
logger = logging.getLogger(__name__)
@@ -78,16 +73,12 @@ class StreamProcessor:
self.all_sources = []
self.attachments = []
self.history = []
self.retrieved_docs = []
self.agent_config = {}
self.retriever_config = {}
self.is_shared_usage = False
self.shared_token = None
self.gpt_model = get_gpt_model()
self.conversation_service = ConversationService()
self.prompt_renderer = PromptRenderer()
self._prompt_content: Optional[str] = None
self._required_tool_actions: Optional[Dict[str, Set[Optional[str]]]] = None
def initialize(self):
"""Initialize all required components for processing"""
@@ -320,312 +311,19 @@ class StreamProcessor:
)
def _configure_retriever(self):
history_token_limit = int(self.data.get("token_limit", 2000))
doc_token_limit = calculate_doc_token_budget(
gpt_model=self.gpt_model, history_token_limit=history_token_limit
)
"""Configure the retriever based on request data"""
self.retriever_config = {
"retriever_name": self.data.get("retriever", "classic"),
"chunks": int(self.data.get("chunks", 2)),
"doc_token_limit": doc_token_limit,
"history_token_limit": history_token_limit,
"token_limit": self.data.get("token_limit", settings.DEFAULT_MAX_HISTORY),
}
api_key = self.data.get("api_key") or self.agent_key
if not api_key and "isNoneDoc" in self.data and self.data["isNoneDoc"]:
self.retriever_config["chunks"] = 0
def create_retriever(self):
return RetrieverCreator.create_retriever(
self.retriever_config["retriever_name"],
source=self.source,
chat_history=self.history,
prompt=get_prompt(self.agent_config["prompt_id"], self.prompts_collection),
chunks=self.retriever_config["chunks"],
doc_token_limit=self.retriever_config.get("doc_token_limit", 50000),
gpt_model=self.gpt_model,
user_api_key=self.agent_config["user_api_key"],
decoded_token=self.decoded_token,
)
def pre_fetch_docs(self, question: str) -> tuple[Optional[str], Optional[list]]:
"""Pre-fetch documents for template rendering before agent creation"""
if self.data.get("isNoneDoc", False):
logger.info("Pre-fetch skipped: isNoneDoc=True")
return None, None
try:
retriever = self.create_retriever()
logger.info(
f"Pre-fetching docs with chunks={retriever.chunks}, doc_token_limit={retriever.doc_token_limit}"
)
docs = retriever.search(question)
logger.info(f"Pre-fetch retrieved {len(docs) if docs else 0} documents")
if not docs:
logger.info("Pre-fetch: No documents returned from search")
return None, None
self.retrieved_docs = docs
docs_with_filenames = []
for doc in docs:
filename = doc.get("filename") or doc.get("title") or doc.get("source")
if filename:
chunk_header = str(filename)
docs_with_filenames.append(f"{chunk_header}\n{doc['text']}")
else:
docs_with_filenames.append(doc["text"])
docs_together = "\n\n".join(docs_with_filenames)
logger.info(f"Pre-fetch docs_together size: {len(docs_together)} chars")
return docs_together, docs
except Exception as e:
logger.error(f"Failed to pre-fetch docs: {str(e)}", exc_info=True)
return None, None
def pre_fetch_tools(self) -> Optional[Dict[str, Any]]:
"""Pre-fetch tool data for template rendering before agent creation
Can be controlled via:
1. Global setting: ENABLE_TOOL_PREFETCH in .env
2. Per-request: disable_tool_prefetch in request data
"""
if not settings.ENABLE_TOOL_PREFETCH:
logger.info(
"Tool pre-fetching disabled globally via ENABLE_TOOL_PREFETCH setting"
)
return None
if self.data.get("disable_tool_prefetch", False):
logger.info("Tool pre-fetching disabled for this request")
return None
required_tool_actions = self._get_required_tool_actions()
filtering_enabled = required_tool_actions is not None
try:
user_tools_collection = self.db["user_tools"]
user_id = self.initial_user_id or "local"
user_tools = list(
user_tools_collection.find({"user": user_id, "status": True})
)
if not user_tools:
return None
tools_data = {}
for tool_doc in user_tools:
tool_name = tool_doc.get("name")
tool_id = str(tool_doc.get("_id"))
if filtering_enabled:
required_actions_by_name = required_tool_actions.get(
tool_name, set()
)
required_actions_by_id = required_tool_actions.get(tool_id, set())
required_actions = required_actions_by_name | required_actions_by_id
if not required_actions:
continue
else:
required_actions = None
tool_data = self._fetch_tool_data(tool_doc, required_actions)
if tool_data:
tools_data[tool_name] = tool_data
tools_data[tool_id] = tool_data
return tools_data if tools_data else None
except Exception as e:
logger.warning(f"Failed to pre-fetch tools: {type(e).__name__}")
return None
def _fetch_tool_data(
self,
tool_doc: Dict[str, Any],
required_actions: Optional[Set[Optional[str]]],
) -> Optional[Dict[str, Any]]:
"""Fetch and execute tool actions with saved parameters"""
try:
from application.agents.tools.tool_manager import ToolManager
tool_name = tool_doc.get("name")
tool_config = tool_doc.get("config", {}).copy()
tool_config["tool_id"] = str(tool_doc["_id"])
tool_manager = ToolManager(config={tool_name: tool_config})
user_id = self.initial_user_id or "local"
tool = tool_manager.load_tool(tool_name, tool_config, user_id=user_id)
if not tool:
logger.debug(f"Tool '{tool_name}' failed to load")
return None
tool_actions = tool.get_actions_metadata()
if not tool_actions:
logger.debug(f"Tool '{tool_name}' has no actions")
return None
saved_actions = tool_doc.get("actions", [])
include_all_actions = required_actions is None or (
required_actions and None in required_actions
)
allowed_actions: Set[str] = (
{action for action in required_actions if isinstance(action, str)}
if required_actions
else set()
)
action_results = {}
for action_meta in tool_actions:
action_name = action_meta.get("name")
if action_name is None:
continue
if (
not include_all_actions
and allowed_actions
and action_name not in allowed_actions
):
continue
try:
saved_action = None
for sa in saved_actions:
if sa.get("name") == action_name:
saved_action = sa
break
action_params = action_meta.get("parameters", {})
properties = action_params.get("properties", {})
kwargs = {}
for param_name, param_spec in properties.items():
if saved_action:
saved_props = saved_action.get("parameters", {}).get(
"properties", {}
)
if param_name in saved_props:
param_value = saved_props[param_name].get("value")
if param_value is not None:
kwargs[param_name] = param_value
continue
if param_name in tool_config:
kwargs[param_name] = tool_config[param_name]
elif "default" in param_spec:
kwargs[param_name] = param_spec["default"]
result = tool.execute_action(action_name, **kwargs)
action_results[action_name] = result
except Exception as e:
logger.debug(
f"Action '{action_name}' execution failed: {type(e).__name__}"
)
continue
return action_results if action_results else None
except Exception as e:
logger.debug(f"Tool pre-fetch failed for '{tool_name}': {type(e).__name__}")
return None
def _get_prompt_content(self) -> Optional[str]:
"""Retrieve and cache the raw prompt content for the current agent configuration."""
if self._prompt_content is not None:
return self._prompt_content
prompt_id = (
self.agent_config.get("prompt_id")
if isinstance(self.agent_config, dict)
else None
)
if not prompt_id:
return None
try:
self._prompt_content = get_prompt(prompt_id, self.prompts_collection)
except ValueError as e:
logger.debug(f"Invalid prompt ID '{prompt_id}': {str(e)}")
self._prompt_content = None
except Exception as e:
logger.debug(f"Failed to fetch prompt '{prompt_id}': {type(e).__name__}")
self._prompt_content = None
return self._prompt_content
def _get_required_tool_actions(self) -> Optional[Dict[str, Set[Optional[str]]]]:
"""Determine which tool actions are referenced in the prompt template"""
if self._required_tool_actions is not None:
return self._required_tool_actions
prompt_content = self._get_prompt_content()
if prompt_content is None:
return None
if "{{" not in prompt_content or "}}" not in prompt_content:
self._required_tool_actions = {}
return self._required_tool_actions
try:
from application.templates.template_engine import TemplateEngine
template_engine = TemplateEngine()
usages = template_engine.extract_tool_usages(prompt_content)
self._required_tool_actions = usages
return self._required_tool_actions
except Exception as e:
logger.debug(f"Failed to extract tool usages: {type(e).__name__}")
self._required_tool_actions = {}
return self._required_tool_actions
def _fetch_memory_tool_data(
self, tool_doc: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Fetch memory tool data for pre-injection into prompt"""
try:
tool_config = tool_doc.get("config", {}).copy()
tool_config["tool_id"] = str(tool_doc["_id"])
from application.agents.tools.memory import MemoryTool
memory_tool = MemoryTool(tool_config, self.initial_user_id)
root_view = memory_tool.execute_action("view", path="/")
if "Error:" in root_view or not root_view.strip():
return None
return {"root": root_view, "available": True}
except Exception as e:
logger.warning(f"Failed to fetch memory tool data: {str(e)}")
return None
def create_agent(
self,
docs_together: Optional[str] = None,
docs: Optional[list] = None,
tools_data: Optional[Dict[str, Any]] = None,
):
"""Create and return the configured agent with rendered prompt"""
raw_prompt = self._get_prompt_content()
if raw_prompt is None:
raw_prompt = get_prompt(
self.agent_config["prompt_id"], self.prompts_collection
)
self._prompt_content = raw_prompt
rendered_prompt = self.prompt_renderer.render_prompt(
prompt_content=raw_prompt,
user_id=self.initial_user_id,
request_id=self.data.get("request_id"),
passthrough_data=self.data.get("passthrough"),
docs=docs,
docs_together=docs_together,
tools_data=tools_data,
)
def create_agent(self):
"""Create and return the configured agent"""
return AgentCreator.create_agent(
self.agent_config["agent_type"],
endpoint="stream",
@@ -633,10 +331,23 @@ class StreamProcessor:
gpt_model=self.gpt_model,
api_key=settings.API_KEY,
user_api_key=self.agent_config["user_api_key"],
prompt=rendered_prompt,
prompt=get_prompt(self.agent_config["prompt_id"], self.prompts_collection),
chat_history=self.history,
retrieved_docs=self.retrieved_docs,
decoded_token=self.decoded_token,
attachments=self.attachments,
json_schema=self.agent_config.get("json_schema"),
)
def create_retriever(self):
"""Create and return the configured retriever"""
return RetrieverCreator.create_retriever(
self.retriever_config["retriever_name"],
source=self.source,
chat_history=self.history,
prompt=get_prompt(self.agent_config["prompt_id"], self.prompts_collection),
chunks=self.retriever_config["chunks"],
token_limit=self.retriever_config["token_limit"],
gpt_model=self.gpt_model,
user_api_key=self.agent_config["user_api_key"],
decoded_token=self.decoded_token,
)

View File

@@ -113,10 +113,14 @@ class ConnectorsCallback(Resource):
session_token = str(uuid.uuid4())
try:
credentials = auth.create_credentials_from_token_info(token_info)
service = auth.build_drive_service(credentials)
user_info = service.about().get(fields="user").execute()
user_email = user_info.get('user', {}).get('emailAddress', 'Connected User')
if provider == "google_drive":
credentials = auth.create_credentials_from_token_info(token_info)
service = auth.build_drive_service(credentials)
user_info = service.about().get(fields="user").execute()
user_email = user_info.get('user', {}).get('emailAddress', 'Connected User')
else:
user_email = token_info.get('user_info', {}).get('email', 'Connected User')
except Exception as e:
current_app.logger.warning(f"Could not get user info: {e}")
user_email = 'Connected User'

View File

@@ -10,6 +10,7 @@ from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.core.settings import settings
from application.api.user.base import (
agents_collection,
db,
@@ -19,7 +20,6 @@ from application.api.user.base import (
storage,
users_collection,
)
from application.core.settings import settings
from application.utils import (
check_required_fields,
generate_image_url,
@@ -76,13 +76,9 @@ class GetAgent(Resource):
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
),
"token_limit": agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]),
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
),
"request_limit": agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
@@ -153,13 +149,9 @@ class GetAgents(Resource):
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
),
"token_limit": agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]),
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
),
"request_limit": agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
@@ -217,19 +209,21 @@ class CreateAgent(Resource):
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
required=False,
description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
required=False,
description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
required=False,
description="Whether the agent is in limited request mode",
description="Whether the agent is in limited request mode"
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
description="Request limit for the agent in limited mode"
)
},
)
@@ -375,26 +369,10 @@ class CreateAgent(Resource):
"agent_type": data.get("agent_type", ""),
"status": data.get("status"),
"json_schema": data.get("json_schema"),
"limited_token_mode": (
data.get("limited_token_mode") == "True"
if isinstance(data.get("limited_token_mode"), str)
else bool(data.get("limited_token_mode", False))
),
"token_limit": int(
data.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
)
),
"limited_request_mode": (
data.get("limited_request_mode") == "True"
if isinstance(data.get("limited_request_mode"), str)
else bool(data.get("limited_request_mode", False))
),
"request_limit": int(
data.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
)
),
"limited_token_mode": data.get("limited_token_mode", False),
"token_limit": data.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]),
"limited_request_mode": data.get("limited_request_mode", False),
"request_limit": data.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]),
"createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None,
@@ -451,19 +429,21 @@ class UpdateAgent(Resource):
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
required=False,
description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
required=False,
description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
require=False,
description="Whether the agent is in limited request mode",
description="Whether the agent is in limited request mode"
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
description="Request limit for the agent in limited mode"
)
},
)
@@ -554,7 +534,7 @@ class UpdateAgent(Resource):
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"request_limit"
]
for field in allowed_fields:
@@ -672,15 +652,8 @@ class UpdateAgent(Resource):
else:
update_fields[field] = None
elif field == "limited_token_mode":
raw_value = data.get("limited_token_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields[field] = bool_value
if bool_value and data.get("token_limit") is None:
is_mode_enabled = data.get("limited_token_mode", False)
if is_mode_enabled and data.get("token_limit") is None:
return make_response(
jsonify(
{
@@ -691,15 +664,8 @@ class UpdateAgent(Resource):
400,
)
elif field == "limited_request_mode":
raw_value = data.get("limited_request_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields[field] = bool_value
if bool_value and data.get("request_limit") is None:
is_mode_enabled = data.get("limited_request_mode", False)
if is_mode_enabled and data.get("request_limit") is None:
return make_response(
jsonify(
{
@@ -711,11 +677,7 @@ class UpdateAgent(Resource):
)
elif field == "token_limit":
token_limit = data.get("token_limit")
# Convert to int and store
update_fields[field] = int(token_limit) if token_limit else 0
# Validate consistency with mode
if update_fields[field] > 0 and not data.get("limited_token_mode"):
if token_limit is not None and not data.get("limited_token_mode"):
return make_response(
jsonify(
{
@@ -727,9 +689,7 @@ class UpdateAgent(Resource):
)
elif field == "request_limit":
request_limit = data.get("request_limit")
update_fields[field] = int(request_limit) if request_limit else 0
if update_fields[field] > 0 and not data.get("limited_request_mode"):
if request_limit is not None and not data.get("limited_request_mode"):
return make_response(
jsonify(
{

View File

@@ -10,7 +10,7 @@ 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.tts_creator import TTSCreator
from application.tts.google_tts import GoogleTTS
from application.utils import safe_filename
@@ -25,7 +25,7 @@ class StoreAttachment(Resource):
api.model(
"AttachmentModel",
{
"file": fields.Raw(required=True, description="File(s) to upload"),
"file": fields.Raw(required=True, description="File to upload"),
"api_key": fields.String(
required=False, description="API key (optional)"
),
@@ -33,24 +33,18 @@ class StoreAttachment(Resource):
)
)
@api.doc(
description="Stores one or multiple attachments without vectorization or training. Supports user or API key authentication."
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")
files = request.files.getlist("file")
if not files:
single_file = request.files.get("file")
if single_file:
files = [single_file]
if not files or all(f.filename == "" for f in files):
file = request.files.get("file")
if not file or file.filename == "":
return make_response(
jsonify({"status": "error", "message": "Missing file(s)"}),
jsonify({"status": "error", "message": "Missing file"}),
400,
)
user = None
if decoded_token:
user = safe_filename(decoded_token.get("sub"))
@@ -65,74 +59,32 @@ class StoreAttachment(Resource):
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
try:
tasks = []
errors = []
original_file_count = len(files)
for idx, file in enumerate(files):
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}"
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,
}
metadata = storage.save_file(file, relative_path)
task = store_attachment.delay(file_info, user)
tasks.append({
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,
"filename": original_filename,
"attachment_id": str(attachment_id),
})
except Exception as file_err:
current_app.logger.error(f"Error processing file {idx} ({file.filename}): {file_err}", exc_info=True)
errors.append({
"filename": file.filename,
"error": str(file_err)
})
if not tasks:
error_msg = "No valid files to upload"
if errors:
error_msg += f". Errors: {errors}"
return make_response(
jsonify({"status": "error", "message": error_msg, "errors": errors}),
400,
)
if original_file_count == 1 and len(tasks) == 1:
current_app.logger.info("Returning single task_id response")
return make_response(
jsonify(
{
"success": True,
"task_id": tasks[0]["task_id"],
"message": "File uploaded successfully. Processing started.",
}
),
200,
)
else:
response_data = {
"success": True,
"tasks": tasks,
"message": f"{len(tasks)} file(s) uploaded successfully. Processing started.",
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" {len(errors)} file(s) failed."
return make_response(
jsonify(response_data),
200,
)
"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)
@@ -181,7 +133,7 @@ class TextToSpeech(Resource):
data = request.get_json()
text = data["text"]
try:
tts_instance = TTSCreator.create_tts(settings.TTS_PROVIDER)
tts_instance = GoogleTTS()
audio_base64, detected_language = tts_instance.text_to_speech(text)
return make_response(
jsonify(

View File

@@ -13,6 +13,7 @@ from application.api.user.base import (
agents_collection,
attachments_collection,
conversations_collection,
db,
shared_conversations_collections,
)
from application.utils import check_required_fields
@@ -96,7 +97,9 @@ class ShareConversation(Resource):
api_uuid = pre_existing_api_document["key"]
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": ObjectId(conversation_id),
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -117,7 +120,10 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": ObjectId(conversation_id),
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -148,7 +154,10 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": ObjectId(conversation_id),
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -166,7 +175,9 @@ class ShareConversation(Resource):
)
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": ObjectId(conversation_id),
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -186,7 +197,10 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": ObjectId(conversation_id),
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -219,12 +233,10 @@ class GetPubliclySharedConversations(Resource):
if (
shared
and "conversation_id" in shared
and isinstance(shared["conversation_id"], DBRef)
):
# conversation_id is now stored as an ObjectId, not a DBRef
conversation_id = shared["conversation_id"]
conversation = conversations_collection.find_one(
{"_id": conversation_id}
)
conversation_ref = shared["conversation_id"]
conversation = db.dereference(conversation_ref)
if conversation is None:
return make_response(
jsonify(

View File

@@ -56,10 +56,9 @@ class GetTools(Resource):
tools = user_tools_collection.find({"user": user})
user_tools = []
for tool in tools:
tool_copy = {**tool}
tool_copy["id"] = str(tool["_id"])
tool_copy.pop("_id", None)
user_tools.append(tool_copy)
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)

View File

@@ -23,18 +23,10 @@ class Settings(BaseSettings):
LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150
LLM_TOKEN_LIMITS: dict = {
"gpt-4o": 128000,
"gpt-4o-mini": 128000,
"gpt-4": 8192,
"gpt-3.5-turbo": 4096,
"claude-2": int(1e5),
"gemini-2.5-flash": int(1e6),
}
DEFAULT_LLM_TOKEN_LIMIT: int = 128000
RESERVED_TOKENS: dict = {
"system_prompt": 500,
"current_query": 500,
"safety_buffer": 1000,
"claude-2": 1e5,
"gemini-2.5-flash": 1e6,
}
DEFAULT_AGENT_LIMITS: dict = {
"token_limit": 50000,
@@ -63,6 +55,11 @@ class Settings(BaseSettings):
"http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp)
)
# Microsoft Entra ID (Azure AD) integration
MICROSOFT_CLIENT_ID: Optional[str] = None # Azure AD Application (client) ID
MICROSOFT_CLIENT_SECRET: Optional[str] = None # Azure AD Application client secret
MICROSOFT_TENANT_ID: Optional[str] = "common" # Azure AD Tenant ID (or 'common' for multi-tenant)
MICROSOFT_AUTHORITY: Optional[str] = None # e.g., "https://login.microsoftonline.com/{tenant_id}"
# GitHub source
GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access
@@ -138,11 +135,7 @@ class Settings(BaseSettings):
# Encryption settings
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
TTS_PROVIDER: str = "google_tts" # google_tts or elevenlabs
ELEVENLABS_API_KEY: Optional[str] = None
# Tool pre-fetch settings
ENABLE_TOOL_PREFETCH: bool = True
path = Path(__file__).parent.parent.absolute()
settings = Settings(_env_file=path.joinpath(".env"), _env_file_encoding="utf-8")

View File

@@ -44,12 +44,6 @@ class BaseLLM(ABC):
)
return self._fallback_llm
@staticmethod
def _remove_null_values(args_dict):
if not isinstance(args_dict, dict):
return args_dict
return {k: v for k, v in args_dict.items() if v is not None}
def _execute_with_fallback(
self, method_name: str, decorators: list, *args, **kwargs
):

View File

@@ -33,15 +33,14 @@ class DocsGPTAPILLM(BaseLLM):
{"role": role, "content": item["text"]}
)
elif "function_call" in item:
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
tool_call = {
"id": item["function_call"]["call_id"],
"type": "function",
"function": {
"name": item["function_call"]["name"],
"arguments": json.dumps(cleaned_args),
"arguments": json.dumps(
item["function_call"]["args"]
),
},
}
cleaned_messages.append(

View File

@@ -163,14 +163,10 @@ class GoogleLLM(BaseLLM):
if "text" in item:
parts.append(types.Part.from_text(text=item["text"]))
elif "function_call" in item:
# Remove null values from args to avoid API errors
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
parts.append(
types.Part.from_function_call(
name=item["function_call"]["name"],
args=cleaned_args,
args=item["function_call"]["args"],
)
)
elif "function_response" in item:
@@ -390,7 +386,7 @@ class GoogleLLM(BaseLLM):
elif hasattr(chunk, "text"):
yield chunk.text
finally:
if hasattr(response, "close"):
if hasattr(response, 'close'):
response.close()
def _supports_tools(self):

View File

@@ -44,15 +44,14 @@ class OpenAILLM(BaseLLM):
{"role": role, "content": item["text"]}
)
elif "function_call" in item:
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
tool_call = {
"id": item["function_call"]["call_id"],
"type": "function",
"function": {
"name": item["function_call"]["name"],
"arguments": json.dumps(cleaned_args),
"arguments": json.dumps(
item["function_call"]["args"]
),
},
}
cleaned_messages.append(
@@ -182,7 +181,7 @@ class OpenAILLM(BaseLLM):
elif len(line.choices) > 0:
yield line.choices[0]
finally:
if hasattr(response, "close"):
if hasattr(response, 'close'):
response.close()
def _supports_tools(self):

View File

@@ -1,5 +1,7 @@
from application.parser.connectors.google_drive.loader import GoogleDriveLoader
from application.parser.connectors.google_drive.auth import GoogleDriveAuth
from application.parser.connectors.share_point.auth import SharePointAuth
from application.parser.connectors.share_point.loader import SharePointLoader
class ConnectorCreator:
@@ -12,10 +14,12 @@ class ConnectorCreator:
connectors = {
"google_drive": GoogleDriveLoader,
"share_point": SharePointLoader,
}
auth_providers = {
"google_drive": GoogleDriveAuth,
"share_point": SharePointAuth,
}
@classmethod

View File

@@ -0,0 +1,10 @@
"""
Share Point connector package for DocsGPT.
This module provides authentication and document loading capabilities for Share Point.
"""
from .auth import SharePointAuth
from .loader import SharePointLoader
__all__ = ['SharePointAuth', 'SharePointLoader']

View File

@@ -0,0 +1,91 @@
import logging
import datetime
from typing import Optional, Dict, Any
from msal import ConfidentialClientApplication
from application.core.settings import settings
from application.parser.connectors.base import BaseConnectorAuth
class SharePointAuth(BaseConnectorAuth):
"""
Handles Microsoft OAuth 2.0 authentication.
# Documentation:
- https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow
- https://learn.microsoft.com/en-gb/entra/msal/python/
"""
# Microsoft Graph scopes for SharePoint access
SCOPES = [
"User.Read",
]
def __init__(self):
self.client_id = settings.MICROSOFT_CLIENT_ID
self.client_secret = settings.MICROSOFT_CLIENT_SECRET
if not self.client_id or not self.client_secret:
raise ValueError(
"Microsoft OAuth credentials not configured. Please set MICROSOFT_CLIENT_ID and MICROSOFT_CLIENT_SECRET in settings."
)
self.redirect_uri = settings.CONNECTOR_REDIRECT_BASE_URI
self.tenant_id = settings.MICROSOFT_TENANT_ID
self.authority = getattr(settings, "MICROSOFT_AUTHORITY", f"https://{self.tenant_id}.ciamlogin.com/{self.tenant_id}")
self.auth_app = ConfidentialClientApplication(
client_id=self.client_id, client_credential=self.client_secret, authority=self.authority
)
def get_authorization_url(self, state: Optional[str] = None) -> str:
return self.auth_app.get_authorization_request_url(
scopes=self.SCOPES, state=state, redirect_uri=self.redirect_uri
)
def exchange_code_for_tokens(self, authorization_code: str) -> Dict[str, Any]:
result = self.auth_app.acquire_token_by_authorization_code(
code=authorization_code, scopes=self.SCOPES, redirect_uri=self.redirect_uri
)
if "error" in result:
logging.error(f"Error acquiring token: {result.get('error_description')}")
raise ValueError(f"Error acquiring token: {result.get('error_description')}")
return self.map_token_response(result)
def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
result = self.auth_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=self.SCOPES)
if "error" in result:
logging.error(f"Error acquiring token: {result.get('error_description')}")
raise ValueError(f"Error acquiring token: {result.get('error_description')}")
return self.map_token_response(result)
def is_token_expired(self, token_info: Dict[str, Any]) -> bool:
if not token_info or "expiry" not in token_info:
# If no expiry info, consider token expired to be safe
return True
# Get expiry timestamp and current time
expiry_timestamp = token_info["expiry"]
current_timestamp = int(datetime.datetime.now().timestamp())
# Token is expired if current time is greater than or equal to expiry time
return current_timestamp >= expiry_timestamp
def map_token_response(self, result) -> Dict[str, Any]:
return {
"access_token": result.get("access_token"),
"refresh_token": result.get("refresh_token"),
"token_uri": result.get("id_token_claims", {}).get("iss"),
"scopes": result.get("scope"),
"expiry": result.get("id_token_claims", {}).get("exp"),
"user_info": {
"name": result.get("id_token_claims", {}).get("name"),
"email": result.get("id_token_claims", {}).get("preferred_username"),
},
"raw_token": result,
}

View File

@@ -0,0 +1,44 @@
from typing import List, Dict, Any
from application.parser.connectors.base import BaseConnectorLoader
from application.parser.schema.base import Document
class SharePointLoader(BaseConnectorLoader):
def __init__(self, session_token: str):
pass
def load_data(self, inputs: Dict[str, Any]) -> List[Document]:
"""
Load documents from the external knowledge base.
Args:
inputs: Configuration dictionary containing:
- file_ids: Optional list of specific file IDs to load
- folder_ids: Optional list of folder IDs to browse/download
- limit: Maximum number of items to return
- list_only: If True, return metadata without content
- recursive: Whether to recursively process folders
Returns:
List of Document objects
"""
pass
def download_to_directory(self, local_dir: str, source_config: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Download files/folders to a local directory.
Args:
local_dir: Local directory path to download files to
source_config: Configuration for what to download
Returns:
Dictionary containing download results:
- files_downloaded: Number of files downloaded
- directory_path: Path where files were downloaded
- empty_result: Whether no files were downloaded
- source_type: Type of connector
- config_used: Configuration that was used
- error: Error message if download failed (optional)
"""
pass

View File

@@ -1,6 +1,5 @@
import os
import logging
from typing import List, Any
from retry import retry
from tqdm import tqdm
from application.core.settings import settings
@@ -23,16 +22,13 @@ def sanitize_content(content: str) -> str:
@retry(tries=10, delay=60)
def add_text_to_store_with_retry(store: Any, doc: Any, source_id: str) -> None:
"""Add a document's text and metadata to the vector store with retry logic.
def add_text_to_store_with_retry(store, doc, source_id):
"""
Add a document's text and metadata to the vector store with retry logic.
Args:
store: The vector store object.
doc: The document to be added.
source_id: Unique identifier for the source.
Raises:
Exception: If document addition fails after all retry attempts.
"""
try:
# Sanitize content to remove NUL characters that cause ingestion failures
@@ -45,21 +41,18 @@ def add_text_to_store_with_retry(store: Any, doc: Any, source_id: str) -> None:
raise
def embed_and_store_documents(docs: List[Any], folder_name: str, source_id: str, task_status: Any) -> None:
"""Embeds documents and stores them in a vector store.
def embed_and_store_documents(docs, folder_name, source_id, task_status):
"""
Embeds documents and stores them in a vector store.
Args:
docs: List of documents to be embedded and stored.
folder_name: Directory to save the vector store.
source_id: Unique identifier for the source.
docs (list): List of documents to be embedded and stored.
folder_name (str): Directory to save the vector store.
source_id (str): Unique identifier for the source.
task_status: Task state manager for progress updates.
Returns:
None
Raises:
OSError: If unable to create folder or save vector store.
Exception: If vector store creation or document embedding fails.
"""
# Ensure the folder exists
if not os.path.exists(folder_name):
@@ -102,21 +95,10 @@ def embed_and_store_documents(docs: List[Any], folder_name: str, source_id: str,
except Exception as e:
logging.error(f"Error embedding document {idx}: {e}", exc_info=True)
logging.info(f"Saving progress at document {idx} out of {total_docs}")
try:
store.save_local(folder_name)
logging.info("Progress saved successfully")
except Exception as save_error:
logging.error(f"CRITICAL: Failed to save progress: {save_error}", exc_info=True)
# Continue without breaking to attempt final save
store.save_local(folder_name)
break
# Save the vector store
if settings.VECTOR_STORE == "faiss":
try:
store.save_local(folder_name)
logging.info("Vector store saved successfully.")
except Exception as e:
logging.error(f"CRITICAL: Failed to save final vector store: {e}", exc_info=True)
raise OSError(f"Unable to save vector store to {folder_name}: {e}") from e
else:
logging.info("Vector store saved successfully.")
store.save_local(folder_name)
logging.info("Vector store saved successfully.")

View File

@@ -10,7 +10,6 @@ ebooklib==0.18
escodegen==1.0.11
esprima==4.0.1
esutils==1.0.1
elevenlabs==2.17.0
Flask==3.1.1
faiss-cpu==1.9.0.post1
fastmcp==2.11.0
@@ -41,6 +40,7 @@ markupsafe==3.0.2
marshmallow==3.26.1
mpmath==1.3.0
multidict==6.4.3
msal==1.34.0
mypy-extensions==1.0.0
networkx==3.4.2
numpy==2.2.1
@@ -88,4 +88,4 @@ werkzeug>=3.1.0,<3.1.2
yarl==1.20.0
markdownify==1.1.0
tldextract==5.1.3
websockets==14.1
websockets==14.1

View File

@@ -8,3 +8,7 @@ class BaseRetriever(ABC):
@abstractmethod
def search(self, *args, **kwargs):
pass
@abstractmethod
def get_params(self):
pass

View File

@@ -4,7 +4,7 @@ import os
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
from application.utils import num_tokens_from_string
from application.vectorstore.vector_creator import VectorCreator
@@ -15,13 +15,14 @@ class ClassicRAG(BaseRetriever):
chat_history=None,
prompt="",
chunks=2,
doc_token_limit=50000,
token_limit=150,
gpt_model="docsgpt",
user_api_key=None,
llm_name=settings.LLM_PROVIDER,
api_key=settings.API_KEY,
decoded_token=None,
):
"""Initialize ClassicRAG retriever with vectorstore sources and LLM configuration"""
self.original_question = source.get("question", "")
self.chat_history = chat_history if chat_history is not None else []
self.prompt = prompt
@@ -41,7 +42,16 @@ class ClassicRAG(BaseRetriever):
f"sources={'active_docs' in source and source['active_docs'] is not None}"
)
self.gpt_model = gpt_model
self.doc_token_limit = doc_token_limit
self.token_limit = (
token_limit
if token_limit
< settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
else settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
)
self.user_api_key = user_api_key
self.llm_name = llm_name
self.api_key = api_key
@@ -108,17 +118,21 @@ class ClassicRAG(BaseRetriever):
return self.original_question
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))
token_budget = max(int(self.doc_token_limit * 0.9), 100)
cumulative_tokens = 0
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:
@@ -126,21 +140,15 @@ class ClassicRAG(BaseRetriever):
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, vectorstore_id, settings.EMBEDDINGS_KEY
)
docs_temp = docsearch.search(
self.question, k=max(chunks_per_source * 2, 20)
)
docs_temp = docsearch.search(self.question, k=chunks_per_source)
for doc in docs_temp:
if cumulative_tokens >= token_budget:
break
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
title = metadata.get(
"title", metadata.get("post_title", page_content)
)
@@ -160,35 +168,23 @@ class ClassicRAG(BaseRetriever):
if not filename:
filename = title
source_path = metadata.get("source") or vectorstore_id
doc_text_with_header = f"{filename}\n{page_content}"
doc_tokens = num_tokens_from_string(doc_text_with_header)
if cumulative_tokens + doc_tokens < token_budget:
all_docs.append(
{
"title": title,
"text": page_content,
"source": source_path,
"filename": filename,
}
)
cumulative_tokens += doc_tokens
if cumulative_tokens >= token_budget:
break
all_docs.append(
{
"title": title,
"text": page_content,
"source": source_path,
"filename": filename,
}
)
except Exception as e:
logging.error(
f"Error searching vectorstore {vectorstore_id}: {e}",
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}, "
f"cumulative_tokens={cumulative_tokens}/{token_budget})"
f"(requested chunks={self.chunks}, chunks_per_source={chunks_per_source})"
)
return all_docs
@@ -198,3 +194,15 @@ class ClassicRAG(BaseRetriever):
self.original_question = query
self.question = self._rephrase_query()
return self._get_data()
def get_params(self):
"""Return current retriever configuration parameters"""
return {
"question": self.original_question,
"rephrased_question": self.question,
"sources": self.vectorstores,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key,
}

View File

@@ -1,190 +0,0 @@
import logging
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class NamespaceBuilder(ABC):
"""Base class for building template context namespaces"""
@abstractmethod
def build(self, **kwargs) -> Dict[str, Any]:
"""Build namespace context dictionary"""
pass
@property
@abstractmethod
def namespace_name(self) -> str:
"""Name of this namespace for template access"""
pass
class SystemNamespace(NamespaceBuilder):
"""System metadata namespace: {{ system.* }}"""
@property
def namespace_name(self) -> str:
return "system"
def build(
self, request_id: Optional[str] = None, user_id: Optional[str] = None, **kwargs
) -> Dict[str, Any]:
"""
Build system context with metadata.
Args:
request_id: Unique request identifier
user_id: Current user identifier
Returns:
Dictionary with system variables
"""
now = datetime.now(timezone.utc)
return {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"timestamp": now.isoformat(),
"request_id": request_id or str(uuid.uuid4()),
"user_id": user_id,
}
class PassthroughNamespace(NamespaceBuilder):
"""Request parameters namespace: {{ passthrough.* }}"""
@property
def namespace_name(self) -> str:
return "passthrough"
def build(
self, passthrough_data: Optional[Dict[str, Any]] = None, **kwargs
) -> Dict[str, Any]:
"""
Build passthrough context from request parameters.
Args:
passthrough_data: Dictionary of parameters from web request
Returns:
Dictionary with passthrough variables
"""
if not passthrough_data:
return {}
safe_data = {}
for key, value in passthrough_data.items():
if isinstance(value, (str, int, float, bool, type(None))):
safe_data[key] = value
else:
logger.warning(
f"Skipping non-serializable passthrough value for key '{key}': {type(value)}"
)
return safe_data
class SourceNamespace(NamespaceBuilder):
"""RAG source documents namespace: {{ source.* }}"""
@property
def namespace_name(self) -> str:
return "source"
def build(
self, docs: Optional[list] = None, docs_together: Optional[str] = None, **kwargs
) -> Dict[str, Any]:
"""
Build source context from RAG retrieval results.
Args:
docs: List of retrieved documents
docs_together: Concatenated document content (for backward compatibility)
Returns:
Dictionary with source variables
"""
context = {}
if docs:
context["documents"] = docs
context["count"] = len(docs)
if docs_together:
context["docs_together"] = docs_together # Add docs_together for custom templates
context["content"] = docs_together
context["summaries"] = docs_together
return context
class ToolsNamespace(NamespaceBuilder):
"""Pre-executed tools namespace: {{ tools.* }}"""
@property
def namespace_name(self) -> str:
return "tools"
def build(
self, tools_data: Optional[Dict[str, Any]] = None, **kwargs
) -> Dict[str, Any]:
"""
Build tools context with pre-executed tool results.
Args:
tools_data: Dictionary of pre-fetched tool results organized by tool name
e.g., {"memory": {"notes": "content", "tasks": "list"}}
Returns:
Dictionary with tool results organized by tool name
"""
if not tools_data:
return {}
safe_data = {}
for tool_name, tool_result in tools_data.items():
if isinstance(tool_result, (str, dict, list, int, float, bool, type(None))):
safe_data[tool_name] = tool_result
else:
logger.warning(
f"Skipping non-serializable tool result for '{tool_name}': {type(tool_result)}"
)
return safe_data
class NamespaceManager:
"""Manages all namespace builders and context assembly"""
def __init__(self):
self._builders = {
"system": SystemNamespace(),
"passthrough": PassthroughNamespace(),
"source": SourceNamespace(),
"tools": ToolsNamespace(),
}
def build_context(self, **kwargs) -> Dict[str, Any]:
"""
Build complete template context from all namespaces.
Args:
**kwargs: Parameters to pass to namespace builders
Returns:
Complete context dictionary for template rendering
"""
context = {}
for namespace_name, builder in self._builders.items():
try:
namespace_context = builder.build(**kwargs)
# Always include namespace, even if empty, to prevent undefined errors
context[namespace_name] = namespace_context if namespace_context else {}
except Exception as e:
logger.error(f"Failed to build {namespace_name} namespace: {str(e)}")
# Include empty namespace on error to prevent template failures
context[namespace_name] = {}
return context
def get_builder(self, namespace_name: str) -> Optional[NamespaceBuilder]:
"""Get specific namespace builder"""
return self._builders.get(namespace_name)

View File

@@ -1,161 +0,0 @@
import logging
from typing import Any, Dict, List, Optional, Set
from jinja2 import (
ChainableUndefined,
Environment,
nodes,
select_autoescape,
TemplateSyntaxError,
)
from jinja2.exceptions import UndefinedError
logger = logging.getLogger(__name__)
class TemplateRenderError(Exception):
"""Raised when template rendering fails"""
pass
class TemplateEngine:
"""Jinja2-based template engine for dynamic prompt rendering"""
def __init__(self):
self._env = Environment(
undefined=ChainableUndefined,
trim_blocks=True,
lstrip_blocks=True,
autoescape=select_autoescape(default_for_string=True, default=True),
)
def render(self, template_content: str, context: Dict[str, Any]) -> str:
"""
Render template with provided context.
Args:
template_content: Raw template string with Jinja2 syntax
context: Dictionary of variables to inject into template
Returns:
Rendered template string
Raises:
TemplateRenderError: If template syntax is invalid or variables undefined
"""
if not template_content:
return ""
try:
template = self._env.from_string(template_content)
return template.render(**context)
except TemplateSyntaxError as e:
error_msg = f"Template syntax error at line {e.lineno}: {e.message}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
except UndefinedError as e:
error_msg = f"Undefined variable in template: {e.message}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
except Exception as e:
error_msg = f"Template rendering failed: {str(e)}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
def validate_template(self, template_content: str) -> bool:
"""
Validate template syntax without rendering.
Args:
template_content: Template string to validate
Returns:
True if template is syntactically valid
"""
if not template_content:
return True
try:
self._env.from_string(template_content)
return True
except TemplateSyntaxError as e:
logger.debug(f"Template syntax invalid at line {e.lineno}: {e.message}")
return False
except Exception as e:
logger.debug(f"Template validation error: {type(e).__name__}: {str(e)}")
return False
def extract_variables(self, template_content: str) -> Set[str]:
"""
Extract all variable names from template.
Args:
template_content: Template string to analyze
Returns:
Set of variable names found in template
"""
if not template_content:
return set()
try:
ast = self._env.parse(template_content)
return set(self._env.get_template_module(ast).make_module().keys())
except TemplateSyntaxError as e:
logger.debug(f"Cannot extract variables - syntax error at line {e.lineno}")
return set()
except Exception as e:
logger.debug(f"Cannot extract variables: {type(e).__name__}")
return set()
def extract_tool_usages(
self, template_content: str
) -> Dict[str, Set[Optional[str]]]:
"""Extract tool and action references from a template"""
if not template_content:
return {}
try:
ast = self._env.parse(template_content)
except TemplateSyntaxError as e:
logger.debug(f"extract_tool_usages - syntax error at line {e.lineno}")
return {}
except Exception as e:
logger.debug(f"extract_tool_usages - parse error: {type(e).__name__}")
return {}
usages: Dict[str, Set[Optional[str]]] = {}
def record(path: List[str]) -> None:
if not path:
return
tool_name = path[0]
action_name = path[1] if len(path) > 1 else None
if not tool_name:
return
tool_entry = usages.setdefault(tool_name, set())
tool_entry.add(action_name)
for node in ast.find_all(nodes.Getattr):
path = []
current = node
while isinstance(current, nodes.Getattr):
path.append(current.attr)
current = current.node
if isinstance(current, nodes.Name) and current.name == "tools":
path.reverse()
record(path)
for node in ast.find_all(nodes.Getitem):
path = []
current = node
while isinstance(current, nodes.Getitem):
key = current.arg
if isinstance(key, nodes.Const) and isinstance(key.value, str):
path.append(key.value)
else:
path = []
break
current = current.node
if path and isinstance(current, nodes.Name) and current.name == "tools":
path.reverse()
record(path)
return usages

View File

@@ -15,11 +15,10 @@ class ElevenlabsTTS(BaseTTS):
def text_to_speech(self, text):
lang = "en"
audio = self.client.text_to_speech.convert(
voice_id="nPczCjzI2devNBz1zQrb",
model_id="eleven_multilingual_v2",
audio = self.client.generate(
text=text,
output_format="mp3_44100_128"
model="eleven_multilingual_v2",
voice="Brian",
)
audio_data = BytesIO()
for chunk in audio:

View File

@@ -1,18 +0,0 @@
from application.tts.google_tts import GoogleTTS
from application.tts.elevenlabs import ElevenlabsTTS
from application.tts.base import BaseTTS
class TTSCreator:
tts_providers = {
"google_tts": GoogleTTS,
"elevenlabs": ElevenlabsTTS,
}
@classmethod
def create_tts(cls, tts_type, *args, **kwargs)-> BaseTTS:
tts_class = cls.tts_providers.get(tts_type.lower())
if not tts_class:
raise ValueError(f"No tts class found for type {tts_type}")
return tts_class(*args, **kwargs)

View File

@@ -21,7 +21,7 @@ def get_encoding():
def get_gpt_model() -> str:
"""Get GPT model based on provider"""
"""Get the appropriate GPT model based on provider"""
model_map = {
"openai": "gpt-4o-mini",
"anthropic": "claude-2",
@@ -32,7 +32,16 @@ def get_gpt_model() -> str:
def safe_filename(filename):
"""Create safe filename, preserving extension. Handles non-Latin characters."""
"""
Creates a safe filename that preserves the original extension.
Uses secure_filename, but ensures a proper filename is returned even with non-Latin characters.
Args:
filename (str): The original filename
Returns:
str: A safe filename that can be used for storage
"""
if not filename:
return str(uuid.uuid4())
_, extension = os.path.splitext(filename)
@@ -74,25 +83,8 @@ def count_tokens_docs(docs):
return tokens
def calculate_doc_token_budget(
gpt_model: str = "gpt-4o", history_token_limit: int = 2000
) -> int:
total_context = settings.LLM_TOKEN_LIMITS.get(
gpt_model, settings.DEFAULT_LLM_TOKEN_LIMIT
)
reserved = sum(settings.RESERVED_TOKENS.values())
doc_budget = total_context - history_token_limit - reserved
return max(doc_budget, 1000)
def get_missing_fields(data, required_fields):
"""Check for missing required fields. Returns list of missing field names."""
return [field for field in required_fields if field not in data]
def check_required_fields(data, required_fields):
"""Validate required fields. Returns Flask 400 response if validation fails, None otherwise."""
missing_fields = get_missing_fields(data, required_fields)
missing_fields = [field for field in required_fields if field not in data]
if missing_fields:
return make_response(
jsonify(
@@ -106,8 +98,7 @@ def check_required_fields(data, required_fields):
return None
def get_field_validation_errors(data, required_fields):
"""Check for missing and empty fields. Returns dict with 'missing_fields' and 'empty_fields', or None."""
def validate_required_fields(data, required_fields):
missing_fields = []
empty_fields = []
@@ -116,24 +107,12 @@ def get_field_validation_errors(data, required_fields):
missing_fields.append(field)
elif not data[field]:
empty_fields.append(field)
if missing_fields or empty_fields:
return {"missing_fields": missing_fields, "empty_fields": empty_fields}
return None
def validate_required_fields(data, required_fields):
"""Validate required fields (must exist and be non-empty). Returns Flask 400 response if validation fails, None otherwise."""
errors_dict = get_field_validation_errors(data, required_fields)
if errors_dict:
errors = []
if errors_dict["missing_fields"]:
errors.append(
f"Missing required fields: {', '.join(errors_dict['missing_fields'])}"
)
if errors_dict["empty_fields"]:
errors.append(
f"Empty values in required fields: {', '.join(errors_dict['empty_fields'])}"
)
errors = []
if missing_fields:
errors.append(f"Missing required fields: {', '.join(missing_fields)}")
if empty_fields:
errors.append(f"Empty values in required fields: {', '.join(empty_fields)}")
if errors:
return make_response(
jsonify({"success": False, "message": " | ".join(errors)}), 400
)
@@ -145,15 +124,18 @@ def get_hash(data):
def limit_chat_history(history, max_token_limit=None, gpt_model="docsgpt"):
"""Limit chat history to fit within token limit."""
"""
Limits chat history based on token count.
Returns a list of messages that fit within the token limit.
"""
from application.core.settings import settings
max_token_limit = (
max_token_limit
if max_token_limit
and max_token_limit
< settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_LLM_TOKEN_LIMIT)
else settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_LLM_TOKEN_LIMIT)
< settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
else settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
)
if not history:
@@ -179,7 +161,7 @@ def limit_chat_history(history, max_token_limit=None, gpt_model="docsgpt"):
def validate_function_name(function_name):
"""Validate function name matches allowed pattern (alphanumeric, underscore, hyphen)."""
"""Validates if a function name matches the allowed pattern."""
if not re.match(r"^[a-zA-Z0-9_-]+$", function_name):
return False
return True
@@ -198,44 +180,3 @@ def generate_image_url(image_path):
else:
base_url = getattr(settings, "API_URL", "http://localhost:7091")
return f"{base_url}/api/images/{image_path}"
def clean_text_for_tts(text: str) -> str:
"""
clean text for Text-to-Speech processing.
"""
# Handle code blocks and links
text = re.sub(r'```mermaid[\s\S]*?```', ' flowchart, ', text) ## ```mermaid...```
text = re.sub(r'```[\s\S]*?```', ' code block, ', text) ## ```code```
text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text) ## [text](url)
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', '', text) ## ![alt](url)
# Remove markdown formatting
text = re.sub(r'`([^`]+)`', r'\1', text) ## `code`
text = re.sub(r'\{([^}]*)\}', r' \1 ', text) ## {text}
text = re.sub(r'[{}]', ' ', text) ## unmatched {}
text = re.sub(r'\[([^\]]+)\]', r' \1 ', text) ## [text]
text = re.sub(r'[\[\]]', ' ', text) ## unmatched []
text = re.sub(r'(\*\*|__)(.*?)\1', r'\2', text) ## **bold** __bold__
text = re.sub(r'(\*|_)(.*?)\1', r'\2', text) ## *italic* _italic_
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) ## # headers
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE) ## > blockquotes
text = re.sub(r'^[\s]*[-\*\+]\s+', '', text, flags=re.MULTILINE) ## - * + lists
text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE) ## 1. numbered lists
text = re.sub(r'^[\*\-_]{3,}\s*$', '', text, flags=re.MULTILINE) ## --- *** ___ rules
text = re.sub(r'<[^>]*>', '', text) ## <html> tags
#Remove non-ASCII (emojis, special Unicode)
text = re.sub(r'[^\x20-\x7E\n\r\t]', '', text)
#Replace special sequences
text = re.sub(r'-->', ', ', text) ## -->
text = re.sub(r'<--', ', ', text) ## <--
text = re.sub(r'=>', ', ', text) ## =>
text = re.sub(r'::', ' ', text) ## ::
#Normalize whitespace
text = re.sub(r'\s+', ' ', text)
text = text.strip()
return text

View File

@@ -1,453 +1,49 @@
---
title: Customizing Prompts
description: This guide explains how to customize prompts in DocsGPT using the new template-based system with dynamic variable injection.
title: Customizing Prompts
description: This guide will explain how to change prompts in DocsGPT and why it might be benefitial. Additionaly this article expains additional variables that can be used in prompts.
---
import Image from 'next/image'
# Customizing Prompts in DocsGPT
# Customizing the Main Prompt
Customizing prompts for DocsGPT gives you powerful control over the AI's behavior and responses. With the new template-based system, you can inject dynamic context through organized namespaces, making prompts flexible and maintainable without hardcoding values.
## Quick Start
Customizing the main prompt for DocsGPT gives you the ability to tailor the AI's responses to your specific requirements. By modifying the prompt text, you can achieve more accurate and relevant answers. Here's how you can do it:
1. Navigate to `SideBar -> Settings`.
2. In Settings, select the `Active Prompt` to see various prompt styles.
3. Click on the `edit icon` on your chosen prompt to customize it.
2.In Settings select the `Active Prompt` now you will be able to see various prompts style.x
3.Click on the `edit icon` on the prompt of your choice and you will be able to see the current prompt for it,you can now customise the prompt as per your choice.
### Video Demo
<Image src="/prompts.gif" alt="prompts" width={800} height={500} />
---
## Template-Based Prompt System
DocsGPT now uses **Jinja2 templating** with four organized namespaces for dynamic variable injection:
### Available Namespaces
#### 1. **`system`** - System Metadata
Access system-level information:
```jinja
{{ system.date }} # Current date (YYYY-MM-DD)
{{ system.time }} # Current time (HH:MM:SS)
{{ system.timestamp }} # ISO 8601 timestamp
{{ system.request_id }} # Unique request identifier
{{ system.user_id }} # Current user ID
```
#### 2. **`source`** - Retrieved Documents
Access RAG (Retrieval-Augmented Generation) document context:
```jinja
{{ source.content }} # Concatenated document content
{{ source.summaries }} # Alias for content (backward compatible)
{{ source.documents }} # List of document objects
{{ source.count }} # Number of retrieved documents
```
#### 3. **`passthrough`** - Request Parameters
Access custom parameters passed in the API request:
```jinja
{{ passthrough.company }} # Custom field from request
{{ passthrough.user_name }} # User-provided data
{{ passthrough.context }} # Any custom parameter
```
To use passthrough data, send it in your API request:
```json
{
"question": "What is the pricing?",
"passthrough": {
"company": "Acme Corp",
"user_name": "Alice",
"plan_type": "enterprise"
}
}
```
#### 4. **`tools`** - Pre-fetched Tool Data
Access results from tools that run before the agent (like memory tool):
```jinja
{{ tools.memory.root }} # Memory tool directory listing
{{ tools.memory.available }} # Boolean: is memory available
```
---
## Example Prompts
### Basic Prompt with Documents
```jinja
You are a helpful AI assistant for DocsGPT.
Current date: {{ system.date }}
Use the following documents to answer the question:
{{ source.content }}
Provide accurate, helpful answers with code examples when relevant.
```
### Advanced Prompt with All Namespaces
```jinja
You are an AI assistant for {{ passthrough.company }}.
**System Info:**
- Date: {{ system.date }}
- Request ID: {{ system.request_id }}
**User Context:**
- User: {{ passthrough.user_name }}
- Role: {{ passthrough.role }}
**Available Documents ({{ source.count }}):**
{{ source.content }}
**Memory Context:**
{% if tools.memory.available %}
{{ tools.memory.root }}
{% else %}
No saved context available.
{% endif %}
Please provide detailed, accurate answers based on the documents above.
```
### Conditional Logic Example
```jinja
You are a DocsGPT assistant.
{% if source.count > 0 %}
I found {{ source.count }} relevant document(s):
{{ source.content }}
Base your answer on these documents.
{% else %}
No documents were found. Please answer based on your general knowledge.
{% endif %}
```
---
## Migration Guide
### Legacy Format (Still Supported)
The old `{summaries}` format continues to work for backward compatibility:
## Example Prompt Modification
**Original Prompt:**
```markdown
You are a helpful assistant.
You are a DocsGPT, friendly and helpful AI assistant by Arc53 that provides help with documents. You give thorough answers with code examples if possible.
Use the following pieces of context to help answer the users question. If it's not relevant to the question, provide friendly responses.
You have access to chat history, and can use it to help answer the question.
When using code examples, use the following format:
Documents:
(code)
{summaries}
```
This will automatically substitute `{summaries}` with document content.
Note that `{summaries}` allows model to see and respond to your upploaded documents. If you don't want this functionality you can safely remove it from the customized prompt.
### New Template Format (Recommended)
Migrate to the new template syntax for more flexibility:
```jinja
You are a helpful assistant.
Documents:
{{ source.content }}
```
**Migration mapping:**
- `{summaries}` → `{{ source.content }}` or `{{ source.summaries }}`
---
## Best Practices
### 1. **Use Descriptive Context**
```jinja
**Retrieved Documents:**
{{ source.content }}
**User Query Context:**
- Company: {{ passthrough.company }}
- Department: {{ passthrough.department }}
```
### 2. **Handle Missing Data Gracefully**
```jinja
{% if passthrough.user_name %}
Hello {{ passthrough.user_name }}!
{% endif %}
```
### 3. **Leverage Memory for Continuity**
```jinja
{% if tools.memory.available %}
**Previous Context:**
{{ tools.memory.root }}
{% endif %}
**Current Question:**
Please consider the above context when answering.
```
### 4. **Add Clear Instructions**
```jinja
You are a technical support assistant.
**Guidelines:**
1. Always reference the documents below
2. Provide step-by-step instructions
3. Include code examples when relevant
**Reference Documents:**
{{ source.content }}
```
---
## Advanced Features
### Looping Over Documents
```jinja
{% for doc in source.documents %}
**Source {{ loop.index }}:** {{ doc.filename }}
{{ doc.text }}
{% endfor %}
```
### Date-Based Behavior
```jinja
{% if system.date > "2025-01-01" %}
Note: This is information from 2025 or later.
{% endif %}
```
### Custom Formatting
```jinja
**Request Information**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• Request ID: {{ system.request_id }}
• User: {{ passthrough.user_name | default("Guest") }}
• Time: {{ system.time }}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## Tool Pre-Fetching
### Memory Tool Configuration
Enable memory tool pre-fetching to inject saved context into prompts:
```python
# In your tool configuration
{
"name": "memory",
"config": {
"pre_fetch_enabled": true # Default: true
}
}
```
Control pre-fetching globally:
```bash
# .env file
ENABLE_TOOL_PREFETCH=true
```
Or per-request:
```json
{
"question": "What are the requirements?",
"disable_tool_prefetch": false
}
```
---
## Debugging Prompts
### View Rendered Prompts in Logs
Set log level to `INFO` to see the final rendered prompt sent to the LLM:
```bash
export LOG_LEVEL=INFO
```
You'll see output like:
```
INFO - Rendered system prompt for agent (length: 1234 chars):
================================================================================
You are a helpful assistant for Acme Corp.
Current date: 2025-10-30
Request ID: req_abc123
Documents:
Technical documentation about...
================================================================================
```
### Template Validation
Test your template syntax before saving:
```python
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
is_valid = renderer.validate_template("Your prompt with {{ variables }}")
```
---
## Common Use Cases
### 1. Customer Support Bot
```jinja
You are a customer support assistant for {{ passthrough.company }}.
**Customer:** {{ passthrough.customer_name }}
**Ticket ID:** {{ system.request_id }}
**Date:** {{ system.date }}
**Knowledge Base:**
{{ source.content }}
**Previous Interactions:**
{{ tools.memory.root }}
Please provide helpful, friendly support based on the knowledge base above.
```
### 2. Technical Documentation Assistant
```jinja
You are a technical documentation expert.
**Available Documentation ({{ source.count }} documents):**
{{ source.content }}
**Requirements:**
- Provide code examples in {{ passthrough.language }}
- Focus on {{ passthrough.framework }} best practices
- Include relevant links when possible
```
### 3. Internal Knowledge Base
```jinja
You are an internal AI assistant for {{ passthrough.department }}.
**Employee:** {{ passthrough.employee_name }}
**Access Level:** {{ passthrough.access_level }}
**Relevant Documents:**
{{ source.content }}
Provide detailed answers appropriate for {{ passthrough.access_level }} access level.
```
---
## Template Syntax Reference
### Variables
```jinja
{{ variable_name }} # Output variable
{{ namespace.field }} # Access nested field
{{ variable | default("N/A") }} # Default value
```
### Conditionals
```jinja
{% if condition %}
Content
{% elif other_condition %}
Other content
{% else %}
Default content
{% endif %}
```
### Loops
```jinja
{% for item in list %}
{{ item.field }}
{% endfor %}
```
### Comments
```jinja
{# This is a comment and won't appear in output #}
```
---
## Security Considerations
1. **Input Sanitization**: Passthrough data is automatically sanitized to prevent injection attacks
2. **Type Filtering**: Only primitive types (string, int, float, bool, None) are allowed in passthrough
3. **Autoescaping**: Jinja2 autoescaping is enabled by default
4. **Size Limits**: Consider the token budget when including large documents
---
## Troubleshooting
### Problem: Variables Not Rendering
**Solution:** Ensure you're using the correct namespace:
```jinja
❌ {{ company }}
✅ {{ passthrough.company }}
```
### Problem: Empty Output for Tool Data
**Solution:** Check that tool pre-fetching is enabled and the tool is configured correctly.
### Problem: Syntax Errors
**Solution:** Validate template syntax. Common issues:
```jinja
❌ {{ variable } # Missing closing brace
❌ {% if x % # Missing closing %}
✅ {{ variable }}
✅ {% if x %}...{% endif %}
```
### Problem: Legacy Prompts Not Working
**Solution:** The system auto-detects template syntax. If your prompt uses `{summaries}`, it will work in legacy mode. To use new features, add `{{ }}` syntax.
---
## API Reference
### Render Prompt via API
```python
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
rendered = renderer.render_prompt(
prompt_content="Your template with {{ passthrough.name }}",
user_id="user_123",
request_id="req_456",
passthrough_data={"name": "Alice"},
docs_together="Document content here",
tools_data={"memory": {"root": "Files: notes.txt"}}
)
```
---
Feel free to customize the prompt to align it with your specific use case or the kind of responses you want from the AI. For example, you can focus on specific document types, industries, or topics to get more targeted results.
## Conclusion
The new template-based prompt system provides powerful flexibility while maintaining backward compatibility. By leveraging namespaces, you can create dynamic, context-aware prompts that adapt to your specific use case.
Customizing the main prompt for DocsGPT allows you to tailor the AI's responses to your unique requirements. Whether you need in-depth explanations, code examples, or specific insights, you can achieve it by modifying the main prompt. Remember to experiment and fine-tune your prompts to get the best results.
**Key Benefits:**
- ✅ Dynamic variable injection
- ✅ Organized namespaces
- ✅ Backward compatible
- ✅ Security built-in
- ✅ Easy to debug
Start with simple templates and gradually add complexity as needed. Happy prompting! 🚀

View File

@@ -57,7 +57,7 @@ The easiest way to launch DocsGPT is using the provided `setup.sh` script. This
* **4) Connect Cloud API Provider:** This option lets you connect DocsGPT to a commercial Cloud API provider such as OpenAI, Google (Vertex AI/Gemini), Anthropic (Claude), Groq, HuggingFace Inference API, or Azure OpenAI. You will need an API key from your chosen provider. Select this if you prefer to use a powerful cloud-based LLM.
* **5) Modify DocsGPT's source code and rebuild the Docker images locally.** Instead of pulling prebuilt images from Docker Hub or using the hosted/public API, you build the entire backend and frontend from source, customizing how DocsGPT works internally, or run it in an environment without internet access.
* **5) Modify DocsGPT's source code and rebuild the Docker images locally. Instead of pulling prebuilt images from Docker Hub or using the hosted/public API, you build the entire backend and frontend from source, customizing how DocsGPT works internally, or run it in an environment without internet access.
After selecting an option and providing any required information (like API keys or model names), the script will configure your `.env` file and start DocsGPT using Docker Compose.
@@ -119,4 +119,4 @@ If you prefer a more manual approach, you can follow our [Docker Deployment docu
For more advanced customization of DocsGPT settings, such as configuring vector stores, embedding models, and other parameters, please refer to the [DocsGPT Settings documentation](/Deploying/DocsGPT-Settings). This guide explains how to modify the `.env` file or `settings.py` for deeper configuration.
Enjoy using DocsGPT!
Enjoy using DocsGPT!

17
frontend/.eslintignore Normal file
View File

@@ -0,0 +1,17 @@
node_modules/
dist/
prettier.config.cjs
.eslintrc.cjs
env.d.ts
public/
assets/
vite-env.d.ts
.prettierignore
package-lock.json
package.json
postcss.config.cjs
prettier.config.cjs
tailwind.config.cjs
tsconfig.json
tsconfig.node.json
vite.config.ts

45
frontend/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,45 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:prettier/recommended',
],
overrides: [],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react', 'unused-imports'],
rules: {
'react/prop-types': 'off',
'unused-imports/no-unused-imports': 'error',
'react/react-in-jsx-scope': 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
};

View File

@@ -1,78 +0,0 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import react from 'eslint-plugin-react'
import unusedImports from 'eslint-plugin-unused-imports'
import prettier from 'eslint-plugin-prettier'
import globals from 'globals'
export default [
{
ignores: [
'node_modules/',
'dist/',
'prettier.config.cjs',
'.eslintrc.cjs',
'env.d.ts',
'public/',
'assets/',
'vite-env.d.ts',
'.prettierignore',
'package-lock.json',
'package.json',
'postcss.config.cjs',
'tailwind.config.cjs',
'tsconfig.json',
'tsconfig.node.json',
'vite.config.ts',
],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
react,
'unused-imports': unusedImports,
prettier,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
...react.configs.recommended.rules,
...prettier.configs.recommended.rules,
'react/prop-types': 'off',
'unused-imports/no-unused-imports': 'error',
'react/react-in-jsx-scope': 'off',
'no-undef': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-expressions': 'warn',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
settings: {
react: {
version: 'detect',
},
},
},
]

File diff suppressed because it is too large Load Diff

View File

@@ -19,25 +19,25 @@
]
},
"dependencies": {
"@reduxjs/toolkit": "^2.10.1",
"@reduxjs/toolkit": "^2.8.2",
"chart.js": "^4.4.4",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-browser-languagedetector": "^8.0.2",
"lodash": "^4.17.21",
"mermaid": "^11.12.1",
"mermaid": "^11.12.0",
"prop-types": "^15.8.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.1",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-google-drive-picker": "^1.2.2",
"react-i18next": "^16.2.4",
"react-i18next": "^15.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.1",
"react-syntax-highlighter": "^16.1.0",
"react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
@@ -46,28 +46,30 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/lodash": "^4.17.20",
"@types/mermaid": "^9.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.7",
"@types/react-dom": "^19.0.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.39.1",
"eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5",
"eslint-config-standard-with-typescript": "^34.0.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-promise": "^6.6.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7",
"husky": "^8.0.0",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"prettier-plugin-tailwindcss": "^0.6.13",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"vite": "^7.2.0",
"vite": "^6.3.5",
"vite-plugin-svgr": "^4.3.0"
}
}

View File

@@ -1 +0,0 @@
<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-640q0-33 23.5-56.5T240-880h480q33 0 56.5 23.5T800-800v640q0 33-23.5 56.5T720-80H240Zm0-80h480v-640H240v640Zm88-104 56-56-56-56-56 56 56 56Zm0-160 56-56-56-56-56 56 56 56Zm0-160 56-56-56-56-56 56 56 56Zm120 280h232v-80H448v80Zm0-160h232v-80H448v80Zm0-160h232v-80H448v80ZM240-160v-640 640Z"/></svg>

Before

Width:  |  Height:  |  Size: 446 B

View File

@@ -9,8 +9,7 @@ import userService from './api/services/userService';
import Add from './assets/add.svg';
import DocsGPT3 from './assets/cute_docsgpt3.svg';
import Discord from './assets/discord.svg';
import PanelLeftClose from './assets/panel-left-close.svg';
import PanelLeftOpen from './assets/panel-left-open.svg';
import Expand from './assets/expand.svg';
import Github from './assets/git_nav.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
@@ -303,20 +302,18 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{
<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">
{!navOpen && (
<button
onClick={() => {
setNavOpen(!navOpen);
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={PanelLeftOpen}
alt="Open navigation menu"
className="m-auto transition-all duration-300 ease-in-out"
/>
</button>
)}
<button
onClick={() => {
setNavOpen(!navOpen);
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={Expand}
alt="Toggle navigation menu"
className="m-auto transition-all duration-300 ease-in-out"
/>
</button>
{queries?.length > 0 && (
<button
onClick={() => {
@@ -366,8 +363,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}}
>
<img
src={navOpen ? PanelLeftClose : PanelLeftOpen}
alt={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
src={Expand}
alt="Toggle navigation menu"
className="m-auto transition-all duration-300 ease-in-out hover:scale-110"
/>
</button>
@@ -411,9 +408,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{recentAgents?.length > 0 ? (
<div>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="mt-1 ml-4 text-sm font-semibold">
{t('navigation.agents')}
</p>
<p className="mt-1 ml-4 text-sm font-semibold">Agents</p>
</div>
<div className="agents-container">
<div>
@@ -567,7 +562,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="flex items-center gap-1 pr-4">
<NavLink
target="_blank"
to={'https://discord.gg/vN7YFfdMpj'}
to={'https://discord.gg/WHJdfbQDR4'}
className={
'rounded-full hover:bg-gray-100 dark:hover:bg-[#28292E]'
}

View File

@@ -1,16 +1,13 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default function PageNotFound() {
const { t } = useTranslation();
return (
<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>{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
<h1>404</h1>
<p>The page you are looking for does not exist.</p>
<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="/">{t('pageNotFound.goHome')}</Link>
<Link to="/">Go Back Home</Link>
</button>
</p>
</div>

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
@@ -12,7 +11,6 @@ import Logs from '../settings/Logs';
import { Agent } from './types';
export default function AgentLogs() {
const { t } = useTranslation();
const navigate = useNavigate();
const { agentId } = useParams();
const token = useSelector(selectToken);
@@ -47,12 +45,12 @@ export default function AgentLogs() {
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
{t('agents.backToAll')}
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="text-eerie-black m-0 text-[32px] font-bold md:text-[40px] dark:text-white">
{t('agents.logs.title')}
Agent Logs
</h1>
</div>
<div className="mt-6 flex flex-col gap-3 px-4">
@@ -61,10 +59,9 @@ export default function AgentLogs() {
<p className="text-[#28292E] dark:text-[#E0E0E0]">{agent.name}</p>
<p className="text-xs text-[#28292E] dark:text-[#E0E0E0]/40">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
? 'Last used at ' +
new Date(agent.last_used_at).toLocaleString()
: t('agents.logs.noUsageHistory')}
: 'No usage history'}
</p>
</div>
)}
@@ -82,9 +79,7 @@ export default function AgentLogs() {
<Spinner />
</div>
) : (
agent && (
<Logs agentId={agent.id} tableHeader={t('agents.logs.tableHeader')} />
)
agent && <Logs agentId={agent.id} tableHeader="Agent endpoint logs" />
)}
</div>
);

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import MessageInput from '../components/MessageInput';
@@ -18,7 +17,6 @@ import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
export default function AgentPreview() {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectPreviewQueries);
@@ -111,18 +109,18 @@ export default function AgentPreview() {
} else setLastQueryReturnedErr(false);
}, [queries]);
return (
<div className="relative h-full w-full">
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:!w-full [&>div>div]:!max-w-none">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 flex w-full flex-col gap-4 pb-2">
<div className="w-full px-4">
<div>
<div className="dark:bg-raisin-black flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden">
<div className="h-[512px] w-full overflow-y-auto">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
@@ -130,10 +128,11 @@ export default function AgentPreview() {
showToolButton={selectedAgent ? false : true}
autoFocus={false}
/>
<p className="text-gray-4000 dark:text-sonic-silver w-full self-center bg-transparent pt-2 text-center text-xs md:inline">
This is a preview of the agent. You can publish it to start using it
in conversations.
</p>
</div>
<p className="text-gray-4000 dark:text-sonic-silver w-full bg-transparent text-center text-xs md:inline">
{t('agents.preview.testMessage')}
</p>
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
@@ -18,7 +17,6 @@ import { agentSectionsConfig } from './agents.config';
import { Agent } from './types';
export default function AgentsList() {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const selectedAgent = useSelector(selectSelectedAgent);
@@ -35,10 +33,11 @@ export default function AgentsList() {
return (
<div className="p-4 md:p-12">
<h1 className="text-eerie-black mb-0 text-[32px] font-bold lg:text-[40px] dark:text-[#E0E0E0]">
{t('agents.title')}
Agents
</h1>
<p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
{t('agents.description')}
Discover and create custom versions of DocsGPT that combine
instructions, extra knowledge, and any combination of skills
</p>
{agentSectionsConfig.map((sectionConfig) => (
<AgentSection key={sectionConfig.id} config={sectionConfig} />
@@ -52,7 +51,6 @@ function AgentSection({
}: {
config: (typeof agentSectionsConfig)[number];
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
@@ -87,18 +85,16 @@ function AgentSection({
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
{t(`agents.sections.${config.id}.title`)}
{config.title}
</h2>
<p className="text-[13px] text-[#71717A]">
{t(`agents.sections.${config.id}.description`)}
</p>
<p className="text-[13px] text-[#71717A]">{config.description}</p>
</div>
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
New Agent
</button>
)}
</div>
@@ -121,13 +117,13 @@ function AgentSection({
</div>
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
<p>{config.emptyStateDescription}</p>
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
New Agent
</button>
)}
</div>

View File

@@ -1,6 +1,5 @@
import isEqual from 'lodash/isEqual';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
@@ -31,7 +30,6 @@ const embeddingsName =
'huggingface_sentence-transformers/all-mpnet-base-v2';
export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { agentId } = useParams();
@@ -89,8 +87,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const modeConfig = {
new: {
heading: t('agents.form.headings.new'),
buttonText: t('agents.form.buttons.publish'),
heading: 'New Agent',
buttonText: 'Publish',
showDelete: false,
showSaveDraft: true,
showLogs: false,
@@ -98,8 +96,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trackChanges: false,
},
edit: {
heading: t('agents.form.headings.edit'),
buttonText: t('agents.form.buttons.save'),
heading: 'Edit Agent',
buttonText: 'Save',
showDelete: true,
showSaveDraft: false,
showLogs: true,
@@ -107,8 +105,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trackChanges: true,
},
draft: {
heading: t('agents.form.headings.draft'),
buttonText: t('agents.form.buttons.publish'),
heading: 'New Agent (Draft)',
buttonText: 'Publish',
showDelete: true,
showSaveDraft: true,
showLogs: false,
@@ -118,8 +116,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
};
const chunks = ['0', '2', '4', '6', '8', '10'];
const agentTypes = [
{ label: t('agents.form.agentTypes.classic'), value: 'classic' },
{ label: t('agents.form.agentTypes.react'), value: 'react' },
{ label: 'Classic', value: 'classic' },
{ label: 'ReAct', value: 'react' },
];
const isPublishable = () => {
@@ -200,19 +198,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
if (agent.limited_token_mode && agent.token_limit) {
formData.append('limited_token_mode', 'True');
formData.append('token_limit', agent.token_limit.toString());
} else {
formData.append('limited_token_mode', 'False');
formData.append('token_limit', '0');
}
formData.append('token_limit', JSON.stringify(agent.token_limit));
} else formData.append('token_limit', '0');
if (agent.limited_request_mode && agent.request_limit) {
formData.append('limited_request_mode', 'True');
formData.append('request_limit', agent.request_limit.toString());
} else {
formData.append('limited_request_mode', 'False');
formData.append('request_limit', '0');
}
formData.append('request_limit', JSON.stringify(agent.request_limit));
} else formData.append('request_limit', '0');
if (imageFile) formData.append('image', imageFile);
@@ -303,22 +295,15 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('json_schema', JSON.stringify(agent.json_schema));
}
// Always send the limited mode fields
if (agent.limited_token_mode && agent.token_limit) {
formData.append('limited_token_mode', 'True');
formData.append('token_limit', agent.token_limit.toString());
} else {
formData.append('limited_token_mode', 'False');
formData.append('token_limit', '0');
}
formData.append('token_limit', JSON.stringify(agent.token_limit));
} else formData.append('token_limit', '0');
if (agent.limited_request_mode && agent.request_limit) {
formData.append('limited_request_mode', 'True');
formData.append('request_limit', agent.request_limit.toString());
} else {
formData.append('limited_request_mode', 'False');
formData.append('request_limit', '0');
}
formData.append('request_limit', JSON.stringify(agent.request_limit));
} else formData.append('request_limit', '0');
try {
setPublishLoading(true);
@@ -549,7 +534,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setHasChanges(isChanged);
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
return (
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-[100dvh] min-[1180px]:h-[100dvh] md:px-12 md:pt-12 md:pb-3">
<div className="p-4 md:p-12">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
@@ -558,7 +543,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
{t('agents.backToAll')}
Back to all agents
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
@@ -570,7 +555,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="text-purple-30 dark:text-light-gray mr-4 rounded-3xl py-2 text-sm font-medium dark:bg-transparent"
onClick={handleCancel}
>
{t('agents.form.buttons.cancel')}
Cancel
</button>
{modeConfig[effectiveMode].showDelete && agent.id && (
<button
@@ -578,7 +563,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onClick={() => setDeleteConfirmation('ACTIVE')}
>
<span className="block h-4 w-4 bg-[url('/src/assets/red-trash.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/white-trash.svg')]" />
{t('agents.form.buttons.delete')}
Delete
</button>
)}
{modeConfig[effectiveMode].showSaveDraft && (
@@ -593,7 +578,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{draftLoading ? (
<Spinner size="small" color="#976af3" />
) : (
t('agents.form.buttons.saveDraft')
'Save Draft'
)}
</span>
</button>
@@ -604,7 +589,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onClick={() => navigate(`/agents/logs/${agent.id}`)}
>
<span className="block h-5 w-5 bg-[url('/src/assets/monitoring-purple.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/monitoring-white.svg')]" />
{t('agents.form.buttons.logs')}
Logs
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
@@ -612,7 +597,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="hover:bg-vi</button>olets-are-blue border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={() => setAgentDetails('ACTIVE')}
>
{t('agents.form.buttons.accessDetails')}
Access Details
</button>
)}
<button
@@ -630,22 +615,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</button>
</div>
</div>
<div className="mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-[30px] bg-[#F6F6F6] p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden dark:bg-[#383838]">
<div className="scrollbar-thin col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.meta')}
</h2>
<div className="mt-5 flex w-full grid-cols-5 flex-col gap-10 min-[1180px]:grid min-[1180px]:gap-5">
<div className="col-span-2 flex flex-col gap-5">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Meta</h2>
<input
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
type="text"
value={agent.name}
placeholder={t('agents.form.placeholders.agentName')}
placeholder="Agent name"
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 h-32 w-full rounded-xl border bg-white px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
placeholder={t('agents.form.placeholders.describeAgent')}
placeholder="Describe your agent"
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
@@ -658,22 +641,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{ text: 'Click to upload', colorClass: 'text-[#7D54D1]' },
{
text: t('agents.form.upload.clickToUpload'),
colorClass: 'text-[#7D54D1]',
},
{
text: t('agents.form.upload.dragAndDrop'),
text: ' or drag and drop',
colorClass: 'text-[#525252]',
},
]}
/>
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.source')}
</h2>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Source</h2>
<div className="mt-3">
<div className="flex flex-wrap items-center gap-1">
<button
@@ -694,13 +672,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
source.name === id ||
source.retriever === id,
);
return (
matchedDoc?.name || t('agents.form.externalKb')
);
return matchedDoc?.name || `External KB`;
})
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectSources')}
: 'Select sources'}
</button>
<MultiSelectPopup
isOpen={isSourcePopupOpen}
@@ -744,13 +720,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setSelectedSourceIds(newSelectedIds);
}
}}
title={t('agents.form.sourcePopup.title')}
searchPlaceholder={t(
'agents.form.sourcePopup.searchPlaceholder',
)}
noOptionsMessage={t(
'agents.form.sourcePopup.noOptionsMessage',
)}
title="Select Sources"
searchPlaceholder="Search sources..."
noOptionsMessage="No sources available"
/>
</div>
<div className="mt-3">
@@ -765,14 +737,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
border="border"
buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder={t('agents.form.placeholders.chunksPerQuery')}
placeholder="Chunks per query"
placeholderClassName="text-gray-400 dark:text-silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<div className="flex flex-wrap items-end gap-1">
<div className="min-w-20 grow basis-full sm:basis-0">
<Prompts
@@ -785,7 +757,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, prompt_id: id })
}
setPrompts={setPrompts}
title={t('agents.form.sections.prompt')}
title="Prompt"
titleClassName="text-lg font-semibold dark:text-[#E0E0E0]"
showAddButton={false}
dropdownProps={{
@@ -805,14 +777,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue w-20 shrink-0 basis-full rounded-3xl border-2 border-solid px-5 py-[11px] text-sm transition-colors hover:text-white sm:basis-auto"
onClick={() => setAddPromptModal('ACTIVE')}
>
{t('agents.form.buttons.add')}
Add
</button>
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.tools')}
</h2>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Tools</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<button
ref={toolAnchorButtonRef}
@@ -828,7 +798,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
.map((tool) => tool.display_name || tool.name)
.filter(Boolean)
.join(', ')
: t('agents.form.placeholders.selectTools')}
: 'Select tools'}
</button>
<MultiSelectPopup
isOpen={isToolsPopupOpen}
@@ -847,18 +817,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
})),
)
}
title={t('agents.form.toolsPopup.title')}
searchPlaceholder={t(
'agents.form.toolsPopup.searchPlaceholder',
)}
noOptionsMessage={t('agents.form.toolsPopup.noOptionsMessage')}
title="Select Tools"
searchPlaceholder="Search tools..."
noOptionsMessage="No tools available"
/>
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.agentType')}
</h2>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Agent type</h2>
<div className="mt-3">
<Dropdown
options={agentTypes}
@@ -876,13 +842,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
border="border"
buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder={t('agents.form.placeholders.selectType')}
placeholder="Select type"
placeholderClassName="text-gray-400 dark:text-silver"
contentSize="text-sm"
/>
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<button
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
@@ -890,9 +856,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="flex w-full items-center justify-between text-left focus:outline-none"
>
<div>
<h2 className="text-lg font-semibold">
{t('agents.form.sections.advanced')}
</h2>
<h2 className="text-lg font-semibold">Advanced</h2>
</div>
<div className="ml-4 flex items-center">
<svg
@@ -915,11 +879,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{isAdvancedSectionExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.jsonSchema')}
</h2>
<h2 className="text-sm font-medium">JSON response schema</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.jsonSchemaDescription')}
Define a JSON schema to enforce structured output format
</p>
</div>
<textarea
@@ -953,19 +915,17 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}`}
/>
{jsonSchemaValid
? t('agents.form.advanced.validJson')
: t('agents.form.advanced.invalidJson')}
? 'Valid JSON'
: 'Invalid JSON - fix to enable saving'}
</div>
)}
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.tokenLimiting')}
</h2>
<h2 className="text-sm font-medium">Token limiting</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.tokenLimitingDescription')}
Limit daily total tokens that can be used by this agent
</p>
</div>
<button
@@ -1005,7 +965,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
})
}
disabled={!agent.limited_token_mode}
placeholder={t('agents.form.placeholders.enterTokenLimit')}
placeholder="Enter token limit"
className={`border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-2 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E] ${
!agent.limited_token_mode
? 'cursor-not-allowed opacity-50'
@@ -1017,11 +977,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.requestLimiting')}
</h2>
<h2 className="text-sm font-medium">Request limiting</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.requestLimitingDescription')}
Limit daily total requests that can be made to this
agent
</p>
</div>
<button
@@ -1061,9 +1020,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
})
}
disabled={!agent.limited_request_mode}
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
)}
placeholder="Enter request limit"
className={`border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-2 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E] ${
!agent.limited_request_mode
? 'cursor-not-allowed opacity-50'
@@ -1075,25 +1032,21 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)}
</div>
</div>
<div className="col-span-3 flex flex-col gap-2 max-[1179px]:h-auto max-[1179px]:px-0 max-[1179px]:py-0 min-[1180px]:h-full min-[1180px]:py-2 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.preview')}
</h2>
<div className="flex-1 max-[1179px]:overflow-visible min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
<AgentPreviewArea />
</div>
<div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Preview</h2>
<AgentPreviewArea />
</div>
</div>
<ConfirmationModal
message={t('agents.deleteConfirmation')}
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('agents.form.buttons.delete')}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('agents.form.buttons.cancel')}
cancelLabel="Cancel"
variant="danger"
/>
<AgentDetailsModal
@@ -1116,19 +1069,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
function AgentPreviewArea() {
const { t } = useTranslation();
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="dark:bg-raisin-black w-full rounded-[30px] border border-[#F6F6F6] bg-white max-[1179px]:h-[600px] min-[1180px]:h-full dark:border-[#7E7E7E]">
<div className="dark:bg-raisin-black h-full w-full rounded-[30px] border border-[#F6F6F6] bg-white max-[1180px]:h-192 dark:border-[#7E7E7E]">
{selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col overflow-hidden rounded-[30px]">
<div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]">
<AgentPreview />
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<p className="dark:text-gray-4000 text-xs text-[#18181B]">
{t('agents.form.preview.publishedPreview')}
Published agents can be previewed here
</p>
</div>
)}

View File

@@ -144,7 +144,7 @@ export default function SharedAgent() {
className="mx-auto mb-6 h-32 w-32"
/>
<p className="dark:text-gray-4000 text-center text-lg text-[#71717A]">
{t('agents.shared.notFound')}
No agent found. Please ensure the agent is shared.
</p>
</div>
</div>
@@ -177,15 +177,13 @@ export default function SharedAgent() {
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="w-full px-2">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
showSourceButton={sharedAgent ? false : true}
showToolButton={sharedAgent ? false : true}
autoFocus={false}
/>
</div>
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
showSourceButton={sharedAgent ? false : true}
showToolButton={sharedAgent ? false : true}
autoFocus={false}
/>
<p className="text-gray-4000 dark:text-sonic-silver hidden w-screen self-center bg-transparent py-2 text-center text-xs md:inline md:w-full">
{t('tagline')}
</p>

View File

@@ -1,3 +0,0 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.2857 14H2.57143C1.15179 14 0 12.8242 0 11.375V2.625C0 1.17578 1.15179 0 2.57143 0H10.7143C11.4241 0 12 0.587891 12 1.3125V9.1875C12 9.75898 11.6411 10.2457 11.1429 10.4262V12.25C11.617 12.25 12 12.641 12 13.125C12 13.609 11.617 14 11.1429 14H10.2857ZM2.57143 10.5C2.09732 10.5 1.71429 10.891 1.71429 11.375C1.71429 11.859 2.09732 12.25 2.57143 12.25H9.42857V10.5H2.57143ZM3.42857 4.15625C3.42857 4.51992 3.71518 4.8125 4.07143 4.8125H8.78571C9.14196 4.8125 9.42857 4.51992 9.42857 4.15625C9.42857 3.79258 9.14196 3.5 8.78571 3.5H4.07143C3.71518 3.5 3.42857 3.79258 3.42857 4.15625ZM4.07143 6.125C3.71518 6.125 3.42857 6.41758 3.42857 6.78125C3.42857 7.14492 3.71518 7.4375 4.07143 7.4375H8.78571C9.14196 7.4375 9.42857 7.14492 9.42857 6.78125C9.42857 6.41758 9.14196 6.125 8.78571 6.125H4.07143Z" fill="#6A4DF4"/>
</svg>

Before

Width:  |  Height:  |  Size: 930 B

View File

@@ -0,0 +1,4 @@
<svg width="27" height="26" viewBox="0 0 27 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.03371 5.27275L4.1915 20.9162C4.20021 21.7802 4.90766 22.4735 5.77162 22.4648L21.4151 22.307C22.2791 22.2983 22.9724 21.5909 22.9637 20.7269L22.8059 5.0834C22.7972 4.21944 22.0897 3.52612 21.2258 3.53483L5.58228 3.69262C4.71831 3.70134 4.02499 4.40878 4.03371 5.27275Z" stroke="#949494" stroke-width="2.08591" stroke-linejoin="round"/>
<path d="M9.42289 22.428L9.23354 3.65585M17.6924 15.0436L15.5856 12.9788L17.6504 10.872M6.29419 22.4596L12.5516 22.3965M6.10484 3.68741L12.3622 3.62429" stroke="#949494" stroke-width="2.08591" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@@ -1,5 +1,5 @@
<svg width="113" height="124" viewBox="0 0 113 124" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="55.5" cy="71" r="53" fill="#E8E3F3" fill-opacity="0.6"/>
<circle cx="55.5" cy="71" r="53" fill="#F1F1F1" fill-opacity="0.5"/>
<rect x="-0.599797" y="0.654564" width="43.9445" height="61.5222" rx="4.39444" transform="matrix(-0.999048 0.0436194 0.0436194 0.999048 68.9873 43.3176)" fill="#EEEEEE" stroke="#999999" stroke-width="1.25556"/>
<rect x="0.704349" y="-0.540466" width="46.4556" height="64.0333" rx="5.65" transform="matrix(-0.991445 -0.130526 -0.130526 0.991445 96.3673 40.893)" fill="#FAFAFA" stroke="#999999" stroke-width="1.25556"/>
<path d="M94.3796 45.7849C94.7417 43.0349 92.8059 40.5122 90.0559 40.1501L55.2011 35.5614C52.4511 35.1994 49.9284 37.1352 49.5663 39.8851L48.3372 49.2212L93.1505 55.121L94.3796 45.7849Z" fill="#EEEEEE"/>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-left-close-icon lucide-panel-left-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>

Before

Width:  |  Height:  |  Size: 345 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-left-open-icon lucide-panel-left-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m14 9 3 3-3 3"/></svg>

Before

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1,16 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 48 48" id="b" xmlns="http://www.w3.org/2000/svg" fill="#000000" stroke="#000000" stroke-width="3.312">
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/>
<g id="SVGRepo_iconCarrier">
<defs>
<style>.c{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style>
</defs>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -45,7 +45,7 @@ export default function ActionButtons({
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
{showNewChat && (
<button
title={t('actionButtons.openNewChat')}
title="Open New Chat"
onClick={newChat}
className="hover:bg-bright-gray flex items-center gap-1 rounded-full p-2 lg:hidden dark:hover:bg-[#28292E]"
>
@@ -62,7 +62,7 @@ export default function ActionButtons({
{showShare && conversationId && (
<>
<button
title={t('actionButtons.share')}
title="Share"
onClick={() => setShareModalState(true)}
className="hover:bg-bright-gray rounded-full p-2 dark:hover:bg-[#28292E]"
>

View File

@@ -136,34 +136,33 @@ const Chunks: React.FC<ChunksProps> = ({
const pathParts = path ? path.split('/') : [];
const fetchChunks = async () => {
const fetchChunks = () => {
setLoading(true);
try {
const response = await userService.getDocumentChunks(
documentId,
page,
perPage,
token,
path,
searchTerm,
);
if (!response.ok) {
throw new Error('Failed to fetch chunks data');
}
const data = await response.json();
setPage(data.page);
setPerPage(data.per_page);
setTotalChunks(data.total);
setPaginatedChunks(data.chunks);
} catch (error) {
setPaginatedChunks([]);
console.error(error);
} finally {
// ✅ always runs, success or failure
userService
.getDocumentChunks(documentId, page, perPage, token, path, searchTerm)
.then((response) => {
if (!response.ok) {
setLoading(false);
setPaginatedChunks([]);
throw new Error('Failed to fetch chunks data');
}
return response.json();
})
.then((data) => {
setPage(data.page);
setPerPage(data.per_page);
setTotalChunks(data.total);
setPaginatedChunks(data.chunks);
setLoading(false);
})
.catch((error) => {
setLoading(false);
setPaginatedChunks([]);
});
} catch (e) {
setLoading(false);
setPaginatedChunks([]);
}
};

View File

@@ -0,0 +1,13 @@
const ConnectedStateSkeleton = () => (
<div className="mb-4">
<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 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 rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
</div>
);
export default ConnectedStateSkeleton;

View File

@@ -150,7 +150,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
{isConnected ? (
<div className="mb-4">
<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">
<div className="flex max-w-[500px] items-center gap-2">
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"

View File

@@ -38,7 +38,7 @@ interface DirectoryStructure {
[key: string]: FileNode;
}
interface ConnectorTreeProps {
interface ConnectorTreeComponentProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
@@ -50,7 +50,7 @@ interface SearchResult {
isFile: boolean;
}
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
const ConnectorTreeComponent: React.FC<ConnectorTreeComponentProps> = ({
docId,
sourceName,
onBackToDocuments,
@@ -744,4 +744,4 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
);
};
export default ConnectorTree;
export default ConnectorTreeComponent;

View File

@@ -20,9 +20,14 @@ type CopyButtonProps = {
const DEFAULT_ICON_SIZE = 'w-4 h-4';
const DEFAULT_PADDING = 'p-2';
const DEFAULT_COPIED_DURATION = 2000;
const DEFAULT_BG_LIGHT = '#FFFFFF';
const DEFAULT_BG_DARK = 'transparent';
const DEFAULT_HOVER_BG_LIGHT = '#EEEEEE';
const DEFAULT_HOVER_BG_DARK = '#464152';
export default function CopyButton({
textToCopy,
iconSize = DEFAULT_ICON_SIZE,
padding = DEFAULT_PADDING,
showText = false,
@@ -38,8 +43,9 @@ export default function CopyButton({
const iconWrapperClasses = clsx(
'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out',
padding,
`bg-[${DEFAULT_BG_LIGHT}] dark:bg-[${DEFAULT_BG_DARK}]`,
{
[`bg-[#FFFFFF}] dark:bg-transparent hover:bg-[#EEEEEE] dark:hover:bg-purple-taupe`]:
[`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

@@ -60,7 +60,7 @@ function Dropdown<T extends DropdownOption>({
}`}
>
{typeof selectedValue === 'string' ? (
<span className={`dark:text-bright-gray truncate ${contentSize}`}>
<span className="dark:text-bright-gray truncate">
{selectedValue}
</span>
) : (

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { formatBytes } from '../utils/stringUtils';
import { formatDate } from '../utils/dateTimeUtils';
import {
@@ -67,7 +66,6 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
);
};
const { t } = useTranslation();
const [files, setFiles] = useState<CloudFile[]>([]);
const [selectedFiles, setSelectedFiles] =
useState<string[]>(initialSelectedFiles);
@@ -419,7 +417,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
<div className="mb-3 max-w-md">
<Input
type="text"
placeholder={t('filePicker.searchPlaceholder')}
placeholder="Search files and folders..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
colorVariant="silver"
@@ -433,9 +431,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
{/* Selected Files Message */}
<div className="pb-3 text-sm text-gray-600 dark:text-gray-400">
{t('filePicker.itemsSelected', {
count: selectedFiles.length + selectedFolders.length,
})}
{selectedFiles.length + selectedFolders.length} selected
</div>
</div>
@@ -452,15 +448,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
<TableHead>
<TableRow>
<TableHeader width="40px"></TableHeader>
<TableHeader width="60%">
{t('filePicker.name')}
</TableHeader>
<TableHeader width="20%">
{t('filePicker.lastModified')}
</TableHeader>
<TableHeader width="20%">
{t('filePicker.size')}
</TableHeader>
<TableHeader width="60%">Name</TableHeader>
<TableHeader width="20%">Last Modified</TableHeader>
<TableHeader width="20%">Size</TableHeader>
</TableRow>
</TableHead>
<TableBody>

View File

@@ -0,0 +1,13 @@
const FilesSectionSkeleton = () => (
<div className="rounded-lg border border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="p-4">
<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 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
);
export default FilesSectionSkeleton;

View File

@@ -36,7 +36,7 @@ interface DirectoryStructure {
[key: string]: FileNode;
}
interface FileTreeProps {
interface FileTreeComponentProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
@@ -48,7 +48,7 @@ interface SearchResult {
isFile: boolean;
}
const FileTree: React.FC<FileTreeProps> = ({
const FileTreeComponent: React.FC<FileTreeComponentProps> = ({
docId,
sourceName,
onBackToDocuments,
@@ -871,4 +871,4 @@ const FileTree: React.FC<FileTreeProps> = ({
);
};
export default FileTree;
export default FileTreeComponent;

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDropzone } from 'react-dropzone';
import { twMerge } from 'tailwind-merge';
@@ -45,14 +44,13 @@ export const FileUpload = ({
activeClassName = 'border-blue-500 bg-blue-50',
acceptClassName = 'border-green-500 dark:border-green-500 bg-green-50 dark:bg-green-50/10',
rejectClassName = 'border-red-500 bg-red-50 dark:bg-red-500/10 dark:border-red-500',
uploadText,
dragActiveText,
fileTypeText,
sizeLimitText,
uploadText = 'Click to upload or drag and drop',
dragActiveText = 'Drop the files here',
fileTypeText = 'PNG, JPG, JPEG up to',
sizeLimitText = 'MB',
disabled = false,
validator,
}: FileUploadProps) => {
const { t } = useTranslation();
const [errors, setErrors] = useState<string[]>([]);
const [preview, setPreview] = useState<string | null>(null);
const [currentFile, setCurrentFile] = useState<File | null>(null);
@@ -73,9 +71,7 @@ export const FileUpload = ({
if (file.size > maxSize) {
return {
isValid: false,
error: t('components.fileUpload.fileSizeError', {
size: maxSize / 1024 / 1024,
}),
error: `File exceeds ${maxSize / 1024 / 1024}MB limit`,
};
}
@@ -182,11 +178,7 @@ export const FileUpload = ({
</p>
);
}
return (
<p className="text-sm font-semibold">
{uploadText || t('components.fileUpload.clickToUpload')}
</p>
);
return <p className="text-sm font-semibold">{uploadText}</p>;
};
const defaultContent = (
@@ -204,17 +196,14 @@ export const FileUpload = ({
<div className="text-center">
<div className="text-sm font-medium">
{isDragActive ? (
<p className="text-sm font-semibold">
{dragActiveText || t('components.fileUpload.dropFiles')}
</p>
<p className="text-sm font-semibold">{dragActiveText}</p>
) : (
renderUploadText()
)}
</div>
<p className="mt-1 text-xs text-[#A3A3A3]">
{fileTypeText || t('components.fileUpload.fileTypes')}{' '}
{maxSize / 1024 / 1024}
{sizeLimitText || t('components.fileUpload.sizeLimitUnit')}
{fileTypeText} {maxSize / 1024 / 1024}
{sizeLimitText}
</p>
</div>
</div>

View File

@@ -7,7 +7,10 @@ import {
getSessionToken,
setSessionToken,
removeSessionToken,
validateProviderSession,
} from '../utils/providerUtils';
import ConnectedStateSkeleton from './ConnectedStateSkeleton';
import FilesSectionSkeleton from './FileSelectionSkeleton';
interface PickerFile {
id: string;
@@ -50,20 +53,9 @@ 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}`,
},
body: JSON.stringify({
provider: 'google_drive',
session_token: sessionToken,
}),
},
const validateResponse = await validateProviderSession(
token,
'google_drive',
);
if (!validateResponse.ok) {
@@ -234,30 +226,6 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
onSelectionChange([], []);
};
const ConnectedStateSkeleton = () => (
<div className="mb-4">
<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 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 rounded bg-gray-300 dark:bg-gray-600"></div>
</div>
</div>
);
const FilesSectionSkeleton = () => (
<div className="rounded-lg border border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="p-4">
<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 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
);
return (
<div>
{isValidating ? (

View File

@@ -20,7 +20,6 @@ const Input = ({
onChange,
onPaste,
onKeyDown,
edgeRoundness = 'rounded-full',
}: InputProps) => {
const colorStyles = {
silver: 'border-silver dark:border-silver/40',
@@ -44,7 +43,7 @@ const Input = ({
<div className={`relative ${className}`}>
<input
ref={inputRef}
className={`peer text-jet dark:text-bright-gray h-[42px] w-full ${edgeRoundness} bg-transparent ${leftIcon ? 'pl-10' : 'px-3'} py-1 placeholder-transparent outline-hidden ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`}
className={`peer text-jet dark:text-bright-gray h-[42px] w-full rounded-full bg-transparent ${leftIcon ? 'pl-10' : 'px-3'} py-1 placeholder-transparent outline-hidden ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`}
type={type}
id={id}
name={name}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import mermaid from 'mermaid';
import CopyButton from './CopyButton';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -16,7 +15,6 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
code,
isLoading,
}) => {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const diagramId = useRef(
`mermaid-${Date.now()}-${Math.random().toString(36).substring(2)}`,
@@ -275,7 +273,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<button
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
className="flex h-full items-center rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
title={t('mermaid.downloadOptions')}
title="Download options"
>
Download <span className="ml-1"></span>
</button>
@@ -309,7 +307,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
? 'bg-blue-200 dark:bg-blue-800'
: 'bg-gray-100 dark:bg-gray-700'
}`}
title={t('mermaid.viewCode')}
title="View Code"
>
Code
</button>
@@ -355,7 +353,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
setZoomFactor((prev) => Math.max(1, prev - 0.5))
}
className="rounded px-1 hover:bg-gray-600"
title={t('mermaid.decreaseZoom')}
title="Decrease zoom"
>
-
</button>
@@ -364,7 +362,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
onClick={() => {
setZoomFactor(2);
}}
title={t('mermaid.resetZoom')}
title="Reset zoom"
>
{zoomFactor.toFixed(1)}x
</span>
@@ -373,7 +371,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
setZoomFactor((prev) => Math.min(6, prev + 0.5))
}
className="rounded px-1 hover:bg-gray-600"
title={t('mermaid.increaseZoom')}
title="Increase zoom"
>
+
</button>

View File

@@ -1,6 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useDropzone } from 'react-dropzone';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
@@ -8,7 +6,6 @@ import endpoints from '../api/endpoints';
import userService from '../api/services/userService';
import AlertIcon from '../assets/alert.svg';
import ClipIcon from '../assets/clip.svg';
import DragFileUpload from '../assets/DragFileUpload.svg';
import ExitIcon from '../assets/exit.svg';
import SendArrowIcon from './SendArrowIcon';
import SourceIcon from '../assets/source.svg';
@@ -19,7 +16,6 @@ import {
removeAttachment,
selectAttachments,
updateAttachment,
reorderAttachments,
} from '../upload/uploadSlice';
import { ActiveState } from '../models/misc';
@@ -57,7 +53,6 @@ export default function MessageInput({
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
@@ -77,7 +72,7 @@ export default function MessageInput({
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
setIsSourcesPopupOpen((s) => !s);
setIsSourcesPopupOpen(!isSourcesPopupOpen);
}
};
@@ -87,360 +82,77 @@ export default function MessageInput({
};
}, [browserOS]);
const uploadFiles = useCallback(
(files: File[]) => {
if (!files || files.length === 0) return;
const apiHost = import.meta.env.VITE_API_HOST;
if (files.length > 1) {
const formData = new FormData();
const indexToUiId: Record<number, string> = {};
files.forEach((file, i) => {
formData.append('file', file);
const uiId = crypto.randomUUID();
indexToUiId[i] = uiId;
dispatch(
addAttachment({
id: uiId,
fileName: file.name,
progress: 0,
status: 'uploading' as const,
taskId: '',
}),
);
});
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
Object.values(indexToUiId).forEach((uiId) =>
dispatch(
updateAttachment({
id: uiId,
updates: { progress },
}),
),
);
}
});
xhr.onload = () => {
const status = xhr.status;
if (status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (Array.isArray(response?.tasks)) {
const tasks = response.tasks as Array<{
task_id?: string;
filename?: string;
attachment_id?: string;
path?: string;
}>;
tasks.forEach((t, idx) => {
const uiId = indexToUiId[idx];
if (!uiId) return;
if (t?.task_id) {
dispatch(
updateAttachment({
id: uiId,
updates: {
taskId: t.task_id,
status: 'processing',
progress: 10,
},
}),
);
} else {
dispatch(
updateAttachment({
id: uiId,
updates: { status: 'failed' },
}),
);
}
});
if (tasks.length < files.length) {
for (let i = tasks.length; i < files.length; i++) {
const uiId = indexToUiId[i];
if (uiId) {
dispatch(
updateAttachment({
id: uiId,
updates: { status: 'failed' },
}),
);
}
}
}
} else if (response?.task_id) {
if (files.length === 1) {
const uiId = indexToUiId[0];
if (uiId) {
dispatch(
updateAttachment({
id: uiId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
} else {
console.warn(
'Server returned a single task_id for multiple files. Update backend to return tasks[].',
);
const firstUi = indexToUiId[0];
if (firstUi) {
dispatch(
updateAttachment({
id: firstUi,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
for (let i = 1; i < files.length; i++) {
const uiId = indexToUiId[i];
if (uiId) {
dispatch(
updateAttachment({
id: uiId,
updates: { status: 'failed' },
}),
);
}
}
}
} else {
console.error('Unexpected upload response shape', response);
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: { status: 'failed' },
}),
),
);
}
} catch (err) {
console.error(
'Failed to parse upload response',
err,
xhr.responseText,
);
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: { status: 'failed' },
}),
),
);
}
} else {
console.error('Upload failed', status, xhr.responseText);
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: { status: 'failed' },
}),
),
);
}
};
xhr.onerror = () => {
console.error('Upload network error');
Object.values(indexToUiId).forEach((id) =>
dispatch(
updateAttachment({
id,
updates: { status: 'failed' },
}),
),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
return;
}
// Single-file path: upload each file individually (original repo behavior)
files.forEach((file) => {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
const uniqueId = crypto.randomUUID();
const newAttachment = {
id: uniqueId,
fileName: file.name,
progress: 0,
status: 'uploading' as const,
taskId: '',
};
dispatch(addAttachment(newAttachment));
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
dispatch(
updateAttachment({
id: uniqueId,
updates: { progress },
}),
);
}
});
xhr.onload = () => {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.task_id) {
dispatch(
updateAttachment({
id: uniqueId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
} else {
// If backend returned tasks[] for single-file, handle gracefully:
if (
Array.isArray(response?.tasks) &&
response.tasks[0]?.task_id
) {
dispatch(
updateAttachment({
id: uniqueId,
updates: {
taskId: response.tasks[0].task_id,
status: 'processing',
progress: 10,
},
}),
);
} else {
dispatch(
updateAttachment({
id: uniqueId,
updates: { status: 'failed' },
}),
);
}
}
} catch (err) {
console.error(
'Failed to parse upload response',
err,
xhr.responseText,
);
dispatch(
updateAttachment({
id: uniqueId,
updates: { status: 'failed' },
}),
);
}
} else {
dispatch(
updateAttachment({
id: uniqueId,
updates: { status: 'failed' },
}),
);
}
};
xhr.onerror = () => {
dispatch(
updateAttachment({
id: uniqueId,
updates: { status: 'failed' },
}),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
});
},
[dispatch, token],
);
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
const files = Array.from(e.target.files);
uploadFiles(files);
// clear input so same file can be selected again
const file = e.target.files[0];
const formData = new FormData();
formData.append('file', file);
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
const newAttachment = {
fileName: file.name,
progress: 0,
status: 'uploading' as const,
taskId: '',
};
dispatch(addAttachment(newAttachment));
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const progress = Math.round((event.loaded / event.total) * 100);
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { progress },
}),
);
}
});
xhr.onload = () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.task_id) {
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
} else {
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
}
};
xhr.onerror = () => {
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.send(formData);
e.target.value = '';
};
// Drag & drop via react-dropzone
const onDrop = useCallback(
(acceptedFiles: File[]) => {
uploadFiles(acceptedFiles);
setHandleDragActive(false);
},
[uploadFiles],
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
noClick: true,
noKeyboard: true,
multiple: true,
onDragEnter: () => {
setHandleDragActive(true);
},
onDragLeave: () => {
setHandleDragActive(false);
},
maxSize: 25000000,
accept: {
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
'text/x-rst': ['.rst'],
'text/x-markdown': ['.md'],
'application/zip': ['.zip'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
['.docx'],
'application/json': ['.json'],
'text/csv': ['.csv'],
'text/html': ['.html'],
'application/epub+zip': ['.epub'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
'.xlsx',
],
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
['.pptx'],
'image/png': ['.png'],
'image/jpeg': ['.jpeg'],
'image/jpg': ['.jpg'],
},
});
useEffect(() => {
const checkTaskStatus = () => {
const processingAttachments = attachments.filter(
@@ -455,7 +167,7 @@ export default function MessageInput({
if (data.status === 'SUCCESS') {
dispatch(
updateAttachment({
id: attachment.id,
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
@@ -467,14 +179,14 @@ export default function MessageInput({
} else if (data.status === 'FAILURE') {
dispatch(
updateAttachment({
id: attachment.id,
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
} else if (data.status === 'PROGRESS' && data.result?.current) {
dispatch(
updateAttachment({
id: attachment.id,
taskId: attachment.taskId!,
updates: { progress: data.result.current },
}),
);
@@ -483,7 +195,7 @@ export default function MessageInput({
.catch(() => {
dispatch(
updateAttachment({
id: attachment.id,
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
@@ -547,134 +259,90 @@ export default function MessageInput({
handleAbort();
};
const [draggingId, setDraggingId] = useState<string | null>(null);
const findIndexById = (id: string) =>
attachments.findIndex((a) => a.id === id);
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggingId(id);
try {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
} catch (err) {
// ignore
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDropOn = (e: React.DragEvent, targetId: string) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('text/plain');
if (!sourceId || sourceId === targetId) return;
const sourceIndex = findIndexById(sourceId);
const destIndex = findIndexById(targetId);
if (sourceIndex === -1 || destIndex === -1) return;
dispatch(reorderAttachments({ sourceIndex, destinationIndex: destIndex }));
setDraggingId(null);
};
return (
<div {...getRootProps()} className="flex w-full flex-col">
{/* react-dropzone input (for drag/drop) */}
<input {...getInputProps()} />
<div className="mx-2 flex w-full flex-col">
<div className="border-dark-gray bg-lotion dark:border-grey relative flex w-full flex-col rounded-[23px] border dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
{attachments.map((attachment) => {
return (
<div
key={attachment.id}
draggable={true}
onDragStart={(e) => handleDragStart(e, attachment.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDropOn(e, attachment.id)}
className={`group dark:text-bright-gray relative flex items-center rounded-xl bg-[#EFF3F4] px-2 py-1 text-[12px] text-[#5D5D5D] sm:px-3 sm:py-1.5 sm:text-[14px] dark:bg-[#393B3D] ${
attachment.status !== 'completed'
? 'opacity-70'
: 'opacity-100'
} ${
draggingId === attachment.id
? 'ring-dashed opacity-60 ring-2 ring-purple-200'
: ''
}`}
title={attachment.fileName}
>
<div className="bg-purple-30 mr-2 flex h-8 w-8 items-center justify-center rounded-md p-1">
{attachment.status === 'completed' && (
<img
src={DocumentationDark}
alt="Attachment"
className="h-[15px] w-[15px] object-fill"
/>
)}
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Failed"
className="h-[15px] w-[15px] object-fill"
/>
)}
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="flex h-[15px] w-[15px] items-center justify-center">
<svg className="h-[15px] w-[15px]" viewBox="0 0 24 24">
<circle
className="opacity-0"
cx="12"
cy="12"
r="10"
stroke="transparent"
strokeWidth="4"
fill="none"
/>
<circle
className="text-[#ECECF1]"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={
62.83 * (1 - attachment.progress / 100)
}
transform="rotate(-90 12 12)"
/>
</svg>
</div>
)}
</div>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{attachment.fileName}
</span>
<button
className="ml-1.5 flex items-center justify-center rounded-full p-1"
onClick={() => {
dispatch(removeAttachment(attachment.id));
}}
aria-label={t('conversation.attachments.remove')}
>
{attachments.map((attachment, index) => (
<div
key={index}
className={`group dark:text-bright-gray relative flex items-center rounded-xl bg-[#EFF3F4] px-2 py-1 text-[12px] text-[#5D5D5D] sm:px-3 sm:py-1.5 sm:text-[14px] dark:bg-[#393B3D] ${
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`}
title={attachment.fileName}
>
<div className="bg-purple-30 mr-2 items-center justify-center rounded-lg p-[5.5px]">
{attachment.status === 'completed' && (
<img
src={ExitIcon}
alt={t('conversation.attachments.remove')}
className="h-2.5 w-2.5 filter dark:invert"
src={DocumentationDark}
alt="Attachment"
className="h-[15px] w-[15px] object-fill"
/>
</button>
)}
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Failed"
className="h-[15px] w-[15px] object-fill"
/>
)}
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="flex h-[15px] w-[15px] items-center justify-center">
<svg className="h-[15px] w-[15px]" viewBox="0 0 24 24">
<circle
className="opacity-0"
cx="12"
cy="12"
r="10"
stroke="transparent"
strokeWidth="4"
fill="none"
/>
<circle
className="text-[#ECECF1]"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={
62.83 * (1 - attachment.progress / 100)
}
transform="rotate(-90 12 12)"
/>
</svg>
</div>
)}
</div>
);
})}
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{attachment.fileName}
</span>
<button
className="ml-1.5 flex items-center justify-center rounded-full p-1"
onClick={() => {
if (attachment.id) {
dispatch(removeAttachment(attachment.id));
} else if (attachment.taskId) {
dispatch(removeAttachment(attachment.taskId));
}
}}
aria-label={t('conversation.attachments.remove')}
>
<img
src={ExitIcon}
alt={t('conversation.attachments.remove')}
className="h-2.5 w-2.5 filter dark:invert"
/>
</button>
</div>
))}
</div>
<div className="w-full">
@@ -756,7 +424,6 @@ export default function MessageInput({
<input
type="file"
className="hidden"
multiple
onChange={handleFileAttachment}
/>
</label>
@@ -816,20 +483,6 @@ export default function MessageInput({
close={() => setUploadModalState('INACTIVE')}
/>
)}
{handleDragActive &&
createPortal(
<div className="dark:bg-gray-alpha/50 pointer-events-none fixed top-0 left-0 z-50 flex size-full flex-col items-center justify-center bg-white/85">
<img className="filter dark:invert" src={DragFileUpload} />
<span className="text-outer-space dark:text-silver px-2 text-2xl font-bold">
{t('modals.uploadDoc.drag.title')}
</span>
<span className="text-s text-outer-space dark:text-silver w-48 p-2 text-center">
{t('modals.uploadDoc.drag.description')}
</span>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { useTranslation } from 'react-i18next';
import close from '../assets/cross.svg';
import rightArrow from '../assets/arrow-full-right.svg';
import bg from '../assets/notification-bg.jpg';
@@ -14,14 +13,13 @@ export default function Notification({
notificationLink,
handleCloseNotification,
}: NotificationProps) {
const { t } = useTranslation();
return (
<a
className="absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 rounded-lg bg-cover bg-center bg-no-repeat px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{ backgroundImage: `url(${bg})` }}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
aria-label="Notification"
rel="noreferrer"
>
<p className="text-white-3000 text-xs leading-6 font-semibold xl:text-sm xl:leading-7">
@@ -33,7 +31,7 @@ export default function Notification({
<button
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
aria-label="Close notification"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();

View File

@@ -24,7 +24,6 @@ interface SettingsBarProps {
}
const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
const { t } = useTranslation();
const [hiddenGradient, setHiddenGradient] =
useState<HiddenGradientType>('left');
const containerRef = useRef<null | HTMLDivElement>(null);
@@ -61,7 +60,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
<button
onClick={() => scrollTabs(-1)}
className="flex h-6 w-6 items-center justify-center rounded-full transition-all hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label={t('settings.scrollTabsLeft')}
aria-label="Scroll tabs left"
>
<img src={ArrowLeft} alt="left-arrow" className="h-3" />
</button>
@@ -70,7 +69,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
ref={containerRef}
className="no-scrollbar flex snap-x flex-nowrap overflow-x-auto scroll-smooth md:space-x-4"
role="tablist"
aria-label={t('settings.tabsAriaLabel')}
aria-label="Settings tabs"
>
{tabs.map((tab, index) => (
<button
@@ -94,7 +93,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
<button
onClick={() => scrollTabs(1)}
className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"
aria-label={t('settings.scrollTabsRight')}
aria-label="Scroll tabs right"
>
<img src={ArrowRight} alt="right-arrow" className="h-3" />
</button>

View File

@@ -0,0 +1,175 @@
import { useTranslation } from 'react-i18next';
import ConnectorAuth from './ConnectorAuth';
import { useEffect, useState } from 'react';
import {
getSessionToken,
setSessionToken,
removeSessionToken,
validateProviderSession,
} from '../utils/providerUtils';
import ConnectedStateSkeleton from './ConnectedStateSkeleton';
import FilesSectionSkeleton from './FileSelectionSkeleton';
interface SharePointPickerProps {
token: string | null;
}
const SharePointPicker: React.FC<SharePointPickerProps> = ({ token }) => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [isConnected, setIsConnected] = useState(false);
const [authError, setAuthError] = useState<string>('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
const sessionToken = getSessionToken('share_point');
if (sessionToken) {
setIsValidating(true);
setIsConnected(true); // Optimistically set as connected for skeleton
validateSession(sessionToken);
}
}, [token]);
const validateSession = async (sessionToken: string) => {
try {
const validateResponse = await validateProviderSession(
token,
'share_point',
);
if (!validateResponse.ok) {
setIsConnected(false);
setAuthError(
t('modals.uploadDoc.connectors.sharePoint.sessionExpired'),
);
setIsValidating(false);
return false;
}
const validateData = await validateResponse.json();
if (validateData.success) {
setUserEmail(
validateData.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
setAccessToken(validateData.access_token || null);
setIsValidating(false);
return true;
} else {
setIsConnected(false);
setAuthError(
validateData.error ||
t('modals.uploadDoc.connectors.sharePoint.sessionExpiredGeneric'),
);
setIsValidating(false);
return false;
}
} catch (error) {
console.error('Error validating session:', error);
setAuthError(t('modals.uploadDoc.connectors.sharePoint.validateFailed'));
setIsConnected(false);
setIsValidating(false);
return false;
}
};
const handleDisconnect = async () => {
const sessionToken = getSessionToken('share_point');
if (sessionToken) {
try {
const apiHost = import.meta.env.VITE_API_HOST;
await fetch(`${apiHost}/api/connectors/disconnect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
provider: 'share_point',
session_token: sessionToken,
}),
});
} catch (err) {
console.error('Error disconnecting from SharePoint:', err);
}
}
removeSessionToken('share_point');
setIsConnected(false);
setAccessToken(null);
setUserEmail('');
setAuthError('');
};
const handleOpenPicker = async () => {
alert('Feature not supported yet.');
};
return (
<div>
{isValidating ? (
<>
<ConnectedStateSkeleton />
<FilesSectionSkeleton />
</>
) : (
<>
<ConnectorAuth
provider="share_point"
label={t('modals.uploadDoc.connectors.sharePoint.connect')}
onSuccess={(data) => {
setUserEmail(
data.user_email ||
t('modals.uploadDoc.connectors.auth.connectedUser'),
);
setIsConnected(true);
setAuthError('');
if (data.session_token) {
setSessionToken('share_point', data.session_token);
validateSession(data.session_token);
}
}}
onError={(error) => {
setAuthError(error);
setIsConnected(false);
}}
isConnected={isConnected}
userEmail={userEmail}
onDisconnect={handleDisconnect}
errorMessage={authError}
/>
{isConnected && (
<div className="rounded-lg border border-[#EEE6FF78] dark:border-[#6A6A6A]">
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-medium">
{t('modals.uploadDoc.connectors.sharePoint.selectedFiles')}
</h3>
<button
onClick={() => handleOpenPicker()}
className="rounded-md bg-[#A076F6] px-3 py-1 text-sm text-white hover:bg-[#8A5FD4]"
disabled={isLoading}
>
{isLoading
? t('modals.uploadDoc.connectors.sharePoint.loading')
: t('modals.uploadDoc.connectors.sharePoint.selectFiles')}
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
);
};
export default SharePointPicker;

View File

@@ -33,7 +33,7 @@ export default function Sidebar({
return (
<div ref={sidebarRef} className="h-vh relative">
<div
className={`dark:bg-chinese-black fixed top-0 right-0 z-50 h-full w-64 transform bg-white shadow-xl transition-all duration-300 sm:w-80 ${
className={`dark:bg-chinese-black fixed top-0 right-0 z-50 h-full w-72 transform bg-white shadow-xl transition-all duration-300 sm:w-96 ${
isOpen ? 'translate-x-[10px]' : 'translate-x-full'
} border-l border-[#9ca3af]/10`}
>

View File

@@ -1,5 +1,4 @@
import React, { useRef, useEffect, useState, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Doc } from '../models/misc';
@@ -108,7 +107,7 @@ export default function SourcesPopup({
onClose();
};
const popupContent = (
return (
<div
ref={popupRef}
className="bg-lotion dark:bg-charleston-green-2 fixed z-50 flex flex-col rounded-xl shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
@@ -172,7 +171,11 @@ export default function SourcesPopup({
: doc.date !== option.date,
)
: [];
dispatch(setSelectedDocs(updatedDocs));
dispatch(
setSelectedDocs(
updatedDocs.length > 0 ? updatedDocs : null,
),
);
handlePostDocumentSelect(
updatedDocs.length > 0 ? updatedDocs : null,
);
@@ -215,7 +218,7 @@ export default function SourcesPopup({
</>
) : (
<div className="dark:text-bright-gray p-4 text-center text-gray-500 dark:text-[14px]">
{t('conversation.sources.noSourcesAvailable')}
{t('noSourcesAvailable')}
</div>
)}
</div>
@@ -242,6 +245,4 @@ export default function SourcesPopup({
</div>
</div>
);
return createPortal(popupContent, document.body);
}

View File

@@ -1,202 +1,94 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef } from 'react';
import Speaker from '../assets/speaker.svg?react';
import Stopspeech from '../assets/stopspeech.svg?react';
import LoadingIcon from '../assets/Loading.svg?react'; // Add a loading icon SVG here
const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
let currentlyPlayingAudio: {
audio: HTMLAudioElement;
stopCallback: () => void;
} | null = null;
let currentLoadingRequest: {
abortController: AbortController;
stopLoadingCallback: () => void;
} | null = null;
// LRU Cache for audio
const audioCache = new Map<string, string>();
const MAX_CACHE_SIZE = 10;
function getCachedAudio(text: string): string | undefined {
const cached = audioCache.get(text);
if (cached) {
audioCache.delete(text);
audioCache.set(text, cached);
}
return cached;
}
function setCachedAudio(text: string, audioBase64: string) {
if (audioCache.has(text)) {
audioCache.delete(text);
}
if (audioCache.size >= MAX_CACHE_SIZE) {
const firstKey = audioCache.keys().next().value;
if (firstKey !== undefined) {
audioCache.delete(firstKey);
}
}
audioCache.set(text, audioBase64);
}
export default function SpeakButton({ text }: { text: string }) {
export default function SpeakButton({
text,
colorLight,
colorDark,
}: {
text: string;
colorLight?: string;
colorDark?: string;
}) {
const [isSpeaking, setIsSpeaking] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSpeakHovered, setIsSpeakHovered] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
// Abort any pending fetch request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Stop any playing audio
if (audioRef.current) {
audioRef.current.pause();
if (currentlyPlayingAudio?.audio === audioRef.current) {
currentlyPlayingAudio = null;
}
audioRef.current = null;
}
// Clear global loading request if it's this component's
if (currentLoadingRequest) {
currentLoadingRequest = null;
}
};
}, []);
const handleSpeakClick = async () => {
if (isSpeaking) {
// Stop audio if it's currently playing
audioRef.current?.pause();
audioRef.current = null;
currentlyPlayingAudio = null;
setIsSpeaking(false);
return;
}
// Stop any currently playing audio
if (currentlyPlayingAudio) {
currentlyPlayingAudio.audio.pause();
currentlyPlayingAudio.stopCallback();
currentlyPlayingAudio = null;
}
// Abort any pending loading request
if (currentLoadingRequest) {
currentLoadingRequest.abortController.abort();
currentLoadingRequest.stopLoadingCallback();
currentLoadingRequest = null;
}
try {
// Set loading state and initiate TTS request
setIsLoading(true);
const cachedAudio = getCachedAudio(text);
let audioBase64: string;
if (cachedAudio) {
audioBase64 = cachedAudio;
setIsLoading(false);
} else {
const abortController = new AbortController();
abortControllerRef.current = abortController;
currentLoadingRequest = {
abortController,
stopLoadingCallback: () => {
setIsLoading(false);
},
};
const response = await fetch(apiHost + '/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
signal: abortController.signal,
});
const data = await response.json();
abortControllerRef.current = null;
currentLoadingRequest = null;
if (data.success && data.audio_base64) {
audioBase64 = data.audio_base64;
// Store in cache
setCachedAudio(text, audioBase64);
setIsLoading(false);
} else {
console.error('Failed to retrieve audio.');
setIsLoading(false);
return;
}
}
const audio = new Audio(`data:audio/mp3;base64,${audioBase64}`);
audioRef.current = audio;
currentlyPlayingAudio = {
audio,
stopCallback: () => {
setIsSpeaking(false);
audioRef.current = null;
},
};
audio.play().then(() => {
setIsSpeaking(true);
setIsLoading(false);
audio.onended = () => {
setIsSpeaking(false);
audioRef.current = null;
if (currentlyPlayingAudio?.audio === audio) {
currentlyPlayingAudio = null;
}
};
const response = await fetch(apiHost + '/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
} catch (error: any) {
abortControllerRef.current = null;
currentLoadingRequest = null;
if (error.name === 'AbortError') {
return;
const data = await response.json();
if (data.success && data.audio_base64) {
// Create and play the audio
const audio = new Audio(`data:audio/mp3;base64,${data.audio_base64}`);
audioRef.current = audio;
audio.play().then(() => {
setIsSpeaking(true);
setIsLoading(false);
// Reset when audio ends
audio.onended = () => {
setIsSpeaking(false);
audioRef.current = null;
};
});
} else {
console.error('Failed to retrieve audio.');
setIsLoading(false);
}
} catch (error) {
console.error('Error fetching audio from TTS endpoint', error);
setIsLoading(false);
}
};
return (
<button
type="button"
className={`flex cursor-pointer items-center justify-center rounded-full p-2 ${
isSpeaking || isLoading
? 'dark:bg-purple-taupe bg-[#EEEEEE]'
: 'bg-white-3000 dark:hover:bg-purple-taupe hover:bg-[#EEEEEE] dark:bg-transparent'
<div
className={`flex items-center justify-center rounded-full p-2 ${
isSpeakHovered
? `dark:bg-purple-taupe bg-[#EEEEEE]`
: `bg-[${colorLight ? colorLight : '#FFFFFF'}] dark:bg-[${colorDark ? colorDark : 'transparent'}]`
}`}
onClick={handleSpeakClick}
aria-label={
isLoading
? 'Loading audio'
: isSpeaking
? 'Stop speaking'
: 'Speak text'
}
disabled={isLoading}
>
{isLoading ? (
<LoadingIcon className="animate-spin" />
) : isSpeaking ? (
<Stopspeech className="fill-none" />
<Stopspeech
className="cursor-pointer fill-none"
onClick={handleSpeakClick}
onMouseEnter={() => setIsSpeakHovered(true)}
onMouseLeave={() => setIsSpeakHovered(false)}
/>
) : (
<Speaker className="fill-none" />
<Speaker
className="cursor-pointer fill-none"
onClick={handleSpeakClick}
onMouseEnter={() => setIsSpeakHovered(true)}
onMouseLeave={() => setIsSpeakHovered(false)}
/>
)}
</button>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { selectToken } from '../preferences/preferenceSlice';
@@ -134,10 +133,10 @@ export default function ToolsPopup({
tool.displayName.toLowerCase().includes(searchTerm.toLowerCase()),
);
const popupContent = (
return (
<div
ref={popupRef}
className="border-light-silver bg-lotion dark:border-dim-gray dark:bg-charleston-green-2 fixed z-50 rounded-lg border shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
className="border-light-silver bg-lotion dark:border-dim-gray dark:bg-charleston-green-2 fixed z-9999 rounded-lg border shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
style={{
top: popupPosition.showAbove ? popupPosition.top : undefined,
bottom: popupPosition.showAbove
@@ -243,6 +242,4 @@ export default function ToolsPopup({
</div>
</div>
);
return createPortal(popupContent, document.body);
}

View File

@@ -44,10 +44,7 @@ export default function UploadToast() {
};
return (
<div
className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2">
{uploadTasks
.filter((task) => !task.dismissed)
.map((task) => {

View File

@@ -23,7 +23,6 @@ export type InputProps = {
e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
) => void;
leftIcon?: React.ReactNode;
edgeRoundness?: string;
};
export type MermaidRendererProps = {

View File

@@ -1,16 +1,20 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import SharedAgentCard from '../agents/SharedAgentCard';
import DragFileUpload from '../assets/DragFileUpload.svg';
import MessageInput from '../components/MessageInput';
import { useMediaQuery } from '../hooks';
import { ActiveState } from '../models/misc';
import {
selectConversationId,
selectSelectedAgent,
selectToken,
} from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
import Upload from '../upload/Upload';
import { handleSendFeedback } from './conversationHandlers';
import ConversationMessages from './ConversationMessages';
import { FEEDBACK, Query } from './conversationModels';
@@ -41,12 +45,53 @@ export default function Conversation() {
const selectedAgent = useSelector(selectSelectedAgent);
const completedAttachments = useSelector(selectCompletedAttachments);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [files, setFiles] = useState<File[]>([]);
const [lastQueryReturnedErr, setLastQueryReturnedErr] =
useState<boolean>(false);
const [isShareModalOpen, setShareModalState] = useState<boolean>(false);
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
const fetchStream = useRef<any>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
setUploadModalState('ACTIVE');
setFiles(acceptedFiles);
setHandleDragActive(false);
}, []);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
noClick: true,
multiple: true,
onDragEnter: () => {
setHandleDragActive(true);
},
onDragLeave: () => {
setHandleDragActive(false);
},
maxSize: 25000000,
accept: {
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
'text/x-rst': ['.rst'],
'text/x-markdown': ['.md'],
'application/zip': ['.zip'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
['.docx'],
'application/json': ['.json'],
'text/csv': ['.csv'],
'text/html': ['.html'],
'application/epub+zip': ['.epub'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
'.xlsx',
],
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
['.pptx'],
},
});
const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
@@ -130,7 +175,7 @@ export default function Conversation() {
}),
);
handleQuestion({
question: question,
question: queries[queries.length - 1].prompt,
isRetry: true,
});
} else {
@@ -177,7 +222,14 @@ export default function Conversation() {
/>
<div className="bg-opacity-0 z-3 flex h-auto w-full max-w-[1300px] flex-col items-end self-center rounded-2xl py-1 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="flex w-full items-center rounded-[40px] px-2">
<div
{...getRootProps()}
className="flex w-full items-center rounded-[40px]"
>
<label htmlFor="file-upload" className="sr-only">
{t('modals.uploadDoc.label')}
</label>
<input {...getInputProps()} id="file-upload" />
<MessageInput
onSubmit={(text) => {
handleQuestionSubmission(text);
@@ -192,6 +244,26 @@ export default function Conversation() {
{t('tagline')}
</p>
</div>
{handleDragActive && (
<div className="bg-opacity-50 dark:bg-gray-alpha pointer-events-none fixed top-0 left-0 z-30 flex size-full flex-col items-center justify-center bg-white">
<img className="filter dark:invert" src={DragFileUpload} />
<span className="text-outer-space dark:text-silver px-2 text-2xl font-bold">
{t('modals.uploadDoc.drag.title')}
</span>
<span className="text-s text-outer-space dark:text-silver w-48 p-2 text-center">
{t('modals.uploadDoc.drag.description')}
</span>
</div>
)}
{uploadModalState === 'ACTIVE' && (
<Upload
receivedFile={files}
setModalState={setUploadModalState}
isOnboarding={false}
renderTab={'file'}
close={() => setUploadModalState('INACTIVE')}
></Upload>
)}
</div>
);
}

View File

@@ -3,9 +3,9 @@
}
.list li:not(:first-child) {
margin-top: 0.5em;
margin-top: 1em;
}
.list li > .list {
margin-top: 0.5em;
margin-top: 1em;
}

View File

@@ -86,7 +86,10 @@ const ConversationBubble = forwardRef<
// const bubbleRef = useRef<HTMLDivElement | null>(null);
const chunks = useSelector(selectChunks);
const selectedDocs = useSelector(selectSelectedDocs);
const [isLikeHovered, setIsLikeHovered] = useState(false);
const [isEditClicked, setIsEditClicked] = useState(false);
const [isDislikeHovered, setIsDislikeHovered] = useState(false);
const [isQuestionHovered, setIsQuestionHovered] = useState(false);
const [editInputBox, setEditInputBox] = useState<string>('');
const messageRef = useRef<HTMLDivElement>(null);
const [shouldShowToggle, setShouldShowToggle] = useState(false);
@@ -112,7 +115,11 @@ const ConversationBubble = forwardRef<
let bubble;
if (type === 'QUESTION') {
bubble = (
<div className={`group ${className}`}>
<div
onMouseEnter={() => setIsQuestionHovered(true)}
onMouseLeave={() => setIsQuestionHovered(false)}
className={className}
>
<div className="flex flex-col items-end">
{filesAttached && filesAttached.length > 0 && (
<div className="mr-12 mb-4 flex flex-wrap justify-end gap-2">
@@ -181,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 pt-1.5 pl-1.5 dark:hover:bg-[#35363B] ${isEditClicked ? 'visible' : 'invisible group-hover:visible'}`}
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>
@@ -414,7 +421,7 @@ const ConversationBubble = forwardRef<
<Fragment key={index}>
{segment.type === 'text' ? (
<ReactMarkdown
className="fade-in flex flex-col gap-3 leading-normal break-words whitespace-pre-wrap"
className="fade-in leading-normal break-words whitespace-pre-wrap"
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
@@ -560,47 +567,53 @@ const ConversationBubble = forwardRef<
{handleFeedback && (
<>
<div className="relative mr-2 flex items-center justify-center">
<button
type="button"
className="bg-white-3000 dark:hover:bg-purple-taupe flex cursor-pointer items-center justify-center rounded-full p-2 hover:bg-[#EEEEEE] dark:bg-transparent"
onClick={() => {
if (feedback === 'LIKE') {
handleFeedback?.(null);
} else {
handleFeedback?.('LIKE');
}
}}
aria-label={
feedback === 'LIKE' ? 'Remove like' : 'Like'
}
>
<Like
className={`${feedback === 'LIKE' ? 'fill-white-3000 stroke-purple-30 dark:fill-transparent' : 'stroke-gray-4000 fill-none'}`}
></Like>
</button>
<div>
<div
className={`flex items-center justify-center rounded-full p-2 ${
isLikeHovered
? 'dark:bg-purple-taupe bg-[#EEEEEE]'
: 'bg-white-3000 dark:bg-transparent'
}`}
>
<Like
className={`${feedback === 'LIKE' ? 'fill-white-3000 stroke-purple-30 dark:fill-transparent' : 'stroke-gray-4000 fill-none'} cursor-pointer`}
onClick={() => {
if (feedback === 'LIKE') {
handleFeedback?.(null);
} else {
handleFeedback?.('LIKE');
}
}}
onMouseEnter={() => setIsLikeHovered(true)}
onMouseLeave={() => setIsLikeHovered(false)}
></Like>
</div>
</div>
</div>
<div className="relative mr-2 flex items-center justify-center">
<button
type="button"
className="bg-white-3000 dark:hover:bg-purple-taupe flex cursor-pointer items-center justify-center rounded-full p-2 hover:bg-[#EEEEEE] dark:bg-transparent"
onClick={() => {
if (feedback === 'DISLIKE') {
handleFeedback?.(null);
} else {
handleFeedback?.('DISLIKE');
}
}}
aria-label={
feedback === 'DISLIKE'
? 'Remove dislike'
: 'Dislike'
}
>
<Dislike
className={`${feedback === 'DISLIKE' ? 'fill-white-3000 stroke-red-2000 dark:fill-transparent' : 'stroke-gray-4000 fill-none'}`}
></Dislike>
</button>
<div>
<div
className={`flex items-center justify-center rounded-full p-2 ${
isDislikeHovered
? 'dark:bg-purple-taupe bg-[#EEEEEE]'
: 'bg-white-3000 dark:bg-transparent'
}`}
>
<Dislike
className={`${feedback === 'DISLIKE' ? 'fill-white-3000 stroke-red-2000 dark:fill-transparent' : 'stroke-gray-4000 fill-none'} cursor-pointer`}
onClick={() => {
if (feedback === 'DISLIKE') {
handleFeedback?.(null);
} else {
handleFeedback?.('DISLIKE');
}
}}
onMouseEnter={() => setIsDislikeHovered(true)}
onMouseLeave={() => setIsDislikeHovered(false)}
></Dislike>
</div>
</div>
</div>
</>
)}
@@ -645,7 +658,7 @@ function AllSources(sources: AllSourcesProps) {
<p className="text-left text-xl">{`${sources.sources.length} ${t('conversation.sources.title')}`}</p>
<div className="mx-1 mt-2 h-[0.8px] w-full rounded-full bg-[#C4C4C4]/40 lg:w-[95%]"></div>
</div>
<div className="scrollbar-thin mt-6 flex h-[90%] w-52 flex-col gap-4 overflow-y-auto pr-3 sm:w-64">
<div className="mt-6 flex h-[90%] w-60 flex-col items-center gap-4 overflow-y-auto sm:w-80">
{sources.sources.map((source, index) => {
const isExternalSource = source.link && source.link !== 'local';
return (
@@ -803,7 +816,6 @@ function Thought({
thought: string;
preprocessLaTeX: (content: string) => string;
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const [isThoughtOpen, setIsThoughtOpen] = useState(true);
@@ -824,9 +836,7 @@ function Thought({
className="flex flex-row items-center gap-2"
onClick={() => setIsThoughtOpen(!isThoughtOpen)}
>
<p className="text-base font-semibold">
{t('conversation.reasoning')}
</p>
<p className="text-base font-semibold">Reasoning</p>
<img
src={ChevronDown}
alt="ChevronDown"

View File

@@ -7,7 +7,6 @@ import {
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ArrowDown from '../assets/arrow-down.svg';
import RetryIcon from '../components/RetryIcon';
@@ -15,7 +14,6 @@ import Hero from '../Hero';
import { useDarkTheme } from '../hooks';
import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels';
import { selectConversationId } from '../preferences/preferenceSlice';
const SCROLL_THRESHOLD = 10;
const LAST_BUBBLE_MARGIN = 'mb-32';
@@ -52,7 +50,6 @@ export default function ConversationMessages({
}: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation();
const conversationId = useSelector(selectConversationId);
const conversationRef = useRef<HTMLDivElement>(null);
const [hasScrolledToLast, setHasScrolledToLast] = useState(true);
@@ -90,20 +87,15 @@ export default function ConversationMessages({
setHasScrolledToLast(isAtBottom);
}, [setHasScrolledToLast]);
const lastQuery = queries[queries.length - 1];
const lastQueryResponse = lastQuery?.response;
const lastQueryError = lastQuery?.error;
const lastQueryThought = lastQuery?.thought;
useEffect(() => {
if (!userInterruptedScroll) {
scrollConversationToBottom();
}
}, [
queries.length,
lastQueryResponse,
lastQueryError,
lastQueryThought,
queries[queries.length - 1]?.response,
queries[queries.length - 1]?.error,
queries[queries.length - 1]?.thought,
userInterruptedScroll,
scrollConversationToBottom,
]);
@@ -145,7 +137,7 @@ export default function ConversationMessages({
return (
<ConversationBubble
className={bubbleMargin}
key={`${conversationId}-${index}-ANSWER`}
key={`${index}-ANSWER`}
message={query.response}
type={'ANSWER'}
thought={query.thought}
@@ -183,7 +175,7 @@ export default function ConversationMessages({
return (
<ConversationBubble
className={bubbleMargin}
key={`${conversationId}-${index}-ERROR`}
key={`${index}-ERROR`}
message={query.error}
type="ERROR"
retryBtn={retryButton}
@@ -222,10 +214,10 @@ export default function ConversationMessages({
{queries.length > 0 ? (
queries.map((query, index) => (
<Fragment key={`${conversationId}-${index}-query-fragment`}>
<Fragment key={`${index}-query-fragment`}>
<ConversationBubble
className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${conversationId}-${index}-QUESTION`}
key={`${index}-QUESTION`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}

View File

@@ -161,16 +161,14 @@ export const SharedConversation = () => {
/>
<div className="flex w-full max-w-[1200px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
{apiKey ? (
<div className="w-full px-2">
<MessageInput
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
loading={status === 'loading'}
showSourceButton={false}
showToolButton={false}
/>
</div>
<MessageInput
onSubmit={(text) => {
handleQuestionSubmission(text);
}}
loading={status === 'loading'}
showSourceButton={false}
showToolButton={false}
/>
) : (
<button
onClick={() => navigate('/')}

View File

@@ -56,7 +56,7 @@ export const fetchAnswer = createAsyncThunk<
question,
signal,
state.preference.token,
state.preference.selectedDocs || [],
state.preference.selectedDocs!,
currentConversationId,
state.preference.prompt.id,
state.preference.chunks,
@@ -163,7 +163,7 @@ export const fetchAnswer = createAsyncThunk<
question,
signal,
state.preference.token,
state.preference.selectedDocs || [],
state.preference.selectedDocs!,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
@@ -370,10 +370,7 @@ export const conversationSlice = createSlice({
return state;
}
state.status = 'failed';
if (state.queries.length > 0) {
state.queries[state.queries.length - 1].error =
'Something went wrong';
}
state.queries[state.queries.length - 1].error = 'Something went wrong';
});
},
});

View File

@@ -118,34 +118,18 @@ layer(base);
background: transparent;
}
/* Light theme scrollbar */
&::-webkit-scrollbar-thumb {
background: rgba(215, 215, 215, 1);
background: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(195, 195, 195, 1);
background: rgba(156, 163, 175, 0.7);
}
/* Dark theme scrollbar */
.dark &::-webkit-scrollbar-thumb {
background: rgba(77, 78, 88, 1);
border-radius: 3px;
}
.dark &::-webkit-scrollbar-thumb:hover {
background: rgba(97, 98, 108, 1);
}
/* For Firefox - Light theme */
/* For Firefox */
scrollbar-width: thin;
scrollbar-color: rgba(215, 215, 215, 1) transparent;
/* For Firefox - Dark theme */
.dark & {
scrollbar-color: rgba(77, 78, 88, 1) transparent;
}
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
@utility table-default {
@@ -225,16 +209,6 @@ layer(base);
}
@layer base {
.prompt-variable-highlight {
background-color: rgba(106, 77, 244, 0.18);
border-radius: 0.375rem;
padding: 0 0.25rem;
}
.dark .prompt-variable-highlight {
background-color: rgba(106, 77, 244, 0.32);
}
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document

View File

@@ -201,15 +201,6 @@
"noAuth": "No Authentication",
"oauthInProgress": "Waiting for OAuth completion...",
"oauthCompleted": "OAuth completed successfully",
"authType": "Authentication Type",
"defaultServerName": "My MCP Server",
"authTypes": {
"none": "No Authentication",
"apiKey": "API Key",
"bearer": "Bearer Token",
"oauth": "OAuth",
"basic": "Basic Authentication"
},
"placeholders": {
"serverUrl": "https://api.example.com",
"apiKey": "Your secret API key",
@@ -229,14 +220,10 @@
"testFailed": "Connection test failed",
"saveFailed": "Failed to save MCP server",
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
"oauthTimeout": "OAuth process timed out, please try again"
}
}
},
"scrollTabsLeft": "Scroll tabs left",
"tabsAriaLabel": "Settings tabs",
"scrollTabsRight": "Scroll tabs right"
}
},
"modals": {
"uploadDoc": {
@@ -268,8 +255,8 @@
"addQuery": "Add Query"
},
"drag": {
"title": "Drop attachments here",
"description": "Release to upload your attachments"
"title": "Upload a source file",
"description": "Drop your file here to add it as a source"
},
"progress": {
"upload": "Upload is in progress",
@@ -311,6 +298,10 @@
"google_drive": {
"label": "Google Drive",
"heading": "Upload from Google Drive"
},
"share_point": {
"label": "SharePoint",
"heading": "Upload from SharePoint"
}
},
"connectors": {
@@ -340,6 +331,24 @@
"remove": "Remove",
"folderAlt": "Folder",
"fileAlt": "File"
},
"sharePoint": {
"connect": "Connect to SharePoint",
"sessionExpired": "Session expired. Please reconnect to SharePoint.",
"sessionExpiredGeneric": "Session expired. Please reconnect your account.",
"validateFailed": "Failed to validate session. Please reconnect.",
"noSession": "No valid session found. Please reconnect to SharePoint.",
"noAccessToken": "No access token available. Please reconnect to SharePoint.",
"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"
}
}
},
@@ -356,8 +365,7 @@
"disclaimer": "This is the only time your key will be shown.",
"copy": "Copy",
"copied": "Copied",
"confirm": "I saved the Key",
"apiKeyLabel": "API Key"
"confirm": "I saved the Key"
},
"deleteConv": {
"confirm": "Are you sure you want to delete all the conversations?",
@@ -375,8 +383,7 @@
"apiKeyLabel": "API Key / OAuth",
"apiKeyPlaceholder": "Enter API Key / OAuth",
"addButton": "Add Tool",
"closeButton": "Close",
"customNamePlaceholder": "Enter custom name (optional)"
"closeButton": "Close"
},
"prompts": {
"addPrompt": "Add Prompt",
@@ -386,32 +393,8 @@
"promptName": "Prompt Name",
"promptText": "Prompt Text",
"save": "Save",
"cancel": "Cancel",
"nameExists": "Name already exists",
"deleteConfirmation": "Are you sure you want to delete the prompt '{{name}}'?",
"placeholderText": "Type your prompt text here...",
"addExamplePlaceholder": "Please summarize this text:",
"variablesLabel": "Variables",
"variablesSubtext": "Click To Insert Into Prompt",
"variablesDescription": "Click to insert into prompt",
"systemVariables": "Click to insert into prompt",
"toolVariables": "Tool Variables",
"systemVariablesDropdownLabel": "System Variables",
"systemVariableOptions": {
"sourceContent": "Sources content",
"sourceSummaries": "Alias for content (backward compatible)",
"sourceDocuments": "Document objects list",
"sourceCount": "Number of retrieved documents",
"systemDate": "Current date (YYYY-MM-DD)",
"systemTime": "Current time (HH:MM:SS)",
"systemTimestamp": "ISO 8601 timestamp",
"systemRequestId": "Unique request identifier",
"systemUserId": "Current user ID"
},
"learnAboutPrompts": "Learn about Prompts →",
"publicPromptEditDisabled": "Public prompts cannot be edited",
"promptTypePublic": "public",
"promptTypePrivate": "private"
"deleteConfirmation": "Are you sure you want to delete the prompt '{{name}}'?"
},
"chunk": {
"add": "Add Chunk",
@@ -425,22 +408,6 @@
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirmation": "Are you sure you want to delete this chunk?"
},
"addAction": {
"title": "New Action",
"actionNamePlaceholder": "Action Name",
"invalidFormat": "Invalid function name format. Use only letters, numbers, underscores, and hyphens.",
"formatHelp": "Use only letters, numbers, underscores, and hyphens (e.g., `get_data`, `send_report`, etc.)",
"addButton": "Add"
},
"agentDetails": {
"title": "Access Details",
"publicLink": "Public Link",
"apiKey": "API Key",
"webhookUrl": "Webhook URL",
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
}
},
"sharedConv": {
@@ -476,160 +443,12 @@
"title": "Sources",
"text": "Choose Your Sources",
"link": "Source link",
"view_more": "{{count}} more sources",
"noSourcesAvailable": "No sources available"
"view_more": "{{count}} more sources"
},
"attachments": {
"attach": "Attach",
"remove": "Remove attachment"
},
"retry": "Retry",
"reasoning": "Reasoning"
},
"agents": {
"title": "Agents",
"description": "Discover and create custom versions of DocsGPT that combine instructions, extra knowledge, and any combination of skills",
"newAgent": "New Agent",
"backToAll": "Back to all agents",
"sections": {
"template": {
"title": "By DocsGPT",
"description": "Agents provided by DocsGPT",
"emptyState": "No template agents found."
},
"user": {
"title": "By me",
"description": "Agents created or published by you",
"emptyState": "You don't have any created agents yet."
},
"shared": {
"title": "Shared with me",
"description": "Agents imported by using a public link",
"emptyState": "No shared agents found."
}
},
"form": {
"headings": {
"new": "New Agent",
"edit": "Edit Agent",
"draft": "New Agent (Draft)"
},
"buttons": {
"publish": "Publish",
"save": "Save",
"saveDraft": "Save Draft",
"cancel": "Cancel",
"delete": "Delete",
"logs": "Logs",
"accessDetails": "Access Details",
"add": "Add"
},
"sections": {
"meta": "Meta",
"source": "Source",
"prompt": "Prompt",
"tools": "Tools",
"agentType": "Agent type",
"advanced": "Advanced",
"preview": "Preview"
},
"placeholders": {
"agentName": "Agent name",
"describeAgent": "Describe your agent",
"selectSources": "Select sources",
"chunksPerQuery": "Chunks per query",
"selectType": "Select type",
"selectTools": "Select tools",
"enterTokenLimit": "Enter token limit",
"enterRequestLimit": "Enter request limit"
},
"sourcePopup": {
"title": "Select Sources",
"searchPlaceholder": "Search sources...",
"noOptionsMessage": "No sources available"
},
"toolsPopup": {
"title": "Select Tools",
"searchPlaceholder": "Search tools...",
"noOptionsMessage": "No tools available"
},
"upload": {
"clickToUpload": "Click to upload",
"dragAndDrop": " or drag and drop"
},
"agentTypes": {
"classic": "Classic",
"react": "ReAct"
},
"advanced": {
"jsonSchema": "JSON response schema",
"jsonSchemaDescription": "Define a JSON schema to enforce structured output format",
"validJson": "Valid JSON",
"invalidJson": "Invalid JSON - fix to enable saving",
"tokenLimiting": "Token limiting",
"tokenLimitingDescription": "Limit daily total tokens that can be used by this agent",
"requestLimiting": "Request limiting",
"requestLimitingDescription": "Limit daily total requests that can be made to this agent"
},
"preview": {
"publishedPreview": "Published agents can be previewed here"
},
"externalKb": "External KB"
},
"logs": {
"title": "Agent Logs",
"lastUsedAt": "Last used at",
"noUsageHistory": "No usage history",
"tableHeader": "Agent endpoint logs"
},
"shared": {
"notFound": "No agent found. Please ensure the agent is shared."
},
"preview": {
"testMessage": "Test your agent here. Published agents can be used in conversations."
},
"deleteConfirmation": "Are you sure you want to delete this agent?"
},
"components": {
"fileUpload": {
"clickToUpload": "Click to upload or drag and drop",
"dropFiles": "Drop the files here",
"fileTypes": "PNG, JPG, JPEG up to",
"sizeLimitUnit": "MB",
"fileSizeError": "File exceeds {{size}}MB limit"
}
},
"pageNotFound": {
"title": "404",
"message": "The page you are looking for does not exist.",
"goHome": "Go Back Home"
},
"filePicker": {
"searchPlaceholder": "Search files and folders...",
"itemsSelected": "{{count}} selected",
"name": "Name",
"lastModified": "Last Modified",
"size": "Size"
},
"actionButtons": {
"openNewChat": "Open New Chat",
"share": "Share"
},
"mermaid": {
"downloadOptions": "Download options",
"viewCode": "View Code",
"decreaseZoom": "Decrease zoom",
"resetZoom": "Reset zoom",
"increaseZoom": "Increase zoom"
},
"navigation": {
"agents": "Agents"
},
"notification": {
"ariaLabel": "Notification",
"closeAriaLabel": "Close notification"
},
"prompts": {
"textAriaLabel": "Prompt Text"
"retry": "Retry"
}
}

View File

@@ -185,58 +185,8 @@
"fieldDescription": "Descripción del campo",
"add": "Añadir",
"cancel": "Cancelar",
"addNew": "Añadir Nuevo",
"mcp": {
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
"timeout": "Timeout (seconds)",
"testConnection": "Test Connection",
"testing": "Testing",
"saving": "Saving",
"save": "Save",
"cancel": "Cancel",
"noAuth": "No Authentication",
"oauthInProgress": "Waiting for OAuth completion...",
"oauthCompleted": "OAuth completed successfully",
"authType": "Authentication Type",
"defaultServerName": "My MCP Server",
"authTypes": {
"none": "No Authentication",
"apiKey": "API Key",
"bearer": "Bearer Token",
"oauth": "OAuth",
"basic": "Basic Authentication"
},
"placeholders": {
"serverUrl": "https://api.example.com",
"apiKey": "Your secret API key",
"bearerToken": "Your secret token",
"username": "Your username",
"password": "Your password",
"oauthScopes": "OAuth scopes (comma separated)"
},
"errors": {
"nameRequired": "Server name is required",
"urlRequired": "Server URL is required",
"invalidUrl": "Please enter a valid URL",
"apiKeyRequired": "API key is required",
"tokenRequired": "Bearer token is required",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"testFailed": "Connection test failed",
"saveFailed": "Failed to save MCP server",
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
}
}
},
"scrollTabsLeft": "Desplazar pestañas a la izquierda",
"tabsAriaLabel": "Pestañas de configuración",
"scrollTabsRight": "Desplazar pestañas a la derecha"
"addNew": "Añadir Nuevo"
}
},
"modals": {
"uploadDoc": {
@@ -268,8 +218,8 @@
"addQuery": "Agregar Consulta"
},
"drag": {
"title": "Suelta los archivos adjuntos aquí",
"description": "Suelta para subir tus archivos adjuntos"
"title": "Subir archivo fuente",
"description": "Arrastra tu archivo aquí para agregarlo como fuente"
},
"progress": {
"upload": "Subida en progreso",
@@ -311,6 +261,10 @@
"google_drive": {
"label": "Google Drive",
"heading": "Subir desde Google Drive"
},
"share_point": {
"label": "SharePoint",
"heading": "Subir desde SharePoint"
}
},
"connectors": {
@@ -340,6 +294,24 @@
"remove": "Eliminar",
"folderAlt": "Carpeta",
"fileAlt": "Archivo"
},
"sharePoint": {
"connect": "Conectar a SharePoint",
"sessionExpired": "Sesión expirada. Por favor, reconecte a SharePoint.",
"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 SharePoint.",
"noAccessToken": "No hay token de acceso disponible. Por favor, reconecte a SharePoint.",
"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"
}
}
},
@@ -356,8 +328,7 @@
"disclaimer": "Esta es la única vez que se mostrará tu clave.",
"copy": "Copiar",
"copied": "Copiado",
"confirm": "He guardado la Clave",
"apiKeyLabel": "API Key"
"confirm": "He guardado la Clave"
},
"deleteConv": {
"confirm": "¿Estás seguro de que deseas eliminar todas las conversaciones?",
@@ -375,8 +346,7 @@
"apiKeyLabel": "Clave API / OAuth",
"apiKeyPlaceholder": "Ingrese la Clave API / OAuth",
"addButton": "Agregar Herramienta",
"closeButton": "Cerrar",
"customNamePlaceholder": "Enter custom name (optional)"
"closeButton": "Cerrar"
},
"prompts": {
"addPrompt": "Agregar Prompt",
@@ -386,32 +356,8 @@
"promptName": "Nombre del Prompt",
"promptText": "Texto del Prompt",
"save": "Guardar",
"cancel": "Cancelar",
"nameExists": "El nombre ya existe",
"deleteConfirmation": "¿Estás seguro de que deseas eliminar el prompt '{{name}}'?",
"placeholderText": "Escribe tu texto de prompt aquí...",
"addExamplePlaceholder": "Por favor, resume este texto:",
"variablesLabel": "Variables",
"variablesSubtext": "Haz clic para insertar en el prompt",
"variablesDescription": "Haz clic para insertar en el prompt",
"systemVariables": "Variables del sistema",
"toolVariables": "Variables de herramientas",
"systemVariablesDropdownLabel": "Variables del sistema",
"systemVariableOptions": {
"sourceContent": "Contenido de las fuentes",
"sourceSummaries": "Alias del contenido (compatibilidad retroactiva)",
"sourceDocuments": "Lista de objetos de documentos",
"sourceCount": "Número de documentos recuperados",
"systemDate": "Fecha actual (YYYY-MM-DD)",
"systemTime": "Hora actual (HH:MM:SS)",
"systemTimestamp": "Marca de tiempo ISO 8601",
"systemRequestId": "Identificador único de solicitud",
"systemUserId": "ID del usuario actual"
},
"learnAboutPrompts": "Aprende sobre los Prompts →",
"publicPromptEditDisabled": "Los prompts públicos no se pueden editar",
"promptTypePublic": "público",
"promptTypePrivate": "privado"
"deleteConfirmation": "¿Estás seguro de que deseas eliminar el prompt '{{name}}'?"
},
"chunk": {
"add": "Agregar Fragmento",
@@ -425,22 +371,6 @@
"cancel": "Cancelar",
"delete": "Eliminar",
"deleteConfirmation": "¿Estás seguro de que deseas eliminar este fragmento?"
},
"addAction": {
"title": "New Action",
"actionNamePlaceholder": "Action Name",
"invalidFormat": "Invalid function name format. Use only letters, numbers, underscores, and hyphens.",
"formatHelp": "Use only letters, numbers, underscores, and hyphens (e.g., `get_data`, `send_report`, etc.)",
"addButton": "Add"
},
"agentDetails": {
"title": "Access Details",
"publicLink": "Public Link",
"apiKey": "API Key",
"webhookUrl": "Webhook URL",
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
}
},
"sharedConv": {
@@ -476,160 +406,12 @@
"title": "Fuentes",
"link": "Enlace fuente",
"view_more": "Ver {{count}} más fuentes",
"text": "Elegir tus fuentes",
"noSourcesAvailable": "No hay fuentes disponibles"
"text": "Elegir tus fuentes"
},
"attachments": {
"attach": "Adjuntar",
"remove": "Eliminar adjunto"
},
"retry": "Reintentar",
"reasoning": "Razonamiento"
},
"agents": {
"title": "Agentes",
"description": "Descubre y crea versiones personalizadas de DocsGPT que combinan instrucciones, conocimiento adicional y cualquier combinación de habilidades",
"newAgent": "Nuevo Agente",
"backToAll": "Volver a todos los agentes",
"sections": {
"template": {
"title": "Por DocsGPT",
"description": "Agentes proporcionados por DocsGPT",
"emptyState": "No se encontraron agentes de plantilla."
},
"user": {
"title": "Por mí",
"description": "Agentes creados o publicados por ti",
"emptyState": "Aún no tienes agentes creados."
},
"shared": {
"title": "Compartidos conmigo",
"description": "Agentes importados mediante un enlace público",
"emptyState": "No se encontraron agentes compartidos."
}
},
"form": {
"headings": {
"new": "Nuevo Agente",
"edit": "Editar Agente",
"draft": "Nuevo Agente (Borrador)"
},
"buttons": {
"publish": "Publicar",
"save": "Guardar",
"saveDraft": "Guardar Borrador",
"cancel": "Cancelar",
"delete": "Eliminar",
"logs": "Registros",
"accessDetails": "Detalles de Acceso",
"add": "Agregar"
},
"sections": {
"meta": "Meta",
"source": "Fuente",
"prompt": "Prompt",
"tools": "Herramientas",
"agentType": "Tipo de agente",
"advanced": "Avanzado",
"preview": "Vista previa"
},
"placeholders": {
"agentName": "Nombre del agente",
"describeAgent": "Describe tu agente",
"selectSources": "Seleccionar fuentes",
"chunksPerQuery": "Fragmentos por consulta",
"selectType": "Seleccionar tipo",
"selectTools": "Seleccionar herramientas",
"enterTokenLimit": "Ingresar límite de tokens",
"enterRequestLimit": "Ingresar límite de solicitudes"
},
"sourcePopup": {
"title": "Seleccionar Fuentes",
"searchPlaceholder": "Buscar fuentes...",
"noOptionsMessage": "No hay fuentes disponibles"
},
"toolsPopup": {
"title": "Seleccionar Herramientas",
"searchPlaceholder": "Buscar herramientas...",
"noOptionsMessage": "No hay herramientas disponibles"
},
"upload": {
"clickToUpload": "Haz clic para subir",
"dragAndDrop": " o arrastra y suelta"
},
"agentTypes": {
"classic": "Clásico",
"react": "ReAct"
},
"advanced": {
"jsonSchema": "Esquema de respuesta JSON",
"jsonSchemaDescription": "Define un esquema JSON para aplicar formato de salida estructurado",
"validJson": "JSON válido",
"invalidJson": "JSON inválido - corrige para habilitar el guardado",
"tokenLimiting": "Límite de tokens",
"tokenLimitingDescription": "Limita el total diario de tokens que puede usar este agente",
"requestLimiting": "Límite de solicitudes",
"requestLimitingDescription": "Limita el total diario de solicitudes que se pueden hacer a este agente"
},
"preview": {
"publishedPreview": "Los agentes publicados se pueden previsualizar aquí"
},
"externalKb": "KB Externa"
},
"logs": {
"title": "Registros del Agente",
"lastUsedAt": "Último uso",
"noUsageHistory": "Sin historial de uso",
"tableHeader": "Registros del endpoint del agente"
},
"shared": {
"notFound": "No se encontró el agente. Asegúrate de que el agente esté compartido."
},
"preview": {
"testMessage": "Prueba tu agente aquí. Los agentes publicados se pueden usar en conversaciones."
},
"deleteConfirmation": "¿Estás seguro de que quieres eliminar este agente?"
},
"components": {
"fileUpload": {
"clickToUpload": "Click to upload or drag and drop",
"dropFiles": "Drop the files here",
"fileTypes": "PNG, JPG, JPEG up to",
"sizeLimitUnit": "MB",
"fileSizeError": "File exceeds {{size}}MB limit"
}
},
"pageNotFound": {
"title": "404",
"message": "The page you are looking for does not exist.",
"goHome": "Go Back Home"
},
"filePicker": {
"searchPlaceholder": "Buscar archivos y carpetas...",
"itemsSelected": "{{count}} seleccionados",
"name": "Nombre",
"lastModified": "Última modificación",
"size": "Tamaño"
},
"actionButtons": {
"openNewChat": "Abrir nuevo chat",
"share": "Compartir"
},
"mermaid": {
"downloadOptions": "Opciones de descarga",
"viewCode": "Ver código",
"decreaseZoom": "Reducir zoom",
"resetZoom": "Restablecer zoom",
"increaseZoom": "Aumentar zoom"
},
"navigation": {
"agents": "Agentes"
},
"notification": {
"ariaLabel": "Notificación",
"closeAriaLabel": "Cerrar notificación"
},
"prompts": {
"textAriaLabel": "Texto del prompt"
"retry": "Reintentar"
}
}

View File

@@ -185,58 +185,8 @@
"cancel": "キャンセル",
"addNew": "新規追加",
"name": "名前",
"type": "タイプ",
"mcp": {
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
"timeout": "Timeout (seconds)",
"testConnection": "Test Connection",
"testing": "Testing",
"saving": "Saving",
"save": "Save",
"cancel": "Cancel",
"noAuth": "No Authentication",
"oauthInProgress": "Waiting for OAuth completion...",
"oauthCompleted": "OAuth completed successfully",
"authType": "Authentication Type",
"defaultServerName": "My MCP Server",
"authTypes": {
"none": "No Authentication",
"apiKey": "API Key",
"bearer": "Bearer Token",
"oauth": "OAuth",
"basic": "Basic Authentication"
},
"placeholders": {
"serverUrl": "https://api.example.com",
"apiKey": "Your secret API key",
"bearerToken": "Your secret token",
"username": "Your username",
"password": "Your password",
"oauthScopes": "OAuth scopes (comma separated)"
},
"errors": {
"nameRequired": "Server name is required",
"urlRequired": "Server URL is required",
"invalidUrl": "Please enter a valid URL",
"apiKeyRequired": "API key is required",
"tokenRequired": "Bearer token is required",
"usernameRequired": "Username is required",
"passwordRequired": "Password is required",
"testFailed": "Connection test failed",
"saveFailed": "Failed to save MCP server",
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
}
}
},
"scrollTabsLeft": "タブを左にスクロール",
"tabsAriaLabel": "設定タブ",
"scrollTabsRight": "タブを右にスクロール"
"type": "タイプ"
}
},
"modals": {
"uploadDoc": {
@@ -268,8 +218,8 @@
"addQuery": "クエリを追加"
},
"drag": {
"title": "添付ファイルをここにドロップ",
"description": "リリースして添付ファイルをアップロード"
"title": "ソースファイルをアップロード",
"description": "ファイルをここにドロップしてソースとして追加してください"
},
"progress": {
"upload": "アップロード中",
@@ -311,6 +261,10 @@
"google_drive": {
"label": "Google Drive",
"heading": "Google Driveからアップロード"
},
"share_point": {
"label": "SharePoint",
"heading": "SharePointからアップロード"
}
},
"connectors": {
@@ -340,6 +294,24 @@
"remove": "削除",
"folderAlt": "フォルダ",
"fileAlt": "ファイル"
},
"sharePoint": {
"connect": "SharePointに接続",
"sessionExpired": "セッションが期限切れです。SharePointに再接続してください。",
"sessionExpiredGeneric": "セッションが期限切れです。アカウントに再接続してください。",
"validateFailed": "セッションの検証に失敗しました。再接続してください。",
"noSession": "有効なセッションが見つかりません。SharePointに再接続してください。",
"noAccessToken": "アクセストークンが利用できません。SharePointに再接続してください。",
"pickerFailed": "ファイルピッカーを開けませんでした。もう一度お試しください。",
"selectedFiles": "選択されたファイル",
"selectFiles": "ファイルを選択",
"loading": "読み込み中...",
"noFilesSelected": "ファイルまたはフォルダが選択されていません",
"folders": "フォルダ",
"files": "ファイル",
"remove": "削除",
"folderAlt": "フォルダ",
"fileAlt": "ファイル"
}
}
},
@@ -356,8 +328,7 @@
"disclaimer": "キーが表示されるのはこのときだけです。",
"copy": "コピー",
"copied": "コピーしました",
"confirm": "キーを保存しました",
"apiKeyLabel": "API Key"
"confirm": "キーを保存しました"
},
"deleteConv": {
"confirm": "すべての会話を削除してもよろしいですか?",
@@ -375,43 +346,18 @@
"apiKeyLabel": "APIキー / OAuth",
"apiKeyPlaceholder": "APIキー / OAuthを入力してください",
"addButton": "ツールを追加",
"closeButton": "閉じる",
"customNamePlaceholder": "Enter custom name (optional)"
"closeButton": "閉じる"
},
"prompts": {
"addPrompt": "プロンプトを追加",
"addDescription": "カスタムプロンプトを追加して DocsGPT に保存します",
"addDescription": "カスタムプロンプトを追加してDocsGPTに保存",
"editPrompt": "プロンプトを編集",
"editDescription": "カスタムプロンプトを編集して DocsGPT に保存します",
"editDescription": "カスタムプロンプトを編集してDocsGPTに保存",
"promptName": "プロンプト名",
"promptText": "プロンプトテキスト",
"promptText": "プロンプトテキスト",
"save": "保存",
"cancel": "キャンセル",
"nameExists": "この名前はすでに存在します",
"deleteConfirmation": "プロンプト『{{name}}』を削除してもよろしいですか?",
"placeholderText": "ここにプロンプトのテキストを入力してください…",
"addExamplePlaceholder": "このテキストを要約してください:",
"variablesLabel": "変数",
"variablesSubtext": "クリックしてプロンプトに挿入",
"variablesDescription": "クリックしてプロンプトに挿入",
"systemVariables": "システム変数",
"toolVariables": "ツール変数",
"systemVariablesDropdownLabel": "System Variables",
"systemVariableOptions": {
"sourceContent": "Sources content",
"sourceSummaries": "Alias for content (backward compatible)",
"sourceDocuments": "Document objects list",
"sourceCount": "Number of retrieved documents",
"systemDate": "Current date (YYYY-MM-DD)",
"systemTime": "Current time (HH:MM:SS)",
"systemTimestamp": "ISO 8601 timestamp",
"systemRequestId": "Unique request identifier",
"systemUserId": "Current user ID"
},
"learnAboutPrompts": "プロンプトについて学ぶ →",
"publicPromptEditDisabled": "公開プロンプトは編集できません",
"promptTypePublic": "公開",
"promptTypePrivate": "非公開"
"nameExists": "名前が既に存在します",
"deleteConfirmation": "プロンプト「{{name}}」を削除してもよろしいですか?"
},
"chunk": {
"add": "チャンクを追加",
@@ -425,22 +371,6 @@
"cancel": "キャンセル",
"delete": "削除",
"deleteConfirmation": "このチャンクを削除してもよろしいですか?"
},
"addAction": {
"title": "New Action",
"actionNamePlaceholder": "Action Name",
"invalidFormat": "Invalid function name format. Use only letters, numbers, underscores, and hyphens.",
"formatHelp": "Use only letters, numbers, underscores, and hyphens (e.g., `get_data`, `send_report`, etc.)",
"addButton": "Add"
},
"agentDetails": {
"title": "Access Details",
"publicLink": "Public Link",
"apiKey": "API Key",
"webhookUrl": "Webhook URL",
"generate": "Generate",
"test": "Test",
"learnMore": "Learn more"
}
},
"sharedConv": {
@@ -476,160 +406,12 @@
"title": "ソース",
"text": "ソーステキスト",
"link": "ソースリンク",
"view_more": "さらに{{count}}個のソース",
"noSourcesAvailable": "利用可能なソースがありません"
"view_more": "さらに{{count}}個のソース"
},
"attachments": {
"attach": "添付",
"remove": "添付ファイルを削除"
},
"retry": "再試行",
"reasoning": "推論"
},
"agents": {
"title": "エージェント",
"description": "指示、追加知識、スキルの組み合わせを含むDocsGPTのカスタムバージョンを発見して作成します",
"newAgent": "新しいエージェント",
"backToAll": "すべてのエージェントに戻る",
"sections": {
"template": {
"title": "DocsGPT提供",
"description": "DocsGPTが提供するエージェント",
"emptyState": "テンプレートエージェントが見つかりません。"
},
"user": {
"title": "自分のエージェント",
"description": "あなたが作成または公開したエージェント",
"emptyState": "まだ作成されたエージェントがありません。"
},
"shared": {
"title": "共有されたエージェント",
"description": "公開リンクを使用してインポートされたエージェント",
"emptyState": "共有エージェントが見つかりません。"
}
},
"form": {
"headings": {
"new": "新しいエージェント",
"edit": "エージェントを編集",
"draft": "新しいエージェント(下書き)"
},
"buttons": {
"publish": "公開",
"save": "保存",
"saveDraft": "下書きを保存",
"cancel": "キャンセル",
"delete": "削除",
"logs": "ログ",
"accessDetails": "アクセス詳細",
"add": "追加"
},
"sections": {
"meta": "メタ",
"source": "ソース",
"prompt": "プロンプト",
"tools": "ツール",
"agentType": "エージェントタイプ",
"advanced": "詳細設定",
"preview": "プレビュー"
},
"placeholders": {
"agentName": "エージェント名",
"describeAgent": "エージェントを説明してください",
"selectSources": "ソースを選択",
"chunksPerQuery": "クエリごとのチャンク数",
"selectType": "タイプを選択",
"selectTools": "ツールを選択",
"enterTokenLimit": "トークン制限を入力",
"enterRequestLimit": "リクエスト制限を入力"
},
"sourcePopup": {
"title": "ソースを選択",
"searchPlaceholder": "ソースを検索...",
"noOptionsMessage": "利用可能なソースがありません"
},
"toolsPopup": {
"title": "ツールを選択",
"searchPlaceholder": "ツールを検索...",
"noOptionsMessage": "利用可能なツールがありません"
},
"upload": {
"clickToUpload": "クリックしてアップロード",
"dragAndDrop": " またはドラッグ&ドロップ"
},
"agentTypes": {
"classic": "クラシック",
"react": "ReAct"
},
"advanced": {
"jsonSchema": "JSON応答スキーマ",
"jsonSchemaDescription": "構造化された出力形式を適用するためのJSONスキーマを定義します",
"validJson": "有効なJSON",
"invalidJson": "無効なJSON - 保存を有効にするには修正してください",
"tokenLimiting": "トークン制限",
"tokenLimitingDescription": "このエージェントが使用できる1日の合計トークン数を制限します",
"requestLimiting": "リクエスト制限",
"requestLimitingDescription": "このエージェントに対して行える1日の合計リクエスト数を制限します"
},
"preview": {
"publishedPreview": "公開されたエージェントはここでプレビューできます"
},
"externalKb": "外部KB"
},
"logs": {
"title": "エージェントログ",
"lastUsedAt": "最終使用日時",
"noUsageHistory": "使用履歴がありません",
"tableHeader": "エージェントエンドポイントログ"
},
"shared": {
"notFound": "エージェントが見つかりません。エージェントが共有されていることを確認してください。"
},
"preview": {
"testMessage": "ここでエージェントをテストできます。公開されたエージェントは会話で使用できます。"
},
"deleteConfirmation": "このエージェントを削除してもよろしいですか?"
},
"components": {
"fileUpload": {
"clickToUpload": "Click to upload or drag and drop",
"dropFiles": "Drop the files here",
"fileTypes": "PNG, JPG, JPEG up to",
"sizeLimitUnit": "MB",
"fileSizeError": "File exceeds {{size}}MB limit"
}
},
"pageNotFound": {
"title": "404",
"message": "The page you are looking for does not exist.",
"goHome": "Go Back Home"
},
"filePicker": {
"searchPlaceholder": "ファイルとフォルダを検索...",
"itemsSelected": "{{count}} 件選択済み",
"name": "名前",
"lastModified": "最終更新日",
"size": "サイズ"
},
"actionButtons": {
"openNewChat": "新しいチャットを開く",
"share": "共有"
},
"mermaid": {
"downloadOptions": "ダウンロードオプション",
"viewCode": "コードを表示",
"decreaseZoom": "ズームアウト",
"resetZoom": "ズームをリセット",
"increaseZoom": "ズームイン"
},
"navigation": {
"agents": "エージェント"
},
"notification": {
"ariaLabel": "通知",
"closeAriaLabel": "通知を閉じる"
},
"prompts": {
"textAriaLabel": "プロンプトテキスト"
"retry": "再試行"
}
}

Some files were not shown because too many files have changed in this diff Show More