Compare commits

..

107 Commits

Author SHA1 Message Date
Pavel
9548364e05 answer endpoint attachments 2025-07-12 15:53:53 +02:00
Alex
2e7cb510ae Update README.md 2025-07-10 17:27:08 +03:00
Alex
dbe45904d7 Merge pull request #1881 from siiddhantt/fix/glacier-images
fix: s3 storage class for image upload
2025-07-10 12:00:55 +01:00
Siddhant Rai
5623734276 feat(storage): enhance save_file method to accept storage class parameter 2025-07-10 15:34:52 +05:30
Alex
839a12bed4 Merge pull request #1869 from siiddhantt/refactor/tools-dict
refactor: update user tools dict to use enumeration based key
2025-07-03 12:51:33 +09:00
Siddhant Rai
1372210004 refactor: update user tools dict to use enumeration based key 2025-07-02 10:37:32 +05:30
Alex
42f48649b9 Merge pull request #1843 from arc53/dependabot/npm_and_yarn/frontend/tailwindcss-4.1.10
build(deps-dev): bump tailwindcss from 3.4.17 to 4.1.10 in /frontend
2025-06-28 13:22:47 +09:00
ManishMadan2882
0b08e8b617 (fix:nav) settings gear dimesions 2025-06-27 22:16:01 +05:30
ManishMadan2882
926b2f1a1b clean 2025-06-25 19:03:24 +05:30
ManishMadan2882
1770a1a45f Merge branch 'main' of https://github.com/arc53/DocsGPT into dependabot/npm_and_yarn/frontend/tailwindcss-4.1.10 2025-06-25 18:59:51 +05:30
ManishMadan2882
50ed2a64c6 (fix/ddropdown) use complete classNames, interpolation 2025-06-25 18:26:33 +05:30
Alex
2332344988 Merge pull request #1861 from arc53/docs-agents-update
Agent docs upd
2025-06-25 09:23:09 +01:00
Alex
7ccc8cdc58 Merge pull request #1855 from siiddhantt/refactor/ddg-brave-tools
refactor: ddg and brave tools with sources fix
2025-06-25 09:21:38 +01:00
ManishMadan2882
ecec9f913e (fix:messageInput) modern tailwind syntx 2025-06-25 02:15:03 +05:30
ManishMadan2882
777f40fc5e (fix:conflicting global css) layered styles 2025-06-25 01:11:39 +05:30
Pavel
327ae35420 Agent docs upd
1. Added a page about interacting with agent API.
2. Added a page about interacting with agent webhooks.
3. Fixed small bug with /api/answer
2025-06-24 16:48:12 +02:00
Siddhant Rai
0d48159da8 feat: enhance modal functionality with reset and confirmation handlers 2025-06-24 02:14:15 +05:30
Siddhant Rai
d36f12a4ea feat: add DuckDuckGo icon and remove sources skeleton 2025-06-24 02:11:58 +05:30
Siddhant Rai
709488beb1 fix: sources not getting set on stream end 2025-06-23 09:23:18 +05:30
Siddhant Rai
a9e4583695 refactor: use DuckDuckGo and Brave as tools instead of retrievers 2025-06-23 09:22:17 +05:30
ManishMadan2882
4702dec933 (chore:tailwindcss) via upgrade tool 2025-06-22 18:31:26 +05:30
ManishMadan2882
e6352dd691 Revert "build(deps-dev): bump tailwindcss from 3.4.17 to 4.1.10 in /frontend"
This reverts commit 240ea3b857.
2025-06-22 18:16:41 +05:30
dependabot[bot]
240ea3b857 build(deps-dev): bump tailwindcss from 3.4.17 to 4.1.10 in /frontend
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 3.4.17 to 4.1.10.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.10/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-22 12:08:36 +00:00
Alex
f0908af3c0 Merge pull request #1829 from arc53/dependabot/npm_and_yarn/frontend/multi-d4a34be08c
build(deps): bump react and @types/react in /frontend
2025-06-22 12:07:44 +01:00
ManishMadan2882
6834961dd1 (fix:types) stricter in v19 2025-06-20 23:11:53 +05:30
Alex
b404162364 fix: fallback to OpenAILLMHandler when no handler class is found 2025-06-20 16:08:40 +01:00
Alex
e879ef805f Merge pull request #1851 from ManishMadan2882/main
Fixed conflict while switching conversations, separated concerns
2025-06-20 13:07:24 +01:00
ManishMadan2882
7077ca5e98 (chore/upgrade) migrate to react v19 2025-06-20 17:36:28 +05:30
GH Action - Upstream Sync
a1e6978c8f Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-20 01:44:14 +00:00
Alex
584391dd59 Update README.md 2025-06-19 17:34:19 +03:00
dependabot[bot]
bab3ae809c build(deps): bump react and @types/react in /frontend
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 18.3.1 to 19.1.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.0/packages/react)

Updates `@types/react` from 18.3.23 to 19.1.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: "@types/react"
  dependency-version: 19.1.6
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-19 11:02:29 +00:00
Manish Madan
c78518baf0 Merge pull request #1827 from arc53/dependabot/npm_and_yarn/frontend/react-dropzone-14.3.8
build(deps): bump react-dropzone from 14.3.5 to 14.3.8 in /frontend
2025-06-19 16:31:06 +05:30
dependabot[bot]
556d7e0497 build(deps): bump react-dropzone from 14.3.5 to 14.3.8 in /frontend
Bumps [react-dropzone](https://github.com/react-dropzone/react-dropzone) from 14.3.5 to 14.3.8.
- [Release notes](https://github.com/react-dropzone/react-dropzone/releases)
- [Commits](https://github.com/react-dropzone/react-dropzone/compare/v14.3.5...v14.3.8)

---
updated-dependencies:
- dependency-name: react-dropzone
  dependency-version: 14.3.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-19 10:59:31 +00:00
Manish Madan
2d27936dab Merge pull request #1826 from arc53/dependabot/npm_and_yarn/frontend/reduxjs/toolkit-2.8.2
build(deps): bump @reduxjs/toolkit from 2.5.1 to 2.8.2 in /frontend
2025-06-19 16:27:30 +05:30
GH Action - Upstream Sync
0cc22de545 Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-19 01:45:04 +00:00
Alex
63f6127049 Revert "Update README.md"
This reverts commit 55f60a9fe1.
2025-06-18 22:26:38 +01:00
Alex
f34e00c986 Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-18 22:26:19 +01:00
Alex
55f60a9fe1 Update README.md 2025-06-18 22:26:11 +01:00
Alex
7da3618e0c Update README.md 2025-06-18 22:25:47 +01:00
Alex
56bfa98633 Merge remote-tracking branch 'upstream/main' 2025-06-18 22:18:06 +01:00
Alex
96f6188722 Initial commit 2025-06-18 22:17:23 +01:00
Manish Madan
aa9d359039 Merge branch 'arc53:main' into main 2025-06-19 02:20:08 +05:30
ManishMadan2882
cef5731028 (feat:nav) shut sidebar on click outside 2025-06-19 02:19:29 +05:30
ManishMadan2882
5bc28bd4fd (fix:prompts) show delete only when possible 2025-06-19 02:18:20 +05:30
ManishMadan2882
55a1d867c3 (refactor/converstationSlice) separate preview 2025-06-19 02:16:03 +05:30
Alex
6c3a79802e Merge pull request #1849 from siiddhantt/feat/upload-agent-logo
feat: enhance agent management with image upload and retrieval
2025-06-18 17:45:51 +01:00
Siddhant Rai
c35c5e0793 Refactor image upload handling and add URL strategy setting 2025-06-18 21:54:44 +05:30
ManishMadan2882
7bc83caa99 Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-06-18 19:42:16 +05:30
ManishMadan2882
3aceca63c6 (fix/bubble) evenly padded 2025-06-18 19:41:52 +05:30
ManishMadan2882
9bc166ffd4 (fix:convSlice) append to right conv id 2025-06-18 19:41:20 +05:30
Siddhant Rai
fc01b90007 Add tailwind-merge dependency to package.json and package-lock.json 2025-06-18 19:00:59 +05:30
Siddhant Rai
e35f1d70e4 - Added image upload functionality for agents in the backend and frontend.
- Implemented image URL generation based on storage strategy (S3 or local).
- Updated agent creation and update endpoints to handle image files.
- Enhanced frontend components to display agent images with fallbacks.
- New API endpoint to serve images from storage.
- Refactored API client to support FormData for file uploads.
- Improved error handling and logging for image processing.
2025-06-18 18:17:20 +05:30
Siddhant Rai
cab1f3787a Refactor S3 storage implementation and enhance file handling
- Improved code readability by reorganizing imports and formatting.
- Updated S3Storage class to handle file uploads and downloads more efficiently.
- Added a new function to generate image URLs based on settings.
- Enhanced file listing and processing methods for better error handling.
- Introduced a FileUpload component for improved file upload experience in the frontend.
- Updated agent management components to support image uploads and previews.
- Added new SVG assets for UI enhancements.
- Modified API client to support FormData for file uploads.
2025-06-18 18:04:44 +05:30
Alex
bb42f4cbc1 Merge pull request #1846 from ManishMadan2882/main
Agents Details: UI update, external links
2025-06-17 14:31:16 +01:00
ManishMadan2882
98dc418a51 (feat:agent-details) redirect on url 2025-06-17 18:30:11 +05:30
ManishMadan2882
322b4eb18c Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-06-17 16:04:33 +05:30
ManishMadan2882
7f1cc30ed8 (feat:agent-details) test, learn more redirects 2025-06-17 16:04:08 +05:30
ManishMadan2882
7b45a6b956 (feat:agentDetails) copy button ui 2025-06-17 12:03:36 +05:30
Alex
e36769e70f Merge pull request #1844 from ManishMadan2882/main
Collapsible Question bubbles
2025-06-15 16:36:37 +01:00
ManishMadan2882
bd4a4cc4af (feat:question) match design 2025-06-14 02:32:59 +05:30
ManishMadan2882
8343fe63cb (feat:bubble) collapsable questions 2025-06-14 02:02:36 +05:30
Alex
7d89fb8461 fix: lint 2025-06-13 01:14:09 +01:00
Alex
098955d230 fix paths in docker compose 2025-06-13 01:11:22 +01:00
Alex
d254d14928 Merge pull request #1838 from ManishMadan2882/main
Fixes ingestion of file with non-ascii characters in name
2025-06-12 09:52:03 +01:00
GH Action - Upstream Sync
0a3e8ca535 Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-12 01:43:21 +00:00
ManishMadan2882
b8a10e0962 (fix:ingestion) display names are separate 2025-06-12 00:57:46 +05:30
Alex
0aceda96e4 Merge pull request #1824 from siiddhantt/refactor/llm-handler
feat: reorganize LLM handler structure with better abstraction
2025-06-11 17:19:50 +01:00
ManishMadan2882
44b6ec25a2 clean 2025-06-11 21:18:37 +05:30
ManishMadan2882
1b84d1fa9d Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-06-11 21:04:57 +05:30
ManishMadan2882
78d5ed2ed2 (fix:ingestion) uuid for non-ascii filename 2025-06-11 21:04:50 +05:30
ManishMadan2882
142477ab9b (feat:safe_filename) handles case of non-ascii char 2025-06-11 21:03:38 +05:30
Siddhant Rai
b414f79bc5 fix: adjust width of tool calls display in ConversationBubble component 2025-06-11 19:37:32 +05:30
Siddhant Rai
6e08fe21d0 Merge branch 'refactor/llm-handler' of https://github.com/siiddhantt/DocsGPT into refactor/llm-handler 2025-06-11 19:28:47 +05:30
Siddhant Rai
9b839655a7 refactor: improve tool call result handling and display in conversation components 2025-06-11 19:28:15 +05:30
Siddhant Rai
3353c0ee1d Merge branch 'main' into refactor/llm-handler 2025-06-11 19:27:33 +05:30
Alex
aaecf52c99 refactor: update docs LLM_NAME and MODEL_NAME to LLM_PROVIDER and LLM_NAME 2025-06-11 12:30:34 +01:00
ManishMadan2882
8b3e960be0 (feat:ingestion) store filepath from now 2025-06-11 16:00:09 +05:30
Siddhant Rai
3351f71813 refactor: tool calls sent when pending and after completion 2025-06-11 12:40:32 +05:30
Alex
7490256303 Merge pull request #1830 from ManishMadan2882/main
UI update: attachments in question bubble
2025-06-10 14:46:05 +01:00
ManishMadan2882
041d600e45 (feat:prompts) delete after confirmation 2025-06-10 18:00:11 +05:30
ManishMadan2882
b4e2588a24 (fix:prompts) save when content changes 2025-06-10 17:02:24 +05:30
ManishMadan2882
68dc14c5a1 (feat:attachments) clear after passing 2025-06-10 02:50:07 +05:30
ManishMadan2882
ef35864e16 (fix) type error, ui adjust 2025-06-09 19:50:07 +05:30
ManishMadan2882
c0d385b983 (refactor:attachments) moved to new uploadSlice 2025-06-09 17:10:12 +05:30
ManishMadan2882
b2df431fa4 (feat:attachments) shared conversations route 2025-06-07 20:04:33 +05:30
ManishMadan2882
69a4bd415a (feat:attachment) message input update 2025-06-06 21:52:51 +05:30
ManishMadan2882
4862548e65 (feat:attach) renaming, semantic identifier names 2025-06-06 21:52:10 +05:30
ManishMadan2882
50248cc9ea Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-06-06 18:55:11 +05:30
ManishMadan2882
430822bae3 (feat:attach)state manage, follow camelCase 2025-06-06 18:54:50 +05:30
Siddhant Rai
dd9d18208d Merge branch 'main' into refactor/llm-handler 2025-06-06 17:36:31 +05:30
Siddhant Rai
e5b1a71659 refactor: update fallback LLM initialization to use factory method 2025-06-06 17:23:27 +05:30
Siddhant Rai
35f4b13237 refactor: add fallback LLM configuration options to settings 2025-06-06 17:05:15 +05:30
Siddhant Rai
5f5c31cd5b refactor: enhance LLM fallback handling and streamline method execution 2025-06-06 16:55:57 +05:30
Siddhant Rai
e9530d5ec5 refactor: update env variable names 2025-06-06 15:29:53 +05:30
Siddhant Rai
143f4aa886 refactor: streamline conversation handling and update agent pinning logic 2025-06-06 14:41:44 +05:30
GH Action - Upstream Sync
ece5c8bb31 Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-06 01:42:12 +00:00
Alex
31baf181a3 fix: default optimisations 2025-06-05 12:21:40 +01:00
ManishMadan2882
3bae30c70c (fix:messages) attachments are for questions 2025-06-05 03:09:37 +05:30
ManishMadan2882
12b18c6bd1 Merge branch 'main' of https://github.com/manishmadan2882/docsgpt 2025-06-05 02:54:51 +05:30
ManishMadan2882
787d9e3bf5 (feat:attachments) ui details in bubble 2025-06-05 02:54:36 +05:30
ManishMadan2882
f325b54895 (fix:get_single_conversation) return attachments with filename 2025-06-05 02:53:43 +05:30
GH Action - Upstream Sync
c5616705b0 Merge branch 'main' of https://github.com/arc53/DocsGPT 2025-06-04 01:43:58 +00:00
ManishMadan2882
e90fe117ec (feat:attachments) render in bubble 2025-06-03 18:05:47 +05:30
ManishMadan2882
7cab5b3b09 (feat:attachments) store filenames in worker 2025-06-03 04:02:41 +05:30
ManishMadan2882
9f911cb5cb (feat:stream) store attachment_ids for query 2025-06-03 03:30:06 +05:30
dependabot[bot]
3da7cba06c build(deps): bump @reduxjs/toolkit from 2.5.1 to 2.8.2 in /frontend
Bumps [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) from 2.5.1 to 2.8.2.
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.5.1...v2.8.2)

---
updated-dependencies:
- dependency-name: "@reduxjs/toolkit"
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 20:27:20 +00:00
Siddhant Rai
92c3c707e1 refactor: reorganize LLM handler structure and improve tool call parsing 2025-06-02 12:17:29 +05:30
124 changed files with 6074 additions and 5013 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -52,8 +52,11 @@
- [x] Chatbots menu re-design to handle tools, agent types, and more (April 2025) - [x] Chatbots menu re-design to handle tools, agent types, and more (April 2025)
- [x] New input box in the conversation menu (April 2025) - [x] New input box in the conversation menu (April 2025)
- [x] Add triggerable actions / tools (webhook) (April 2025) - [x] Add triggerable actions / tools (webhook) (April 2025)
- [ ] Anthropic Tool compatibility (May 2025) - [x] Agent optimisations (May 2025)
- [ ] Add OAuth 2.0 authentication for tools and sources - [ ] Filesystem sources update (July 2025)
- [ ] Anthropic Tool compatibility (July 2025)
- [ ] MCP support (July 2025)
- [ ] Add OAuth 2.0 authentication for tools and sources (August 2025)
- [ ] Agent scheduling - [ ] Agent scheduling
You can find our full roadmap [here](https://github.com/orgs/arc53/projects/2). Please don't hesitate to contribute or create issues, it helps us improve DocsGPT! You can find our full roadmap [here](https://github.com/orgs/arc53/projects/2). Please don't hesitate to contribute or create issues, it helps us improve DocsGPT!

View File

@@ -2,16 +2,18 @@ import uuid
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Generator, List, Optional from typing import Dict, Generator, List, Optional
from application.agents.llm_handler import get_llm_handler from bson.objectid import ObjectId
from application.agents.tools.tool_action_parser import ToolActionParser from application.agents.tools.tool_action_parser import ToolActionParser
from application.agents.tools.tool_manager import ToolManager from application.agents.tools.tool_manager import ToolManager
from application.core.mongo_db import MongoDB from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.llm.handlers.handler_creator import LLMHandlerCreator
from application.llm.llm_creator import LLMCreator from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext from application.logging import build_stack_data, log_activity, LogContext
from application.retriever.base import BaseRetriever from application.retriever.base import BaseRetriever
from application.core.settings import settings
from bson.objectid import ObjectId
class BaseAgent(ABC): class BaseAgent(ABC):
@@ -45,7 +47,9 @@ class BaseAgent(ABC):
user_api_key=user_api_key, user_api_key=user_api_key,
decoded_token=decoded_token, decoded_token=decoded_token,
) )
self.llm_handler = get_llm_handler(llm_name) self.llm_handler = LLMHandlerCreator.create_handler(
llm_name if llm_name else "default"
)
self.attachments = attachments or [] self.attachments = attachments or []
@log_activity() @log_activity()
@@ -87,8 +91,8 @@ class BaseAgent(ABC):
user_tools_collection = db["user_tools"] user_tools_collection = db["user_tools"]
user_tools = user_tools_collection.find({"user": user, "status": True}) user_tools = user_tools_collection.find({"user": user, "status": True})
user_tools = list(user_tools) user_tools = list(user_tools)
tools_by_id = {str(tool["_id"]): tool for tool in user_tools}
return tools_by_id return {str(i): tool for i, tool in enumerate(user_tools)}
def _build_tool_parameters(self, action): def _build_tool_parameters(self, action):
params = {"type": "object", "properties": {}, "required": []} params = {"type": "object", "properties": {}, "required": []}
@@ -132,6 +136,15 @@ class BaseAgent(ABC):
parser = ToolActionParser(self.llm.__class__.__name__) parser = ToolActionParser(self.llm.__class__.__name__)
tool_id, action_name, call_args = parser.parse_args(call) tool_id, action_name, call_args = parser.parse_args(call)
call_id = getattr(call, "id", None) or str(uuid.uuid4())
tool_call_data = {
"tool_name": tools_dict[tool_id]["name"],
"call_id": call_id,
"action_name": f"{action_name}_{tool_id}",
"arguments": call_args,
}
yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}}
tool_data = tools_dict[tool_id] tool_data = tools_dict[tool_id]
action_data = ( action_data = (
tool_data["config"]["actions"][action_name] tool_data["config"]["actions"][action_name]
@@ -184,19 +197,29 @@ class BaseAgent(ABC):
else: else:
print(f"Executing tool: {action_name} with args: {call_args}") print(f"Executing tool: {action_name} with args: {call_args}")
result = tool.execute_action(action_name, **parameters) result = tool.execute_action(action_name, **parameters)
call_id = getattr(call, "id", None) tool_call_data["result"] = (
f"{str(result)[:50]}..." if len(str(result)) > 50 else result
)
tool_call_data = { yield {"type": "tool_call", "data": {**tool_call_data, "status": "completed"}}
"tool_name": tool_data["name"],
"call_id": call_id if call_id is not None else "None",
"action_name": f"{action_name}_{tool_id}",
"arguments": call_args,
"result": result,
}
self.tool_calls.append(tool_call_data) self.tool_calls.append(tool_call_data)
return result, call_id return result, call_id
def _get_truncated_tool_calls(self):
return [
{
**tool_call,
"result": (
f"{str(tool_call['result'])[:50]}..."
if len(str(tool_call["result"])) > 50
else tool_call["result"]
),
"status": "completed",
}
for tool_call in self.tool_calls
]
def _build_messages( def _build_messages(
self, self,
system_prompt: str, system_prompt: str,
@@ -252,9 +275,16 @@ class BaseAgent(ABC):
return retrieved_data return retrieved_data
def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None): def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None):
resp = self.llm.gen_stream( gen_kwargs = {"model": self.gpt_model, "messages": messages}
model=self.gpt_model, messages=messages, tools=self.tools
) if (
hasattr(self.llm, "_supports_tools")
and self.llm._supports_tools
and self.tools
):
gen_kwargs["tools"] = self.tools
resp = self.llm.gen_stream(**gen_kwargs)
if log_context: if log_context:
data = build_stack_data(self.llm, exclude_attributes=["client"]) data = build_stack_data(self.llm, exclude_attributes=["client"])
log_context.stacks.append({"component": "llm", "data": data}) log_context.stacks.append({"component": "llm", "data": data})
@@ -268,10 +298,29 @@ class BaseAgent(ABC):
log_context: Optional[LogContext] = None, log_context: Optional[LogContext] = None,
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
): ):
resp = self.llm_handler.handle_response( resp = self.llm_handler.process_message_flow(
self, resp, tools_dict, messages, attachments self, resp, tools_dict, messages, attachments, True
) )
if log_context: if log_context:
data = build_stack_data(self.llm_handler, exclude_attributes=["tool_calls"]) data = build_stack_data(self.llm_handler, exclude_attributes=["tool_calls"])
log_context.stacks.append({"component": "llm_handler", "data": data}) log_context.stacks.append({"component": "llm_handler", "data": data})
return resp return resp
def _handle_response(self, response, tools_dict, messages, log_context):
if isinstance(response, str):
yield {"answer": response}
return
if hasattr(response, "message") and getattr(response.message, "content", None):
yield {"answer": response.message.content}
return
processed_response_gen = self._llm_handler(
response, tools_dict, messages, log_context, self.attachments
)
for event in processed_response_gen:
if isinstance(event, str):
yield {"answer": event}
elif hasattr(event, "message") and getattr(event.message, "content", None):
yield {"answer": event.message.content}
elif isinstance(event, dict) and "type" in event:
yield event

View File

@@ -1,8 +1,6 @@
from typing import Dict, Generator from typing import Dict, Generator
from application.agents.base import BaseAgent from application.agents.base import BaseAgent
from application.logging import LogContext from application.logging import LogContext
from application.retriever.base import BaseRetriever from application.retriever.base import BaseRetriever
import logging import logging
@@ -10,55 +8,46 @@ logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent): class ClassicAgent(BaseAgent):
"""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( def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext self, query: str, retriever: BaseRetriever, log_context: LogContext
) -> Generator[Dict, None, None]: ) -> Generator[Dict, None, None]:
# Step 1: Retrieve relevant data
retrieved_data = self._retriever_search(retriever, query, log_context) retrieved_data = self._retriever_search(retriever, query, log_context)
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key) # Step 2: Prepare tools
else: tools_dict = (
tools_dict = self._get_user_tools(self.user) self._get_user_tools(self.user)
if not self.user_api_key
else self._get_tools(self.user_api_key)
)
self._prepare_tools(tools_dict) self._prepare_tools(tools_dict)
# Step 3: Build and process messages
messages = self._build_messages(self.prompt, query, retrieved_data) messages = self._build_messages(self.prompt, query, retrieved_data)
llm_response = self._llm_gen(messages, log_context)
resp = self._llm_gen(messages, log_context) # Step 4: Handle the response
yield from self._handle_response(
llm_response, tools_dict, messages, log_context
)
attachments = self.attachments # Step 5: Return metadata
yield {"sources": retrieved_data}
if isinstance(resp, str): yield {"tool_calls": self._get_truncated_tool_calls()}
yield {"answer": resp}
return
if (
hasattr(resp, "message")
and hasattr(resp.message, "content")
and resp.message.content is not None
):
yield {"answer": resp.message.content}
return
resp = self._llm_handler(resp, tools_dict, messages, log_context, attachments)
if isinstance(resp, str):
yield {"answer": resp}
elif (
hasattr(resp, "message")
and hasattr(resp.message, "content")
and resp.message.content is not None
):
yield {"answer": resp.message.content}
else:
for line in resp:
if isinstance(line, str):
yield {"answer": line}
# Log tool calls for debugging
log_context.stacks.append( log_context.stacks.append(
{"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}} {"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}}
) )
yield {"sources": retrieved_data}
# clean tool_call_data only send first 50 characters of tool_call['result']
for tool_call in self.tool_calls:
if len(str(tool_call["result"])) > 50:
tool_call["result"] = str(tool_call["result"])[:50] + "..."
yield {"tool_calls": self.tool_calls.copy()}

View File

@@ -1,351 +0,0 @@
import json
import logging
from abc import ABC, abstractmethod
from application.logging import build_stack_data
logger = logging.getLogger(__name__)
class LLMHandler(ABC):
def __init__(self):
self.llm_calls = []
self.tool_calls = []
@abstractmethod
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, **kwargs):
pass
def prepare_messages_with_attachments(self, agent, messages, attachments=None):
"""
Prepare messages with attachment content if available.
Args:
agent: The current agent instance.
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content.
Returns:
list: Messages with attachment context added to the system prompt.
"""
if not attachments:
return messages
logger.info(f"Preparing messages with {len(attachments)} attachments")
supported_types = agent.llm.get_supported_attachment_types()
supported_attachments = []
unsupported_attachments = []
for attachment in attachments:
mime_type = attachment.get('mime_type')
if mime_type in supported_types:
supported_attachments.append(attachment)
else:
unsupported_attachments.append(attachment)
# Process supported attachments with the LLM's custom method
prepared_messages = messages
if supported_attachments:
logger.info(f"Processing {len(supported_attachments)} supported attachments with {agent.llm.__class__.__name__}'s method")
prepared_messages = agent.llm.prepare_messages_with_attachments(messages, supported_attachments)
# Process unsupported attachments with the default method
if unsupported_attachments:
logger.info(f"Processing {len(unsupported_attachments)} unsupported attachments with default method")
prepared_messages = self._append_attachment_content_to_system(prepared_messages, unsupported_attachments)
return prepared_messages
def _append_attachment_content_to_system(self, messages, attachments):
"""
Default method to append attachment content to the system prompt.
Args:
messages (list): List of message dictionaries.
attachments (list): List of attachment dictionaries with content.
Returns:
list: Messages with attachment context added to the system prompt.
"""
prepared_messages = messages.copy()
attachment_texts = []
for attachment in attachments:
logger.info(f"Adding attachment {attachment.get('id')} to context")
if 'content' in attachment:
attachment_texts.append(f"Attached file content:\n\n{attachment['content']}")
if attachment_texts:
combined_attachment_text = "\n\n".join(attachment_texts)
system_found = False
for i in range(len(prepared_messages)):
if prepared_messages[i].get("role") == "system":
prepared_messages[i]["content"] += f"\n\n{combined_attachment_text}"
system_found = True
break
if not system_found:
prepared_messages.insert(0, {"role": "system", "content": combined_attachment_text})
return prepared_messages
class OpenAILLMHandler(LLMHandler):
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
logger.info(f"Messages with attachments: {messages}")
if not stream:
while hasattr(resp, "finish_reason") and resp.finish_reason == "tool_calls":
message = json.loads(resp.model_dump_json())["message"]
keys_to_remove = {"audio", "function_call", "refusal"}
filtered_data = {
k: v for k, v in message.items() if k not in keys_to_remove
}
messages.append(filtered_data)
tool_calls = resp.message.tool_calls
for call in tool_calls:
try:
self.tool_calls.append(call)
tool_response, call_id = agent._execute_tool_action(
tools_dict, call
)
function_call_dict = {
"function_call": {
"name": call.function.name,
"args": call.function.arguments,
"call_id": call_id,
}
}
function_response_dict = {
"function_response": {
"name": call.function.name,
"response": {"result": tool_response},
"call_id": call_id,
}
}
messages.append(
{"role": "assistant", "content": [function_call_dict]}
)
messages.append(
{"role": "tool", "content": [function_response_dict]}
)
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
except Exception as e:
logging.error(f"Error executing tool: {str(e)}", exc_info=True)
messages.append(
{
"role": "tool",
"content": f"Error executing tool: {str(e)}",
"tool_call_id": call_id,
}
)
resp = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))
return resp
else:
text_buffer = ""
while True:
tool_calls = {}
for chunk in resp:
if isinstance(chunk, str) and len(chunk) > 0:
yield chunk
continue
elif hasattr(chunk, "delta"):
chunk_delta = chunk.delta
if (
hasattr(chunk_delta, "tool_calls")
and chunk_delta.tool_calls is not None
):
for tool_call in chunk_delta.tool_calls:
index = tool_call.index
if index not in tool_calls:
tool_calls[index] = {
"id": "",
"function": {"name": "", "arguments": ""},
}
current = tool_calls[index]
if tool_call.id:
current["id"] = tool_call.id
if tool_call.function.name:
current["function"][
"name"
] = tool_call.function.name
if tool_call.function.arguments:
current["function"][
"arguments"
] += tool_call.function.arguments
tool_calls[index] = current
if (
hasattr(chunk, "finish_reason")
and chunk.finish_reason == "tool_calls"
):
for index in sorted(tool_calls.keys()):
call = tool_calls[index]
try:
self.tool_calls.append(call)
tool_response, call_id = agent._execute_tool_action(
tools_dict, call
)
if isinstance(call["function"]["arguments"], str):
call["function"]["arguments"] = json.loads(call["function"]["arguments"])
function_call_dict = {
"function_call": {
"name": call["function"]["name"],
"args": call["function"]["arguments"],
"call_id": call["id"],
}
}
function_response_dict = {
"function_response": {
"name": call["function"]["name"],
"response": {"result": tool_response},
"call_id": call["id"],
}
}
messages.append(
{
"role": "assistant",
"content": [function_call_dict],
}
)
messages.append(
{
"role": "tool",
"content": [function_response_dict],
}
)
except Exception as e:
logging.error(f"Error executing tool: {str(e)}", exc_info=True)
messages.append(
{
"role": "assistant",
"content": f"Error executing tool: {str(e)}",
}
)
tool_calls = {}
if hasattr(chunk_delta, "content") and chunk_delta.content:
# Add to buffer or yield immediately based on your preference
text_buffer += chunk_delta.content
yield text_buffer
text_buffer = ""
if (
hasattr(chunk, "finish_reason")
and chunk.finish_reason == "stop"
):
return resp
elif isinstance(chunk, str) and len(chunk) == 0:
continue
logger.info(f"Regenerating with messages: {messages}")
resp = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))
class GoogleLLMHandler(LLMHandler):
def handle_response(self, agent, resp, tools_dict, messages, attachments=None, stream: bool = True):
from google.genai import types
messages = self.prepare_messages_with_attachments(agent, messages, attachments)
while True:
if not stream:
response = agent.llm.gen(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))
if response.candidates and response.candidates[0].content.parts:
tool_call_found = False
for part in response.candidates[0].content.parts:
if part.function_call:
tool_call_found = True
self.tool_calls.append(part.function_call)
tool_response, call_id = agent._execute_tool_action(
tools_dict, part.function_call
)
function_response_part = types.Part.from_function_response(
name=part.function_call.name,
response={"result": tool_response},
)
messages.append(
{"role": "model", "content": [part.to_json_dict()]}
)
messages.append(
{
"role": "tool",
"content": [function_response_part.to_json_dict()],
}
)
if (
not tool_call_found
and response.candidates[0].content.parts
and response.candidates[0].content.parts[0].text
):
return response.candidates[0].content.parts[0].text
elif not tool_call_found:
return response.candidates[0].content.parts
else:
return response
else:
response = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))
tool_call_found = False
for result in response:
if hasattr(result, "function_call"):
tool_call_found = True
self.tool_calls.append(result.function_call)
tool_response, call_id = agent._execute_tool_action(
tools_dict, result.function_call
)
function_response_part = types.Part.from_function_response(
name=result.function_call.name,
response={"result": tool_response},
)
messages.append(
{"role": "model", "content": [result.to_json_dict()]}
)
messages.append(
{
"role": "tool",
"content": [function_response_part.to_json_dict()],
}
)
else:
tool_call_found = False
yield result
if not tool_call_found:
return response
def get_llm_handler(llm_type):
handlers = {
"openai": OpenAILLMHandler(),
"google": GoogleLLMHandler(),
}
return handlers.get(llm_type, OpenAILLMHandler())

View File

@@ -25,27 +25,35 @@ class BraveSearchTool(Tool):
else: else:
raise ValueError(f"Unknown action: {action_name}") raise ValueError(f"Unknown action: {action_name}")
def _web_search(self, query, country="ALL", search_lang="en", count=10, def _web_search(
offset=0, safesearch="off", freshness=None, self,
result_filter=None, extra_snippets=False, summary=False): query,
country="ALL",
search_lang="en",
count=10,
offset=0,
safesearch="off",
freshness=None,
result_filter=None,
extra_snippets=False,
summary=False,
):
""" """
Performs a web search using the Brave Search API. Performs a web search using the Brave Search API.
""" """
print(f"Performing Brave web search for: {query}") print(f"Performing Brave web search for: {query}")
url = f"{self.base_url}/web/search" url = f"{self.base_url}/web/search"
# Build query parameters
params = { params = {
"q": query, "q": query,
"country": country, "country": country,
"search_lang": search_lang, "search_lang": search_lang,
"count": min(count, 20), "count": min(count, 20),
"offset": min(offset, 9), "offset": min(offset, 9),
"safesearch": safesearch "safesearch": safesearch,
} }
# Add optional parameters only if they have values
if freshness: if freshness:
params["freshness"] = freshness params["freshness"] = freshness
if result_filter: if result_filter:
@@ -54,68 +62,69 @@ class BraveSearchTool(Tool):
params["extra_snippets"] = 1 params["extra_snippets"] = 1
if summary: if summary:
params["summary"] = 1 params["summary"] = 1
# Set up headers
headers = { headers = {
"Accept": "application/json", "Accept": "application/json",
"Accept-Encoding": "gzip", "Accept-Encoding": "gzip",
"X-Subscription-Token": self.token "X-Subscription-Token": self.token,
} }
# Make the request
response = requests.get(url, params=params, headers=headers) response = requests.get(url, params=params, headers=headers)
if response.status_code == 200: if response.status_code == 200:
return { return {
"status_code": response.status_code, "status_code": response.status_code,
"results": response.json(), "results": response.json(),
"message": "Search completed successfully." "message": "Search completed successfully.",
} }
else: else:
return { return {
"status_code": response.status_code, "status_code": response.status_code,
"message": f"Search failed with status code: {response.status_code}." "message": f"Search failed with status code: {response.status_code}.",
} }
def _image_search(self, query, country="ALL", search_lang="en", count=5, def _image_search(
safesearch="off", spellcheck=False): self,
query,
country="ALL",
search_lang="en",
count=5,
safesearch="off",
spellcheck=False,
):
""" """
Performs an image search using the Brave Search API. Performs an image search using the Brave Search API.
""" """
print(f"Performing Brave image search for: {query}") print(f"Performing Brave image search for: {query}")
url = f"{self.base_url}/images/search" url = f"{self.base_url}/images/search"
# Build query parameters
params = { params = {
"q": query, "q": query,
"country": country, "country": country,
"search_lang": search_lang, "search_lang": search_lang,
"count": min(count, 100), # API max is 100 "count": min(count, 100), # API max is 100
"safesearch": safesearch, "safesearch": safesearch,
"spellcheck": 1 if spellcheck else 0 "spellcheck": 1 if spellcheck else 0,
} }
# Set up headers
headers = { headers = {
"Accept": "application/json", "Accept": "application/json",
"Accept-Encoding": "gzip", "Accept-Encoding": "gzip",
"X-Subscription-Token": self.token "X-Subscription-Token": self.token,
} }
# Make the request
response = requests.get(url, params=params, headers=headers) response = requests.get(url, params=params, headers=headers)
if response.status_code == 200: if response.status_code == 200:
return { return {
"status_code": response.status_code, "status_code": response.status_code,
"results": response.json(), "results": response.json(),
"message": "Image search completed successfully." "message": "Image search completed successfully.",
} }
else: else:
return { return {
"status_code": response.status_code, "status_code": response.status_code,
"message": f"Image search failed with status code: {response.status_code}." "message": f"Image search failed with status code: {response.status_code}.",
} }
def get_actions_metadata(self): def get_actions_metadata(self):
@@ -130,42 +139,14 @@ class BraveSearchTool(Tool):
"type": "string", "type": "string",
"description": "The search query (max 400 characters, 50 words)", "description": "The search query (max 400 characters, 50 words)",
}, },
# "country": {
# "type": "string",
# "description": "The 2-character country code (default: US)",
# },
"search_lang": { "search_lang": {
"type": "string", "type": "string",
"description": "The search language preference (default: en)", "description": "The search language preference (default: en)",
}, },
# "count": {
# "type": "integer",
# "description": "Number of results to return (max 20, default: 10)",
# },
# "offset": {
# "type": "integer",
# "description": "Pagination offset (max 9, default: 0)",
# },
# "safesearch": {
# "type": "string",
# "description": "Filter level for adult content (off, moderate, strict)",
# },
"freshness": { "freshness": {
"type": "string", "type": "string",
"description": "Time filter for results (pd: last 24h, pw: last week, pm: last month, py: last year)", "description": "Time filter for results (pd: last 24h, pw: last week, pm: last month, py: last year)",
}, },
# "result_filter": {
# "type": "string",
# "description": "Comma-delimited list of result types to include",
# },
# "extra_snippets": {
# "type": "boolean",
# "description": "Get additional excerpts from result pages",
# },
# "summary": {
# "type": "boolean",
# "description": "Enable summary generation in search results",
# }
}, },
"required": ["query"], "required": ["query"],
"additionalProperties": False, "additionalProperties": False,
@@ -181,37 +162,21 @@ class BraveSearchTool(Tool):
"type": "string", "type": "string",
"description": "The search query (max 400 characters, 50 words)", "description": "The search query (max 400 characters, 50 words)",
}, },
# "country": {
# "type": "string",
# "description": "The 2-character country code (default: US)",
# },
# "search_lang": {
# "type": "string",
# "description": "The search language preference (default: en)",
# },
"count": { "count": {
"type": "integer", "type": "integer",
"description": "Number of results to return (max 100, default: 5)", "description": "Number of results to return (max 100, default: 5)",
}, },
# "safesearch": {
# "type": "string",
# "description": "Filter level for adult content (off, strict). Default: strict",
# },
# "spellcheck": {
# "type": "boolean",
# "description": "Whether to spellcheck provided query (default: true)",
# }
}, },
"required": ["query"], "required": ["query"],
"additionalProperties": False, "additionalProperties": False,
}, },
} },
] ]
def get_config_requirements(self): def get_config_requirements(self):
return { return {
"token": { "token": {
"type": "string", "type": "string",
"description": "Brave Search API key for authentication" "description": "Brave Search API key for authentication",
}, },
} }

View File

@@ -0,0 +1,114 @@
from application.agents.tools.base import Tool
from duckduckgo_search import DDGS
class DuckDuckGoSearchTool(Tool):
"""
DuckDuckGo Search
A tool for performing web and image searches using DuckDuckGo.
"""
def __init__(self, config):
self.config = config
def execute_action(self, action_name, **kwargs):
actions = {
"ddg_web_search": self._web_search,
"ddg_image_search": self._image_search,
}
if action_name in actions:
return actions[action_name](**kwargs)
else:
raise ValueError(f"Unknown action: {action_name}")
def _web_search(
self,
query,
max_results=5,
):
print(f"Performing DuckDuckGo web search for: {query}")
try:
results = DDGS().text(
query,
max_results=max_results,
)
return {
"status_code": 200,
"results": results,
"message": "Web search completed successfully.",
}
except Exception as e:
return {
"status_code": 500,
"message": f"Web search failed: {str(e)}",
}
def _image_search(
self,
query,
max_results=5,
):
print(f"Performing DuckDuckGo image search for: {query}")
try:
results = DDGS().images(
keywords=query,
max_results=max_results,
)
return {
"status_code": 200,
"results": results,
"message": "Image search completed successfully.",
}
except Exception as e:
return {
"status_code": 500,
"message": f"Image search failed: {str(e)}",
}
def get_actions_metadata(self):
return [
{
"name": "ddg_web_search",
"description": "Perform a web search using DuckDuckGo.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query",
},
"max_results": {
"type": "integer",
"description": "Number of results to return (default: 5)",
},
},
"required": ["query"],
},
},
{
"name": "ddg_image_search",
"description": "Perform an image search using DuckDuckGo.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query",
},
"max_results": {
"type": "integer",
"description": "Number of results to return (default: 5, max: 50)",
},
},
"required": ["query"],
},
},
]
def get_config_requirements(self):
return {}

View File

@@ -17,26 +17,21 @@ class ToolActionParser:
return parser(call) return parser(call)
def _parse_openai_llm(self, call): def _parse_openai_llm(self, call):
if isinstance(call, dict): try:
try: call_args = json.loads(call.arguments)
call_args = json.loads(call["function"]["arguments"]) tool_id = call.name.split("_")[-1]
tool_id = call["function"]["name"].split("_")[-1] action_name = call.name.rsplit("_", 1)[0]
action_name = call["function"]["name"].rsplit("_", 1)[0] except (AttributeError, TypeError) as e:
except (KeyError, TypeError) as e: logger.error(f"Error parsing OpenAI LLM call: {e}")
logger.error(f"Error parsing OpenAI LLM call: {e}") return None, None, None
return None, None, None
else:
try:
call_args = json.loads(call.function.arguments)
tool_id = call.function.name.split("_")[-1]
action_name = call.function.name.rsplit("_", 1)[0]
except (AttributeError, TypeError) as e:
logger.error(f"Error parsing OpenAI LLM call: {e}")
return None, None, None
return tool_id, action_name, call_args return tool_id, action_name, call_args
def _parse_google_llm(self, call): def _parse_google_llm(self, call):
call_args = call.args try:
tool_id = call.name.split("_")[-1] call_args = call.arguments
action_name = call.name.rsplit("_", 1)[0] tool_id = call.name.split("_")[-1]
action_name = call.name.rsplit("_", 1)[0]
except (AttributeError, TypeError) as e:
logger.error(f"Error parsing Google LLM call: {e}")
return None, None, None
return tool_id, action_name, call_args return tool_id, action_name, call_args

View File

@@ -37,17 +37,17 @@ api.add_namespace(answer_ns)
gpt_model = "" gpt_model = ""
# to have some kind of default behaviour # to have some kind of default behaviour
if settings.LLM_NAME == "openai": if settings.LLM_PROVIDER == "openai":
gpt_model = "gpt-4o-mini" gpt_model = "gpt-4o-mini"
elif settings.LLM_NAME == "anthropic": elif settings.LLM_PROVIDER == "anthropic":
gpt_model = "claude-2" gpt_model = "claude-2"
elif settings.LLM_NAME == "groq": elif settings.LLM_PROVIDER == "groq":
gpt_model = "llama3-8b-8192" gpt_model = "llama3-8b-8192"
elif settings.LLM_NAME == "novita": elif settings.LLM_PROVIDER == "novita":
gpt_model = "deepseek/deepseek-r1" gpt_model = "deepseek/deepseek-r1"
if settings.MODEL_NAME: # in case there is particular model name configured if settings.LLM_NAME: # in case there is particular model name configured
gpt_model = settings.MODEL_NAME gpt_model = settings.LLM_NAME
# load the prompts # load the prompts
current_dir = os.path.dirname( current_dir = os.path.dirname(
@@ -164,6 +164,7 @@ def save_conversation(
agent_id=None, agent_id=None,
is_shared_usage=False, is_shared_usage=False,
shared_token=None, shared_token=None,
attachment_ids=None,
): ):
current_time = datetime.datetime.now(datetime.timezone.utc) current_time = datetime.datetime.now(datetime.timezone.utc)
if conversation_id is not None and index is not None: if conversation_id is not None and index is not None:
@@ -177,6 +178,7 @@ def save_conversation(
f"queries.{index}.sources": source_log_docs, f"queries.{index}.sources": source_log_docs,
f"queries.{index}.tool_calls": tool_calls, f"queries.{index}.tool_calls": tool_calls,
f"queries.{index}.timestamp": current_time, f"queries.{index}.timestamp": current_time,
f"queries.{index}.attachments": attachment_ids,
} }
}, },
) )
@@ -197,6 +199,7 @@ def save_conversation(
"sources": source_log_docs, "sources": source_log_docs,
"tool_calls": tool_calls, "tool_calls": tool_calls,
"timestamp": current_time, "timestamp": current_time,
"attachments": attachment_ids,
} }
} }
}, },
@@ -233,6 +236,7 @@ def save_conversation(
"sources": source_log_docs, "sources": source_log_docs,
"tool_calls": tool_calls, "tool_calls": tool_calls,
"timestamp": current_time, "timestamp": current_time,
"attachments": attachment_ids,
} }
], ],
} }
@@ -273,20 +277,13 @@ def complete_stream(
isNoneDoc=False, isNoneDoc=False,
index=None, index=None,
should_save_conversation=True, should_save_conversation=True,
attachments=None, attachment_ids=None,
agent_id=None, agent_id=None,
is_shared_usage=False, is_shared_usage=False,
shared_token=None, shared_token=None,
): ):
try: try:
response_full, thought, source_log_docs, tool_calls = "", "", [], [] response_full, thought, source_log_docs, tool_calls = "", "", [], []
attachment_ids = []
if attachments:
attachment_ids = [attachment["id"] for attachment in attachments]
logger.info(
f"Processing request with {len(attachments)} attachments: {attachment_ids}"
)
answer = agent.gen(query=question, retriever=retriever) answer = agent.gen(query=question, retriever=retriever)
@@ -310,19 +307,20 @@ def complete_stream(
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
elif "tool_calls" in line: elif "tool_calls" in line:
tool_calls = line["tool_calls"] tool_calls = line["tool_calls"]
data = json.dumps({"type": "tool_calls", "tool_calls": tool_calls})
yield f"data: {data}\n\n"
elif "thought" in line: elif "thought" in line:
thought += line["thought"] thought += line["thought"]
data = json.dumps({"type": "thought", "thought": line["thought"]}) data = json.dumps({"type": "thought", "thought": line["thought"]})
yield f"data: {data}\n\n" yield f"data: {data}\n\n"
elif "type" in line:
data = json.dumps(line)
yield f"data: {data}\n\n"
if isNoneDoc: if isNoneDoc:
for doc in source_log_docs: for doc in source_log_docs:
doc["source"] = "None" doc["source"] = "None"
llm = LLMCreator.create_llm( llm = LLMCreator.create_llm(
settings.LLM_NAME, settings.LLM_PROVIDER,
api_key=settings.API_KEY, api_key=settings.API_KEY,
user_api_key=user_api_key, user_api_key=user_api_key,
decoded_token=decoded_token, decoded_token=decoded_token,
@@ -340,6 +338,7 @@ def complete_stream(
decoded_token, decoded_token,
index, index,
api_key=user_api_key, api_key=user_api_key,
attachment_ids=attachment_ids,
agent_id=agent_id, agent_id=agent_id,
is_shared_usage=is_shared_usage, is_shared_usage=is_shared_usage,
shared_token=shared_token, shared_token=shared_token,
@@ -446,17 +445,14 @@ class Stream(Resource):
attachment_ids = data.get("attachments", []) attachment_ids = data.get("attachments", [])
index = data.get("index", None) index = data.get("index", None)
chunks_from_request = data.get("chunks", 2) chunks = int(data.get("chunks", 2))
chunks = chunks_from_request if str(chunks_from_request) == 'Auto' else int(chunks_from_request)
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY) token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic") retriever_name = data.get("retriever", "classic")
agent_id = data.get("agent_id", None) agent_id = data.get("agent_id", None)
agent_type = settings.AGENT_NAME agent_type = settings.AGENT_NAME
decoded_token = getattr(request, "decoded_token", None) decoded_token = getattr(request, "decoded_token", None)
user_sub = decoded_token.get("sub") if decoded_token else None user_sub = decoded_token.get("sub") if decoded_token else None
agent_key, is_shared_usage, shared_token = get_agent_key( agent_key, is_shared_usage, shared_token = get_agent_key(agent_id, user_sub)
agent_id, user_sub
)
if agent_key: if agent_key:
data.update({"api_key": agent_key}) data.update({"api_key": agent_key})
@@ -507,7 +503,7 @@ class Stream(Resource):
agent = AgentCreator.create_agent( agent = AgentCreator.create_agent(
agent_type, agent_type,
endpoint="stream", endpoint="stream",
llm_name=settings.LLM_NAME, llm_name=settings.LLM_PROVIDER,
gpt_model=gpt_model, gpt_model=gpt_model,
api_key=settings.API_KEY, api_key=settings.API_KEY,
user_api_key=user_api_key, user_api_key=user_api_key,
@@ -540,6 +536,7 @@ class Stream(Resource):
isNoneDoc=data.get("isNoneDoc"), isNoneDoc=data.get("isNoneDoc"),
index=index, index=index,
should_save_conversation=save_conv, should_save_conversation=save_conv,
attachment_ids=attachment_ids,
agent_id=agent_id, agent_id=agent_id,
is_shared_usage=is_shared_usage, is_shared_usage=is_shared_usage,
shared_token=shared_token, shared_token=shared_token,
@@ -602,6 +599,9 @@ class Answer(Resource):
"isNoneDoc": fields.Boolean( "isNoneDoc": fields.Boolean(
required=False, description="Flag indicating if no document is used" required=False, description="Flag indicating if no document is used"
), ),
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
),
}, },
) )
@@ -617,12 +617,12 @@ class Answer(Resource):
try: try:
question = data["question"] question = data["question"]
history = limit_chat_history( history = limit_chat_history(
json.loads(data.get("history", [])), gpt_model=gpt_model json.loads(data.get("history", "[]")), gpt_model=gpt_model
) )
conversation_id = data.get("conversation_id") conversation_id = data.get("conversation_id")
prompt_id = data.get("prompt_id", "default") prompt_id = data.get("prompt_id", "default")
chunks_from_request = data.get("chunks", 2) attachment_ids = data.get("attachments", [])
chunks = chunks_from_request if str(chunks_from_request) == 'Auto' else int(chunks_from_request) chunks = int(data.get("chunks", 2))
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY) token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic") retriever_name = data.get("retriever", "classic")
agent_type = settings.AGENT_NAME agent_type = settings.AGENT_NAME
@@ -651,23 +651,28 @@ class Answer(Resource):
if not decoded_token: if not decoded_token:
return make_response({"error": "Unauthorized"}, 401) return make_response({"error": "Unauthorized"}, 401)
attachments = get_attachments_content(
attachment_ids, decoded_token.get("sub")
)
prompt = get_prompt(prompt_id) prompt = get_prompt(prompt_id)
logger.info( logger.info(
f"/api/answer - request_data: {data}, source: {source}", f"/api/answer - request_data: {data}, source: {source}, attachments: {len(attachments)}",
extra={"data": json.dumps({"request_data": data, "source": source})}, extra={"data": json.dumps({"request_data": data, "source": source})},
) )
agent = AgentCreator.create_agent( agent = AgentCreator.create_agent(
agent_type, agent_type,
endpoint="api/answer", endpoint="api/answer",
llm_name=settings.LLM_NAME, llm_name=settings.LLM_PROVIDER,
gpt_model=gpt_model, gpt_model=gpt_model,
api_key=settings.API_KEY, api_key=settings.API_KEY,
user_api_key=user_api_key, user_api_key=user_api_key,
prompt=prompt, prompt=prompt,
chat_history=history, chat_history=history,
decoded_token=decoded_token, decoded_token=decoded_token,
attachments=attachments,
) )
retriever = RetrieverCreator.create_retriever( retriever = RetrieverCreator.create_retriever(
@@ -698,6 +703,7 @@ class Answer(Resource):
isNoneDoc=data.get("isNoneDoc"), isNoneDoc=data.get("isNoneDoc"),
index=None, index=None,
should_save_conversation=False, should_save_conversation=False,
attachment_ids=attachment_ids,
): ):
try: try:
event_data = line.replace("data: ", "").strip() event_data = line.replace("data: ", "").strip()
@@ -730,7 +736,7 @@ class Answer(Resource):
doc["source"] = "None" doc["source"] = "None"
llm = LLMCreator.create_llm( llm = LLMCreator.create_llm(
settings.LLM_NAME, settings.LLM_PROVIDER,
api_key=settings.API_KEY, api_key=settings.API_KEY,
user_api_key=user_api_key, user_api_key=user_api_key,
decoded_token=decoded_token, decoded_token=decoded_token,
@@ -748,6 +754,7 @@ class Answer(Resource):
llm, llm,
decoded_token, decoded_token,
api_key=user_api_key, api_key=user_api_key,
attachment_ids=attachment_ids,
) )
) )
@@ -816,8 +823,7 @@ class Search(Resource):
try: try:
question = data["question"] question = data["question"]
chunks_from_request = data.get("chunks", 2) chunks = int(data.get("chunks", 2))
chunks = chunks_from_request if str(chunks_from_request) == 'Auto' else int(chunks_from_request)
token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY) token_limit = data.get("token_limit", settings.DEFAULT_MAX_HISTORY)
retriever_name = data.get("retriever", "classic") retriever_name = data.get("retriever", "classic")

View File

@@ -37,16 +37,18 @@ def upload_index_files():
"""Upload two files(index.faiss, index.pkl) to the user's folder.""" """Upload two files(index.faiss, index.pkl) to the user's folder."""
if "user" not in request.form: if "user" not in request.form:
return {"status": "no user"} return {"status": "no user"}
user = secure_filename(request.form["user"]) user = request.form["user"]
if "name" not in request.form: if "name" not in request.form:
return {"status": "no name"} return {"status": "no name"}
job_name = secure_filename(request.form["name"]) job_name = request.form["name"]
tokens = secure_filename(request.form["tokens"]) tokens = request.form["tokens"]
retriever = secure_filename(request.form["retriever"]) retriever = request.form["retriever"]
id = secure_filename(request.form["id"]) id = request.form["id"]
type = secure_filename(request.form["type"]) type = request.form["type"]
remote_data = request.form["remote_data"] if "remote_data" in request.form else None remote_data = request.form["remote_data"] if "remote_data" in request.form else None
sync_frequency = secure_filename(request.form["sync_frequency"]) if "sync_frequency" in request.form else None sync_frequency = request.form["sync_frequency"] if "sync_frequency" in request.form else None
original_file_path = request.form.get("original_file_path")
storage = StorageCreator.get_storage() storage = StorageCreator.get_storage()
index_base_path = f"indexes/{id}" index_base_path = f"indexes/{id}"
@@ -85,6 +87,7 @@ def upload_index_files():
"retriever": retriever, "retriever": retriever,
"remote_data": remote_data, "remote_data": remote_data,
"sync_frequency": sync_frequency, "sync_frequency": sync_frequency,
"file_path": original_file_path,
} }
}, },
) )
@@ -102,6 +105,7 @@ def upload_index_files():
"retriever": retriever, "retriever": retriever,
"remote_data": remote_data, "remote_data": remote_data,
"sync_frequency": sync_frequency, "sync_frequency": sync_frequency,
"file_path": original_file_path,
} }
) )
return {"status": "ok"} return {"status": "ok"}

View File

@@ -6,16 +6,25 @@ import secrets
import shutil import shutil
import uuid import uuid
from functools import wraps from functools import wraps
from typing import Optional, Tuple
from bson.binary import Binary, UuidRepresentation from bson.binary import Binary, UuidRepresentation
from bson.dbref import DBRef from bson.dbref import DBRef
from bson.objectid import ObjectId from bson.objectid import ObjectId
from flask import Blueprint, current_app, jsonify, make_response, redirect, request from flask import (
Blueprint,
current_app,
jsonify,
make_response,
redirect,
request,
Response,
)
from flask_restx import fields, inputs, Namespace, Resource from flask_restx import fields, inputs, Namespace, Resource
from pymongo import ReturnDocument
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from application.agents.tools.tool_manager import ToolManager from application.agents.tools.tool_manager import ToolManager
from pymongo import ReturnDocument
from application.api.user.tasks import ( from application.api.user.tasks import (
ingest, ingest,
@@ -28,7 +37,12 @@ from application.core.settings import settings
from application.extensions import api from application.extensions import api
from application.storage.storage_creator import StorageCreator from application.storage.storage_creator import StorageCreator
from application.tts.google_tts import GoogleTTS from application.tts.google_tts import GoogleTTS
from application.utils import check_required_fields, validate_function_name from application.utils import (
check_required_fields,
generate_image_url,
safe_filename,
validate_function_name,
)
from application.vectorstore.vector_creator import VectorCreator from application.vectorstore.vector_creator import VectorCreator
storage = StorageCreator.get_storage() storage = StorageCreator.get_storage()
@@ -45,6 +59,7 @@ shared_conversations_collections = db["shared_conversations"]
users_collection = db["users"] users_collection = db["users"]
user_logs_collection = db["user_logs"] user_logs_collection = db["user_logs"]
user_tools_collection = db["user_tools"] user_tools_collection = db["user_tools"]
attachments_collection = db["attachments"]
agents_collection.create_index( agents_collection.create_index(
[("shared", 1)], [("shared", 1)],
@@ -142,6 +157,29 @@ def get_vector_store(source_id):
return store return store
def handle_image_upload(
request, existing_url: str, user: str, storage, base_path: str = "attachments/"
) -> Tuple[str, Optional[Response]]:
image_url = existing_url
if "image" in request.files:
file = request.files["image"]
if file.filename != "":
filename = secure_filename(file.filename)
upload_path = f"{settings.UPLOAD_FOLDER.rstrip('/')}/{user}/{base_path.rstrip('/')}/{uuid.uuid4()}_{filename}"
try:
storage.save_file(file, upload_path, storage_class="STANDARD")
image_url = upload_path
except Exception as e:
current_app.logger.error(f"Error uploading image: {e}")
return None, make_response(
jsonify({"success": False, "message": "Image upload failed"}),
400,
)
return image_url, None
@user_ns.route("/api/delete_conversation") @user_ns.route("/api/delete_conversation")
class DeleteConversation(Resource): class DeleteConversation(Resource):
@api.doc( @api.doc(
@@ -252,13 +290,39 @@ class GetSingleConversation(Resource):
) )
if not conversation: if not conversation:
return make_response(jsonify({"status": "not found"}), 404) return make_response(jsonify({"status": "not found"}), 404)
# Process queries to include attachment names
queries = conversation["queries"]
for query in queries:
if "attachments" in query and query["attachments"]:
attachment_details = []
for attachment_id in query["attachments"]:
try:
attachment = attachments_collection.find_one(
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
except Exception as err: except Exception as err:
current_app.logger.error( current_app.logger.error(
f"Error retrieving conversation: {err}", exc_info=True f"Error retrieving conversation: {err}", exc_info=True
) )
return make_response(jsonify({"success": False}), 400) return make_response(jsonify({"success": False}), 400)
data = { data = {
"queries": conversation["queries"], "queries": queries,
"agent_id": conversation.get("agent_id"), "agent_id": conversation.get("agent_id"),
"is_shared_usage": conversation.get("is_shared_usage", False), "is_shared_usage": conversation.get("is_shared_usage", False),
"shared_token": conversation.get("shared_token", None), "shared_token": conversation.get("shared_token", None),
@@ -475,29 +539,30 @@ class UploadFile(Resource):
), ),
400, 400,
) )
user = secure_filename(decoded_token.get("sub")) user = decoded_token.get("sub")
job_name = secure_filename(request.form["name"]) job_name = request.form["name"]
# Create safe versions for filesystem operations
safe_user = safe_filename(user)
dir_name = safe_filename(job_name)
try: try:
from application.storage.storage_creator import StorageCreator
storage = StorageCreator.get_storage() storage = StorageCreator.get_storage()
base_path = f"{settings.UPLOAD_FOLDER}/{safe_user}/{dir_name}"
base_path = f"{settings.UPLOAD_FOLDER}/{user}/{job_name}"
if len(files) > 1: if len(files) > 1:
temp_files = [] temp_files = []
for file in files: for file in files:
filename = secure_filename(file.filename) filename = safe_filename(file.filename)
temp_path = f"{base_path}/temp/{filename}" temp_path = f"{base_path}/temp/{filename}"
storage.save_file(file, temp_path) storage.save_file(file, temp_path)
temp_files.append(temp_path) temp_files.append(temp_path)
print(f"Saved file: {filename}") print(f"Saved file: {filename}")
zip_filename = f"{job_name}.zip" zip_filename = f"{dir_name}.zip"
zip_path = f"{base_path}/{zip_filename}" zip_path = f"{base_path}/{zip_filename}"
zip_temp_path = None zip_temp_path = None
def create_zip_archive(temp_paths, job_name, storage): def create_zip_archive(temp_paths, dir_name, storage):
import tempfile import tempfile
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
@@ -537,7 +602,7 @@ class UploadFile(Resource):
return zip_output_path return zip_output_path
try: try:
zip_temp_path = create_zip_archive(temp_files, job_name, storage) zip_temp_path = create_zip_archive(temp_files, dir_name, storage)
with open(zip_temp_path, "rb") as zip_file: with open(zip_temp_path, "rb") as zip_file:
storage.save_file(zip_file, zip_path) storage.save_file(zip_file, zip_path)
task = ingest.delay( task = ingest.delay(
@@ -562,6 +627,8 @@ class UploadFile(Resource):
job_name, job_name,
zip_filename, zip_filename,
user, user,
dir_name,
safe_user,
) )
finally: finally:
# Clean up temporary files # Clean up temporary files
@@ -582,7 +649,7 @@ class UploadFile(Resource):
# For single file # For single file
file = files[0] file = files[0]
filename = secure_filename(file.filename) filename = safe_filename(file.filename)
file_path = f"{base_path}/{filename}" file_path = f"{base_path}/{filename}"
storage.save_file(file, file_path) storage.save_file(file, file_path)
@@ -609,6 +676,8 @@ class UploadFile(Resource):
job_name, job_name,
filename, # Corrected variable for single-file case filename, # Corrected variable for single-file case
user, user,
dir_name,
safe_user,
) )
except Exception as err: except Exception as err:
current_app.logger.error(f"Error uploading file: {err}", exc_info=True) current_app.logger.error(f"Error uploading file: {err}", exc_info=True)
@@ -810,29 +879,6 @@ class CombinedJson(Resource):
"syncFrequency": index.get("sync_frequency", ""), "syncFrequency": index.get("sync_frequency", ""),
} }
) )
if "duckduck_search" in settings.RETRIEVERS_ENABLED:
data.append(
{
"name": "DuckDuckGo Search",
"date": "duckduck_search",
"model": settings.EMBEDDINGS_NAME,
"location": "custom",
"tokens": "",
"retriever": "duckduck_search",
}
)
if "brave_search" in settings.RETRIEVERS_ENABLED:
data.append(
{
"name": "Brave Search",
"language": "en",
"date": "brave_search",
"model": settings.EMBEDDINGS_NAME,
"location": "custom",
"tokens": "",
"retriever": "brave_search",
}
)
except Exception as err: except Exception as err:
current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True) current_app.logger.error(f"Error retrieving sources: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400) return make_response(jsonify({"success": False}), 400)
@@ -1042,27 +1088,28 @@ class UpdatePrompt(Resource):
@user_ns.route("/api/get_agent") @user_ns.route("/api/get_agent")
class GetAgent(Resource): class GetAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Get a single agent by ID") @api.doc(params={"id": "Agent ID"}, description="Get agent by ID")
def get(self): def get(self):
decoded_token = request.decoded_token if not (decoded_token := request.decoded_token):
if not decoded_token: return {"success": False}, 401
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub") if not (agent_id := request.args.get("id")):
agent_id = request.args.get("id") return {"success": False, "message": "ID required"}, 400
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try: try:
agent = agents_collection.find_one( agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": user} {"_id": ObjectId(agent_id), "user": decoded_token["sub"]}
) )
if not agent: if not agent:
return make_response(jsonify({"status": "Not found"}), 404) return {"status": "Not found"}, 404
data = { data = {
"id": str(agent["_id"]), "id": str(agent["_id"]),
"name": agent["name"], "name": agent["name"],
"description": agent.get("description", ""), "description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": ( "source": (
str(source_doc["_id"]) str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef) if isinstance(agent.get("source"), DBRef)
@@ -1089,19 +1136,20 @@ class GetAgent(Resource):
"shared_metadata": agent.get("shared_metadata", {}), "shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""), "shared_token": agent.get("shared_token", ""),
} }
except Exception as err: return make_response(jsonify(data), 200)
current_app.logger.error(f"Error retrieving agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400) except Exception as e:
return make_response(jsonify(data), 200) current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
return {"success": False}, 400
@user_ns.route("/api/get_agents") @user_ns.route("/api/get_agents")
class GetAgents(Resource): class GetAgents(Resource):
@api.doc(description="Retrieve agents for the user") @api.doc(description="Retrieve agents for the user")
def get(self): def get(self):
decoded_token = request.decoded_token if not (decoded_token := request.decoded_token):
if not decoded_token: return {"success": False}, 401
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub") user = decoded_token.get("sub")
try: try:
user_doc = ensure_user_doc(user) user_doc = ensure_user_doc(user)
@@ -1113,6 +1161,9 @@ class GetAgents(Resource):
"id": str(agent["_id"]), "id": str(agent["_id"]),
"name": agent["name"], "name": agent["name"],
"description": agent.get("description", ""), "description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": ( "source": (
str(source_doc["_id"]) str(source_doc["_id"])
if isinstance(agent.get("source"), DBRef) if isinstance(agent.get("source"), DBRef)
@@ -1157,8 +1208,8 @@ class CreateAgent(Resource):
"description": fields.String( "description": fields.String(
required=True, description="Description of the agent" required=True, description="Description of the agent"
), ),
"image": fields.String( "image": fields.Raw(
required=False, description="Image URL or identifier" required=False, description="Image file upload", type="file"
), ),
"source": fields.String(required=True, description="Source ID"), "source": fields.String(required=True, description="Source ID"),
"chunks": fields.Integer(required=True, description="Chunks count"), "chunks": fields.Integer(required=True, description="Chunks count"),
@@ -1177,12 +1228,20 @@ class CreateAgent(Resource):
@api.expect(create_agent_model) @api.expect(create_agent_model)
@api.doc(description="Create a new agent") @api.doc(description="Create a new agent")
def post(self): def post(self):
decoded_token = request.decoded_token if not (decoded_token := request.decoded_token):
if not decoded_token: return {"success": False}, 401
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub") user = decoded_token.get("sub")
data = request.get_json() if request.content_type == "application/json":
data = request.get_json()
else:
print(request.form)
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
print(f"Received data: {data}")
if data.get("status") not in ["draft", "published"]: if data.get("status") not in ["draft", "published"]:
return make_response( return make_response(
jsonify({"success": False, "message": "Invalid status"}), 400 jsonify({"success": False, "message": "Invalid status"}), 400
@@ -1203,13 +1262,20 @@ class CreateAgent(Resource):
missing_fields = check_required_fields(data, required_fields) missing_fields = check_required_fields(data, required_fields)
if missing_fields: if missing_fields:
return missing_fields return missing_fields
image_url, error = handle_image_upload(request, "", user, storage)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
try: try:
key = str(uuid.uuid4()) key = str(uuid.uuid4())
new_agent = { new_agent = {
"user": user, "user": user,
"name": data.get("name"), "name": data.get("name"),
"description": data.get("description", ""), "description": data.get("description", ""),
"image": data.get("image", ""), "image": image_url,
"source": ( "source": (
DBRef("sources", ObjectId(data.get("source"))) DBRef("sources", ObjectId(data.get("source")))
if ObjectId.is_valid(data.get("source")) if ObjectId.is_valid(data.get("source"))
@@ -1267,11 +1333,18 @@ class UpdateAgent(Resource):
@api.expect(update_agent_model) @api.expect(update_agent_model)
@api.doc(description="Update an existing agent") @api.doc(description="Update an existing agent")
def put(self, agent_id): def put(self, agent_id):
decoded_token = request.decoded_token if not (decoded_token := request.decoded_token):
if not decoded_token: return {"success": False}, 401
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub") user = decoded_token.get("sub")
data = request.get_json() if request.content_type == "application/json":
data = request.get_json()
else:
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
if not ObjectId.is_valid(agent_id): if not ObjectId.is_valid(agent_id):
return make_response( return make_response(
@@ -1296,6 +1369,15 @@ class UpdateAgent(Resource):
), ),
404, 404,
) )
image_url, error = handle_image_upload(
request, existing_agent.get("image", ""), user, storage
)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
update_fields = {} update_fields = {}
allowed_fields = [ allowed_fields = [
"name", "name",
@@ -1367,6 +1449,8 @@ class UpdateAgent(Resource):
) )
else: else:
update_fields[field] = data[field] update_fields[field] = data[field]
if image_url:
update_fields["image"] = image_url
if not update_fields: if not update_fields:
return make_response( return make_response(
jsonify({"success": False, "message": "No update data provided"}), 400 jsonify({"success": False, "message": "No update data provided"}), 400
@@ -1515,6 +1599,9 @@ class PinnedAgents(Resource):
"id": str(agent["_id"]), "id": str(agent["_id"]),
"name": agent.get("name", ""), "name": agent.get("name", ""),
"description": agent.get("description", ""), "description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"source": ( "source": (
str(db.dereference(agent["source"])["_id"]) str(db.dereference(agent["source"])["_id"])
if "source" in agent if "source" in agent
@@ -1675,6 +1762,11 @@ class SharedAgent(Resource):
"id": agent_id, "id": agent_id,
"user": shared_agent.get("user", ""), "user": shared_agent.get("user", ""),
"name": shared_agent.get("name", ""), "name": shared_agent.get("name", ""),
"image": (
generate_image_url(shared_agent["image"])
if shared_agent.get("image")
else ""
),
"description": shared_agent.get("description", ""), "description": shared_agent.get("description", ""),
"tools": shared_agent.get("tools", []), "tools": shared_agent.get("tools", []),
"tool_details": resolve_tool_details(shared_agent.get("tools", [])), "tool_details": resolve_tool_details(shared_agent.get("tools", [])),
@@ -1750,6 +1842,9 @@ class SharedAgents(Resource):
"id": str(agent["_id"]), "id": str(agent["_id"]),
"name": agent.get("name", ""), "name": agent.get("name", ""),
"description": agent.get("description", ""), "description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) if agent.get("image") else ""
),
"tools": agent.get("tools", []), "tools": agent.get("tools", []),
"tool_details": resolve_tool_details(agent.get("tools", [])), "tool_details": resolve_tool_details(agent.get("tools", [])),
"agent_type": agent.get("agent_type", ""), "agent_type": agent.get("agent_type", ""),
@@ -2205,7 +2300,7 @@ class GetPubliclySharedConversations(Resource):
return make_response( return make_response(
jsonify( jsonify(
{ {
"sucess": False, "success": False,
"error": "might have broken url or the conversation does not exist", "error": "might have broken url or the conversation does not exist",
} }
), ),
@@ -2214,11 +2309,35 @@ class GetPubliclySharedConversations(Resource):
conversation_queries = conversation["queries"][ conversation_queries = conversation["queries"][
: (shared["first_n_queries"]) : (shared["first_n_queries"])
] ]
for query in conversation_queries:
if "attachments" in query and query["attachments"]:
attachment_details = []
for attachment_id in query["attachments"]:
try:
attachment = attachments_collection.find_one(
{"_id": ObjectId(attachment_id)}
)
if attachment:
attachment_details.append(
{
"id": str(attachment["_id"]),
"fileName": attachment.get(
"filename", "Unknown file"
),
}
)
except Exception as e:
current_app.logger.error(
f"Error retrieving attachment {attachment_id}: {e}",
exc_info=True,
)
query["attachments"] = attachment_details
else: else:
return make_response( return make_response(
jsonify( jsonify(
{ {
"sucess": False, "success": False,
"error": "might have broken url or the conversation does not exist", "error": "might have broken url or the conversation does not exist",
} }
), ),
@@ -3416,7 +3535,7 @@ class StoreAttachment(Resource):
jsonify({"status": "error", "message": "Missing file"}), jsonify({"status": "error", "message": "Missing file"}),
400, 400,
) )
user = secure_filename(decoded_token.get("sub")) user = safe_filename(decoded_token.get("sub"))
try: try:
attachment_id = ObjectId() attachment_id = ObjectId()
@@ -3447,3 +3566,30 @@ class StoreAttachment(Resource):
except Exception as err: except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}", exc_info=True) current_app.logger.error(f"Error storing attachment: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": str(err)}), 400) return make_response(jsonify({"success": False, "error": str(err)}), 400)
@user_ns.route("/api/images/<path:image_path>")
class ServeImage(Resource):
@api.doc(description="Serve an image from storage")
def get(self, image_path):
try:
file_obj = storage.get_file(image_path)
extension = image_path.split(".")[-1].lower()
content_type = f"image/{extension}"
if extension == "jpg":
content_type = "image/jpeg"
response = make_response(file_obj.read())
response.headers.set("Content-Type", content_type)
response.headers.set("Cache-Control", "max-age=86400")
return response
except FileNotFoundError:
return make_response(
jsonify({"success": False, "message": "Image not found"}), 404
)
except Exception as e:
current_app.logger.error(f"Error serving image: {e}")
return make_response(
jsonify({"success": False, "message": "Error retrieving image"}), 500
)

View File

@@ -11,8 +11,8 @@ from application.worker import (
@celery.task(bind=True) @celery.task(bind=True)
def ingest(self, directory, formats, name_job, filename, user): def ingest(self, directory, formats, job_name, filename, user, dir_name, user_dir):
resp = ingest_worker(self, directory, formats, name_job, filename, user) resp = ingest_worker(self, directory, formats, job_name, filename, user, dir_name, user_dir)
return resp return resp

View File

@@ -11,18 +11,18 @@ current_dir = os.path.dirname(
class Settings(BaseSettings): class Settings(BaseSettings):
AUTH_TYPE: Optional[str] = None AUTH_TYPE: Optional[str] = None
LLM_NAME: str = "docsgpt" LLM_PROVIDER: str = "docsgpt"
MODEL_NAME: Optional[str] = ( LLM_NAME: Optional[str] = (
None # if LLM_NAME is openai, MODEL_NAME can be gpt-4 or gpt-3.5-turbo None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo
) )
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2" EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
CELERY_BROKER_URL: str = "redis://localhost:6379/0" CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1" CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
MONGO_URI: str = "mongodb://localhost:27017/docsgpt" MONGO_URI: str = "mongodb://localhost:27017/docsgpt"
MONGO_DB_NAME: str = "docsgpt" MONGO_DB_NAME: str = "docsgpt"
MODEL_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf") LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150 DEFAULT_MAX_HISTORY: int = 150
MODEL_TOKEN_LIMITS: dict = { LLM_TOKEN_LIMITS: dict = {
"gpt-4o-mini": 128000, "gpt-4o-mini": 128000,
"gpt-3.5-turbo": 4096, "gpt-3.5-turbo": 4096,
"claude-2": 1e5, "claude-2": 1e5,
@@ -33,8 +33,11 @@ class Settings(BaseSettings):
VECTOR_STORE: str = ( VECTOR_STORE: str = (
"faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb"
) )
RETRIEVERS_ENABLED: list = ["classic_rag", "duckduck_search"] # also brave_search RETRIEVERS_ENABLED: list = ["classic_rag"]
AGENT_NAME: str = "classic" AGENT_NAME: str = "classic"
FALLBACK_LLM_PROVIDER: Optional[str] = None # provider for fallback llm
FALLBACK_LLM_NAME: Optional[str] = None # model name for fallback llm
FALLBACK_LLM_API_KEY: Optional[str] = None # api key for fallback llm
# LLM Cache # LLM Cache
CACHE_REDIS_URL: str = "redis://localhost:6379/2" CACHE_REDIS_URL: str = "redis://localhost:6379/2"
@@ -96,11 +99,10 @@ class Settings(BaseSettings):
LANCEDB_TABLE_NAME: Optional[str] = ( LANCEDB_TABLE_NAME: Optional[str] = (
"docsgpts" # Name of the table to use for storing vectors "docsgpts" # Name of the table to use for storing vectors
) )
BRAVE_SEARCH_API_KEY: Optional[str] = None
FLASK_DEBUG_MODE: bool = False FLASK_DEBUG_MODE: bool = False
STORAGE_TYPE: str = "local" # local or s3 STORAGE_TYPE: str = "local" # local or s3
URL_STRATEGY: str = "backend" # backend or s3
JWT_SECRET_KEY: str = "" JWT_SECRET_KEY: str = ""

View File

@@ -1,53 +1,117 @@
import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from application.cache import gen_cache, stream_cache from application.cache import gen_cache, stream_cache
from application.core.settings import settings
from application.usage import gen_token_usage, stream_token_usage from application.usage import gen_token_usage, stream_token_usage
logger = logging.getLogger(__name__)
class BaseLLM(ABC): class BaseLLM(ABC):
def __init__(self, decoded_token=None): def __init__(
self,
decoded_token=None,
):
self.decoded_token = decoded_token self.decoded_token = decoded_token
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0} self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
self.fallback_provider = settings.FALLBACK_LLM_PROVIDER
self.fallback_model_name = settings.FALLBACK_LLM_NAME
self.fallback_llm_api_key = settings.FALLBACK_LLM_API_KEY
self._fallback_llm = None
def _apply_decorator(self, method, decorators, *args, **kwargs): @property
for decorator in decorators: def fallback_llm(self):
method = decorator(method) """Lazy-loaded fallback LLM instance."""
return method(self, *args, **kwargs) if (
self._fallback_llm is None
and self.fallback_provider
and self.fallback_model_name
):
try:
from application.llm.llm_creator import LLMCreator
self._fallback_llm = LLMCreator.create_llm(
self.fallback_provider,
self.fallback_llm_api_key,
None,
self.decoded_token,
)
except Exception as e:
logger.error(
f"Failed to initialize fallback LLM: {str(e)}", exc_info=True
)
return self._fallback_llm
def _execute_with_fallback(
self, method_name: str, decorators: list, *args, **kwargs
):
"""
Unified method execution with fallback support.
Args:
method_name: Name of the raw method ('_raw_gen' or '_raw_gen_stream')
decorators: List of decorators to apply
*args: Positional arguments
**kwargs: Keyword arguments
"""
def decorated_method():
method = getattr(self, method_name)
for decorator in decorators:
method = decorator(method)
return method(self, *args, **kwargs)
try:
return decorated_method()
except Exception as e:
if not self.fallback_llm:
logger.error(f"Primary LLM failed and no fallback available: {str(e)}")
raise
logger.warning(
f"Falling back to {self.fallback_provider}/{self.fallback_model_name}. Error: {str(e)}"
)
fallback_method = getattr(
self.fallback_llm, method_name.replace("_raw_", "")
)
return fallback_method(*args, **kwargs)
def gen(self, model, messages, stream=False, tools=None, *args, **kwargs):
decorators = [gen_token_usage, gen_cache]
return self._execute_with_fallback(
"_raw_gen",
decorators,
model=model,
messages=messages,
stream=stream,
tools=tools,
*args,
**kwargs,
)
def gen_stream(self, model, messages, stream=True, tools=None, *args, **kwargs):
decorators = [stream_cache, stream_token_usage]
return self._execute_with_fallback(
"_raw_gen_stream",
decorators,
model=model,
messages=messages,
stream=stream,
tools=tools,
*args,
**kwargs,
)
@abstractmethod @abstractmethod
def _raw_gen(self, model, messages, stream, tools, *args, **kwargs): def _raw_gen(self, model, messages, stream, tools, *args, **kwargs):
pass pass
def gen(self, model, messages, stream=False, tools=None, *args, **kwargs):
decorators = [gen_token_usage, gen_cache]
return self._apply_decorator(
self._raw_gen,
decorators=decorators,
model=model,
messages=messages,
stream=stream,
tools=tools,
*args,
**kwargs
)
@abstractmethod @abstractmethod
def _raw_gen_stream(self, model, messages, stream, *args, **kwargs): def _raw_gen_stream(self, model, messages, stream, *args, **kwargs):
pass pass
def gen_stream(self, model, messages, stream=True, tools=None, *args, **kwargs):
decorators = [stream_cache, stream_token_usage]
return self._apply_decorator(
self._raw_gen_stream,
decorators=decorators,
model=model,
messages=messages,
stream=stream,
tools=tools,
*args,
**kwargs
)
def supports_tools(self): def supports_tools(self):
return hasattr(self, "_supports_tools") and callable( return hasattr(self, "_supports_tools") and callable(
getattr(self, "_supports_tools") getattr(self, "_supports_tools")
@@ -55,11 +119,11 @@ class BaseLLM(ABC):
def _supports_tools(self): def _supports_tools(self):
raise NotImplementedError("Subclass must implement _supports_tools method") raise NotImplementedError("Subclass must implement _supports_tools method")
def get_supported_attachment_types(self): def get_supported_attachment_types(self):
""" """
Return a list of MIME types supported by this LLM for file uploads. Return a list of MIME types supported by this LLM for file uploads.
Returns: Returns:
list: List of supported MIME types list: List of supported MIME types
""" """

View File

View File

@@ -0,0 +1,335 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, Generator, List, Optional, Union
from application.logging import build_stack_data
logger = logging.getLogger(__name__)
@dataclass
class ToolCall:
"""Represents a tool/function call from the LLM."""
id: str
name: str
arguments: Union[str, Dict]
index: Optional[int] = None
@classmethod
def from_dict(cls, data: Dict) -> "ToolCall":
"""Create ToolCall from dictionary."""
return cls(
id=data.get("id", ""),
name=data.get("name", ""),
arguments=data.get("arguments", {}),
index=data.get("index"),
)
@dataclass
class LLMResponse:
"""Represents a response from the LLM."""
content: str
tool_calls: List[ToolCall]
finish_reason: str
raw_response: Any
@property
def requires_tool_call(self) -> bool:
"""Check if the response requires tool calls."""
return bool(self.tool_calls) and self.finish_reason == "tool_calls"
class LLMHandler(ABC):
"""Abstract base class for LLM handlers."""
def __init__(self):
self.llm_calls = []
self.tool_calls = []
@abstractmethod
def parse_response(self, response: Any) -> LLMResponse:
"""Parse raw LLM response into standardized format."""
pass
@abstractmethod
def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict:
"""Create a tool result message for the conversation history."""
pass
@abstractmethod
def _iterate_stream(self, response: Any) -> Generator:
"""Iterate through streaming response chunks."""
pass
def process_message_flow(
self,
agent,
initial_response,
tools_dict: Dict,
messages: List[Dict],
attachments: Optional[List] = None,
stream: bool = False,
) -> Union[str, Generator]:
"""
Main orchestration method for processing LLM message flow.
Args:
agent: The agent instance
initial_response: Initial LLM response
tools_dict: Dictionary of available tools
messages: Conversation history
attachments: Optional attachments
stream: Whether to use streaming
Returns:
Final response or generator for streaming
"""
messages = self.prepare_messages(agent, messages, attachments)
if stream:
return self.handle_streaming(agent, initial_response, tools_dict, messages)
else:
return self.handle_non_streaming(
agent, initial_response, tools_dict, messages
)
def prepare_messages(
self, agent, messages: List[Dict], attachments: Optional[List] = None
) -> List[Dict]:
"""
Prepare messages with attachments and provider-specific formatting.
Args:
agent: The agent instance
messages: Original messages
attachments: List of attachments
Returns:
Prepared messages list
"""
if not attachments:
return messages
logger.info(f"Preparing messages with {len(attachments)} attachments")
supported_types = agent.llm.get_supported_attachment_types()
supported_attachments = [
a for a in attachments if a.get("mime_type") in supported_types
]
unsupported_attachments = [
a for a in attachments if a.get("mime_type") not in supported_types
]
# Process supported attachments with the LLM's custom method
if supported_attachments:
logger.info(
f"Processing {len(supported_attachments)} supported attachments"
)
messages = agent.llm.prepare_messages_with_attachments(
messages, supported_attachments
)
# Process unsupported attachments with default method
if unsupported_attachments:
logger.info(
f"Processing {len(unsupported_attachments)} unsupported attachments"
)
messages = self._append_unsupported_attachments(
messages, unsupported_attachments
)
return messages
def _append_unsupported_attachments(
self, messages: List[Dict], attachments: List[Dict]
) -> List[Dict]:
"""
Default method to append unsupported attachment content to system prompt.
Args:
messages: Current messages
attachments: List of unsupported attachments
Returns:
Updated messages list
"""
prepared_messages = messages.copy()
attachment_texts = []
for attachment in attachments:
logger.info(f"Adding attachment {attachment.get('id')} to context")
if "content" in attachment:
attachment_texts.append(
f"Attached file content:\n\n{attachment['content']}"
)
if attachment_texts:
combined_text = "\n\n".join(attachment_texts)
system_msg = next(
(msg for msg in prepared_messages if msg.get("role") == "system"),
{"role": "system", "content": ""},
)
if system_msg not in prepared_messages:
prepared_messages.insert(0, system_msg)
system_msg["content"] += f"\n\n{combined_text}"
return prepared_messages
def handle_tool_calls(
self, agent, tool_calls: List[ToolCall], tools_dict: Dict, messages: List[Dict]
) -> Generator:
"""
Execute tool calls and update conversation history.
Args:
agent: The agent instance
tool_calls: List of tool calls to execute
tools_dict: Available tools dictionary
messages: Current conversation history
Returns:
Updated messages list
"""
updated_messages = messages.copy()
for call in tool_calls:
try:
self.tool_calls.append(call)
tool_executor_gen = agent._execute_tool_action(tools_dict, call)
while True:
try:
yield next(tool_executor_gen)
except StopIteration as e:
tool_response, call_id = e.value
break
updated_messages.append(
{
"role": "assistant",
"content": [
{
"function_call": {
"name": call.name,
"args": call.arguments,
"call_id": call_id,
}
}
],
}
)
updated_messages.append(self.create_tool_message(call, tool_response))
except Exception as e:
logger.error(f"Error executing tool: {str(e)}", exc_info=True)
updated_messages.append(
{
"role": "tool",
"content": f"Error executing tool: {str(e)}",
"tool_call_id": call.id,
}
)
return updated_messages
def handle_non_streaming(
self, agent, response: Any, tools_dict: Dict, messages: List[Dict]
) -> Generator:
"""
Handle non-streaming response flow.
Args:
agent: The agent instance
response: Current LLM response
tools_dict: Available tools dictionary
messages: Conversation history
Returns:
Final response after processing all tool calls
"""
parsed = self.parse_response(response)
self.llm_calls.append(build_stack_data(agent.llm))
while parsed.requires_tool_call:
tool_handler_gen = self.handle_tool_calls(
agent, parsed.tool_calls, tools_dict, messages
)
while True:
try:
yield next(tool_handler_gen)
except StopIteration as e:
messages = e.value
break
response = agent.llm.gen(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
parsed = self.parse_response(response)
self.llm_calls.append(build_stack_data(agent.llm))
return parsed.content
def handle_streaming(
self, agent, response: Any, tools_dict: Dict, messages: List[Dict]
) -> Generator:
"""
Handle streaming response flow.
Args:
agent: The agent instance
response: Current LLM response
tools_dict: Available tools dictionary
messages: Conversation history
Yields:
Streaming response chunks
"""
buffer = ""
tool_calls = {}
for chunk in self._iterate_stream(response):
if isinstance(chunk, str):
yield chunk
continue
parsed = self.parse_response(chunk)
if parsed.tool_calls:
for call in parsed.tool_calls:
if call.index not in tool_calls:
tool_calls[call.index] = call
else:
existing = tool_calls[call.index]
if call.id:
existing.id = call.id
if call.name:
existing.name = call.name
if call.arguments:
existing.arguments += call.arguments
if parsed.finish_reason == "tool_calls":
tool_handler_gen = self.handle_tool_calls(
agent, list(tool_calls.values()), tools_dict, messages
)
while True:
try:
yield next(tool_handler_gen)
except StopIteration as e:
messages = e.value
break
tool_calls = {}
response = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))
yield from self.handle_streaming(agent, response, tools_dict, messages)
return
if parsed.content:
buffer += parsed.content
yield buffer
buffer = ""
if parsed.finish_reason == "stop":
return

View File

@@ -0,0 +1,78 @@
import uuid
from typing import Any, Dict, Generator
from application.llm.handlers.base import LLMHandler, LLMResponse, ToolCall
class GoogleLLMHandler(LLMHandler):
"""Handler for Google's GenAI API."""
def parse_response(self, response: Any) -> LLMResponse:
"""Parse Google response into standardized format."""
if isinstance(response, str):
return LLMResponse(
content=response,
tool_calls=[],
finish_reason="stop",
raw_response=response,
)
if hasattr(response, "candidates"):
parts = response.candidates[0].content.parts if response.candidates else []
tool_calls = [
ToolCall(
id=str(uuid.uuid4()),
name=part.function_call.name,
arguments=part.function_call.args,
)
for part in parts
if hasattr(part, "function_call") and part.function_call is not None
]
content = " ".join(
part.text
for part in parts
if hasattr(part, "text") and part.text is not None
)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
raw_response=response,
)
else:
tool_calls = []
if hasattr(response, "function_call"):
tool_calls.append(
ToolCall(
id=str(uuid.uuid4()),
name=response.function_call.name,
arguments=response.function_call.args,
)
)
return LLMResponse(
content=response.text if hasattr(response, "text") else "",
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
raw_response=response,
)
def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict:
"""Create Google-style tool message."""
from google.genai import types
return {
"role": "tool",
"content": [
types.Part.from_function_response(
name=tool_call.name, response={"result": result}
).to_json_dict()
],
}
def _iterate_stream(self, response: Any) -> Generator:
"""Iterate through Google streaming response."""
for chunk in response:
yield chunk

View File

@@ -0,0 +1,18 @@
from application.llm.handlers.base import LLMHandler
from application.llm.handlers.google import GoogleLLMHandler
from application.llm.handlers.openai import OpenAILLMHandler
class LLMHandlerCreator:
handlers = {
"openai": OpenAILLMHandler,
"google": GoogleLLMHandler,
"default": OpenAILLMHandler,
}
@classmethod
def create_handler(cls, llm_type: str, *args, **kwargs) -> LLMHandler:
handler_class = cls.handlers.get(llm_type.lower())
if not handler_class:
handler_class = OpenAILLMHandler
return handler_class(*args, **kwargs)

View File

@@ -0,0 +1,57 @@
from typing import Any, Dict, Generator
from application.llm.handlers.base import LLMHandler, LLMResponse, ToolCall
class OpenAILLMHandler(LLMHandler):
"""Handler for OpenAI API."""
def parse_response(self, response: Any) -> LLMResponse:
"""Parse OpenAI response into standardized format."""
if isinstance(response, str):
return LLMResponse(
content=response,
tool_calls=[],
finish_reason="stop",
raw_response=response,
)
message = getattr(response, "message", None) or getattr(response, "delta", None)
tool_calls = []
if hasattr(message, "tool_calls"):
tool_calls = [
ToolCall(
id=getattr(tc, "id", ""),
name=getattr(tc.function, "name", ""),
arguments=getattr(tc.function, "arguments", ""),
index=getattr(tc, "index", None),
)
for tc in message.tool_calls or []
]
return LLMResponse(
content=getattr(message, "content", ""),
tool_calls=tool_calls,
finish_reason=getattr(response, "finish_reason", ""),
raw_response=response,
)
def create_tool_message(self, tool_call: ToolCall, result: Any) -> Dict:
"""Create OpenAI-style tool message."""
return {
"role": "tool",
"content": [
{
"function_response": {
"name": tool_call.name,
"response": {"result": result},
"call_id": tool_call.id,
}
}
],
}
def _iterate_stream(self, response: Any) -> Generator:
"""Iterate through OpenAI streaming response."""
for chunk in response:
yield chunk

View File

@@ -2,6 +2,7 @@ from application.llm.base import BaseLLM
from application.core.settings import settings from application.core.settings import settings
import threading import threading
class LlamaSingleton: class LlamaSingleton:
_instances = {} _instances = {}
_lock = threading.Lock() # Add a lock for thread synchronization _lock = threading.Lock() # Add a lock for thread synchronization
@@ -29,7 +30,7 @@ class LlamaCpp(BaseLLM):
self, self,
api_key=None, api_key=None,
user_api_key=None, user_api_key=None,
llm_name=settings.MODEL_PATH, llm_name=settings.LLM_PATH,
*args, *args,
**kwargs, **kwargs,
): ):
@@ -42,14 +43,18 @@ class LlamaCpp(BaseLLM):
context = messages[0]["content"] context = messages[0]["content"]
user_question = messages[-1]["content"] user_question = messages[-1]["content"]
prompt = f"### Instruction \n {user_question} \n ### Context \n {context} \n ### Answer \n" prompt = f"### Instruction \n {user_question} \n ### Context \n {context} \n ### Answer \n"
result = LlamaSingleton.query_model(self.llama, prompt, max_tokens=150, echo=False) result = LlamaSingleton.query_model(
self.llama, prompt, max_tokens=150, echo=False
)
return result["choices"][0]["text"].split("### Answer \n")[-1] return result["choices"][0]["text"].split("### Answer \n")[-1]
def _raw_gen_stream(self, baseself, model, messages, stream=True, **kwargs): def _raw_gen_stream(self, baseself, model, messages, stream=True, **kwargs):
context = messages[0]["content"] context = messages[0]["content"]
user_question = messages[-1]["content"] user_question = messages[-1]["content"]
prompt = f"### Instruction \n {user_question} \n ### Context \n {context} \n ### Answer \n" prompt = f"### Instruction \n {user_question} \n ### Context \n {context} \n ### Answer \n"
result = LlamaSingleton.query_model(self.llama, prompt, max_tokens=150, echo=False, stream=stream) result = LlamaSingleton.query_model(
self.llama, prompt, max_tokens=150, echo=False, stream=stream
)
for item in result: for item in result:
for choice in item["choices"]: for choice in item["choices"]:
yield choice["text"] yield choice["text"]

View File

@@ -1,112 +0,0 @@
import json
from langchain_community.tools import BraveSearch
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
class BraveRetSearch(BaseRetriever):
def __init__(
self,
source,
chat_history,
prompt,
chunks=2,
token_limit=150,
gpt_model="docsgpt",
user_api_key=None,
decoded_token=None,
):
self.question = ""
self.source = source
self.chat_history = chat_history
self.prompt = prompt
self.chunks = chunks
self.gpt_model = gpt_model
self.token_limit = (
token_limit
if token_limit
< settings.MODEL_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
else settings.MODEL_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
)
self.user_api_key = user_api_key
self.decoded_token = decoded_token
def _get_data(self):
if self.chunks == 0:
docs = []
else:
search = BraveSearch.from_api_key(
api_key=settings.BRAVE_SEARCH_API_KEY,
search_kwargs={"count": int(self.chunks)},
)
results = search.run(self.question)
results = json.loads(results)
docs = []
for i in results:
try:
title = i["title"]
link = i["link"]
snippet = i["snippet"]
docs.append({"text": snippet, "title": title, "link": link})
except IndexError:
pass
if settings.LLM_NAME == "llama.cpp":
docs = [docs[0]]
return docs
def gen(self):
docs = self._get_data()
# join all page_content together with a newline
docs_together = "\n".join([doc["text"] for doc in docs])
p_chat_combine = self.prompt.replace("{summaries}", docs_together)
messages_combine = [{"role": "system", "content": p_chat_combine}]
for doc in docs:
yield {"source": doc}
if len(self.chat_history) > 0:
for i in self.chat_history:
if "prompt" in i and "response" in i:
messages_combine.append({"role": "user", "content": i["prompt"]})
messages_combine.append(
{"role": "assistant", "content": i["response"]}
)
messages_combine.append({"role": "user", "content": self.question})
llm = LLMCreator.create_llm(
settings.LLM_NAME,
api_key=settings.API_KEY,
user_api_key=self.user_api_key,
decoded_token=self.decoded_token,
)
completion = llm.gen_stream(model=self.gpt_model, messages=messages_combine)
for line in completion:
yield {"answer": str(line)}
def search(self, query: str = ""):
if query:
self.question = query
return self._get_data()
def get_params(self):
return {
"question": self.question,
"source": self.source,
"chat_history": self.chat_history,
"prompt": self.prompt,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key,
}

View File

@@ -2,16 +2,11 @@ import logging
from application.core.settings import settings from application.core.settings import settings
from application.llm.llm_creator import LLMCreator from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever from application.retriever.base import BaseRetriever
from application.vectorstore.vector_creator import VectorCreator from application.vectorstore.vector_creator import VectorCreator
logger = logging.getLogger(__name__)
class ClassicRAG(BaseRetriever): class ClassicRAG(BaseRetriever):
# Settings for Auto-Chunking
AUTO_CHUNK_MIN: int = 0
AUTO_CHUNK_MAX: int = 10
SIMILARITY_SCORE_THRESHOLD: float = 0.5
def __init__( def __init__(
self, self,
source, source,
@@ -21,7 +16,7 @@ class ClassicRAG(BaseRetriever):
token_limit=150, token_limit=150,
gpt_model="docsgpt", gpt_model="docsgpt",
user_api_key=None, user_api_key=None,
llm_name=settings.LLM_NAME, llm_name=settings.LLM_PROVIDER,
api_key=settings.API_KEY, api_key=settings.API_KEY,
decoded_token=None, decoded_token=None,
): ):
@@ -33,10 +28,10 @@ class ClassicRAG(BaseRetriever):
self.token_limit = ( self.token_limit = (
token_limit token_limit
if token_limit if token_limit
< settings.MODEL_TOKEN_LIMITS.get( < settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY self.gpt_model, settings.DEFAULT_MAX_HISTORY
) )
else settings.MODEL_TOKEN_LIMITS.get( else settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY self.gpt_model, settings.DEFAULT_MAX_HISTORY
) )
) )
@@ -49,16 +44,17 @@ class ClassicRAG(BaseRetriever):
user_api_key=self.user_api_key, user_api_key=self.user_api_key,
decoded_token=decoded_token, decoded_token=decoded_token,
) )
self.question = self._rephrase_query()
self.vectorstore = source["active_docs"] if "active_docs" in source else None self.vectorstore = source["active_docs"] if "active_docs" in source else None
self.question = self._rephrase_query()
self.decoded_token = decoded_token self.decoded_token = decoded_token
self.actual_chunks_retrieved = 0
def _rephrase_query(self): def _rephrase_query(self):
if ( if (
not self.original_question not self.original_question
or not self.chat_history or not self.chat_history
or self.chat_history == [] or self.chat_history == []
or self.chunks == 0
or self.vectorstore is None
): ):
return self.original_question return self.original_question
@@ -83,66 +79,8 @@ class ClassicRAG(BaseRetriever):
return self.original_question return self.original_question
def _get_data(self): def _get_data(self):
if self.chunks == 'Auto': if self.chunks == 0 or self.vectorstore is None:
return self._get_data_auto() docs = []
else:
return self._get_data_classic()
def _get_data_auto(self):
if not self.vectorstore:
self.actual_chunks_retrieved = 0
return []
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, self.vectorstore, settings.EMBEDDINGS_KEY
)
try:
docs_with_scores = docsearch.search_with_scores(self.question, k=self.AUTO_CHUNK_MAX)
except Exception as e:
logger.error(f"Error during search_with_scores: {e}", exc_info=True)
self.actual_chunks_retrieved = 0
return []
if not docs_with_scores:
self.actual_chunks_retrieved = 0
return []
candidate_docs = []
for doc, score in docs_with_scores:
if score >= self.SIMILARITY_SCORE_THRESHOLD:
candidate_docs.append(doc)
if len(candidate_docs) < self.AUTO_CHUNK_MIN and self.AUTO_CHUNK_MIN > 0:
final_docs_to_format = [doc for doc, score in docs_with_scores[:self.AUTO_CHUNK_MIN]]
else:
final_docs_to_format = candidate_docs
self.actual_chunks_retrieved = len(final_docs_to_format)
if not final_docs_to_format:
return []
formatted_docs = [
{
"title": i.metadata.get(
"title", i.metadata.get("post_title", i.page_content)
).split("/")[-1],
"text": i.page_content,
"source": (
i.metadata.get("source")
if i.metadata.get("source")
else "local"
),
}
for i in final_docs_to_format
]
logger.info(f"AutoRAG: Retrieved {self.actual_chunks_retrieved} chunks for query '{self.original_question}'.")
return formatted_docs
def _get_data_classic(self):
if self.chunks == 0:
return []
else: else:
docsearch = VectorCreator.create_vectorstore( docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, self.vectorstore, settings.EMBEDDINGS_KEY settings.VECTOR_STORE, self.vectorstore, settings.EMBEDDINGS_KEY
@@ -162,7 +100,8 @@ class ClassicRAG(BaseRetriever):
} }
for i in docs_temp for i in docs_temp
] ]
return docs
return docs
def gen(): def gen():
pass pass
@@ -174,24 +113,12 @@ class ClassicRAG(BaseRetriever):
return self._get_data() return self._get_data()
def get_params(self): def get_params(self):
params = { return {
"question": self.original_question, "question": self.original_question,
"rephrased_question": self.question, "rephrased_question": self.question,
"source": self.vectorstore, "source": self.vectorstore,
"chunks": self.chunks,
"token_limit": self.token_limit, "token_limit": self.token_limit,
"gpt_model": self.gpt_model, "gpt_model": self.gpt_model,
"user_api_key": self.user_api_key, "user_api_key": self.user_api_key,
} }
if self.chunks == 'Auto':
params.update({
"chunks_mode": "Auto",
"chunks_retrieved_auto": self.actual_chunks_retrieved,
"auto_chunk_min_setting": self.AUTO_CHUNK_MIN,
"auto_chunk_max_setting": self.AUTO_CHUNK_MAX,
"similarity_threshold_setting": self.SIMILARITY_SCORE_THRESHOLD,
})
else:
params["chunks_mode"] = "Classic"
params["chunks"] = self.chunks
return params

View File

@@ -1,111 +0,0 @@
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_community.utilities import DuckDuckGoSearchAPIWrapper
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
class DuckDuckSearch(BaseRetriever):
def __init__(
self,
source,
chat_history,
prompt,
chunks=2,
token_limit=150,
gpt_model="docsgpt",
user_api_key=None,
decoded_token=None,
):
self.question = ""
self.source = source
self.chat_history = chat_history
self.prompt = prompt
self.chunks = chunks
self.gpt_model = gpt_model
self.token_limit = (
token_limit
if token_limit
< settings.MODEL_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
else settings.MODEL_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
)
self.user_api_key = user_api_key
self.decoded_token = decoded_token
def _get_data(self):
if self.chunks == 0:
docs = []
else:
wrapper = DuckDuckGoSearchAPIWrapper(max_results=self.chunks)
search = DuckDuckGoSearchResults(api_wrapper=wrapper, output_format="list")
results = search.run(self.question)
docs = []
for i in results:
try:
docs.append(
{
"text": i.get("snippet", "").strip(),
"title": i.get("title", "").strip(),
"link": i.get("link", "").strip(),
}
)
except IndexError:
pass
if settings.LLM_NAME == "llama.cpp":
docs = [docs[0]]
return docs
def gen(self):
docs = self._get_data()
# join all page_content together with a newline
docs_together = "\n".join([doc["text"] for doc in docs])
p_chat_combine = self.prompt.replace("{summaries}", docs_together)
messages_combine = [{"role": "system", "content": p_chat_combine}]
for doc in docs:
yield {"source": doc}
if len(self.chat_history) > 0:
for i in self.chat_history:
if "prompt" in i and "response" in i:
messages_combine.append({"role": "user", "content": i["prompt"]})
messages_combine.append(
{"role": "assistant", "content": i["response"]}
)
messages_combine.append({"role": "user", "content": self.question})
llm = LLMCreator.create_llm(
settings.LLM_NAME,
api_key=settings.API_KEY,
user_api_key=self.user_api_key,
decoded_token=self.decoded_token,
)
completion = llm.gen_stream(model=self.gpt_model, messages=messages_combine)
for line in completion:
yield {"answer": str(line)}
def search(self, query: str = ""):
if query:
self.question = query
return self._get_data()
def get_params(self):
return {
"question": self.question,
"source": self.source,
"chat_history": self.chat_history,
"prompt": self.prompt,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key,
}

View File

@@ -1,12 +1,9 @@
from application.retriever.classic_rag import ClassicRAG from application.retriever.classic_rag import ClassicRAG
from application.retriever.duckduck_search import DuckDuckSearch
from application.retriever.brave_search import BraveRetSearch
class RetrieverCreator: class RetrieverCreator:
retrievers = { retrievers = {
"classic": ClassicRAG, "classic": ClassicRAG,
"duckduck_search": DuckDuckSearch,
"brave_search": BraveRetSearch,
"default": ClassicRAG, "default": ClassicRAG,
} }

View File

@@ -1,4 +1,5 @@
"""Base storage class for file system abstraction.""" """Base storage class for file system abstraction."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import BinaryIO, List, Callable from typing import BinaryIO, List, Callable
@@ -7,7 +8,7 @@ class BaseStorage(ABC):
"""Abstract base class for storage implementations.""" """Abstract base class for storage implementations."""
@abstractmethod @abstractmethod
def save_file(self, file_data: BinaryIO, path: str) -> dict: def save_file(self, file_data: BinaryIO, path: str, **kwargs) -> dict:
""" """
Save a file to storage. Save a file to storage.

View File

@@ -1,13 +1,14 @@
"""S3 storage implementation.""" """S3 storage implementation."""
import io import io
from typing import BinaryIO, List, Callable
import os import os
from typing import BinaryIO, Callable, List
import boto3 import boto3
from botocore.exceptions import ClientError from application.core.settings import settings
from application.storage.base import BaseStorage from application.storage.base import BaseStorage
from application.core.settings import settings from botocore.exceptions import ClientError
class S3Storage(BaseStorage): class S3Storage(BaseStorage):
@@ -20,38 +21,48 @@ class S3Storage(BaseStorage):
Args: Args:
bucket_name: S3 bucket name (optional, defaults to settings) bucket_name: S3 bucket name (optional, defaults to settings)
""" """
self.bucket_name = bucket_name or getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket") self.bucket_name = bucket_name or getattr(
settings, "S3_BUCKET_NAME", "docsgpt-test-bucket"
)
# Get credentials from settings # Get credentials from settings
aws_access_key_id = getattr(settings, "SAGEMAKER_ACCESS_KEY", None) aws_access_key_id = getattr(settings, "SAGEMAKER_ACCESS_KEY", None)
aws_secret_access_key = getattr(settings, "SAGEMAKER_SECRET_KEY", None) aws_secret_access_key = getattr(settings, "SAGEMAKER_SECRET_KEY", None)
region_name = getattr(settings, "SAGEMAKER_REGION", None) region_name = getattr(settings, "SAGEMAKER_REGION", None)
self.s3 = boto3.client( self.s3 = boto3.client(
's3', "s3",
aws_access_key_id=aws_access_key_id, aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key, aws_secret_access_key=aws_secret_access_key,
region_name=region_name region_name=region_name,
) )
def save_file(self, file_data: BinaryIO, path: str) -> dict: def save_file(
self,
file_data: BinaryIO,
path: str,
storage_class: str = "INTELLIGENT_TIERING",
**kwargs,
) -> dict:
"""Save a file to S3 storage.""" """Save a file to S3 storage."""
self.s3.upload_fileobj(file_data, self.bucket_name, path) self.s3.upload_fileobj(
file_data, self.bucket_name, path, ExtraArgs={"StorageClass": storage_class}
)
region = getattr(settings, "SAGEMAKER_REGION", None) region = getattr(settings, "SAGEMAKER_REGION", None)
return { return {
'storage_type': 's3', "storage_type": "s3",
'bucket_name': self.bucket_name, "bucket_name": self.bucket_name,
'uri': f's3://{self.bucket_name}/{path}', "uri": f"s3://{self.bucket_name}/{path}",
'region': region "region": region,
} }
def get_file(self, path: str) -> BinaryIO: def get_file(self, path: str) -> BinaryIO:
"""Get a file from S3 storage.""" """Get a file from S3 storage."""
if not self.file_exists(path): if not self.file_exists(path):
raise FileNotFoundError(f"File not found: {path}") raise FileNotFoundError(f"File not found: {path}")
file_obj = io.BytesIO() file_obj = io.BytesIO()
self.s3.download_fileobj(self.bucket_name, path, file_obj) self.s3.download_fileobj(self.bucket_name, path, file_obj)
file_obj.seek(0) file_obj.seek(0)
@@ -76,18 +87,17 @@ class S3Storage(BaseStorage):
def list_files(self, directory: str) -> List[str]: def list_files(self, directory: str) -> List[str]:
"""List all files in a directory in S3 storage.""" """List all files in a directory in S3 storage."""
# Ensure directory ends with a slash if it's not empty # Ensure directory ends with a slash if it's not empty
if directory and not directory.endswith('/'):
directory += '/'
if directory and not directory.endswith("/"):
directory += "/"
result = [] result = []
paginator = self.s3.get_paginator('list_objects_v2') paginator = self.s3.get_paginator("list_objects_v2")
pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory) pages = paginator.paginate(Bucket=self.bucket_name, Prefix=directory)
for page in pages: for page in pages:
if 'Contents' in page: if "Contents" in page:
for obj in page['Contents']: for obj in page["Contents"]:
result.append(obj['Key']) result.append(obj["Key"])
return result return result
def process_file(self, path: str, processor_func: Callable, **kwargs): def process_file(self, path: str, processor_func: Callable, **kwargs):
@@ -98,22 +108,24 @@ class S3Storage(BaseStorage):
path: Path to the file path: Path to the file
processor_func: Function that processes the file processor_func: Function that processes the file
**kwargs: Additional arguments to pass to the processor function **kwargs: Additional arguments to pass to the processor function
Returns: Returns:
The result of the processor function The result of the processor function
""" """
import tempfile
import logging import logging
import tempfile
if not self.file_exists(path): if not self.file_exists(path):
raise FileNotFoundError(f"File not found in S3: {path}") raise FileNotFoundError(f"File not found in S3: {path}")
with tempfile.NamedTemporaryFile(
with tempfile.NamedTemporaryFile(suffix=os.path.splitext(path)[1], delete=True) as temp_file: suffix=os.path.splitext(path)[1], delete=True
) as temp_file:
try: try:
# Download the file from S3 to the temporary file # Download the file from S3 to the temporary file
self.s3.download_fileobj(self.bucket_name, path, temp_file) self.s3.download_fileobj(self.bucket_name, path, temp_file)
temp_file.flush() temp_file.flush()
return processor_func(local_path=temp_file.name, **kwargs) return processor_func(local_path=temp_file.name, **kwargs)
except Exception as e: except Exception as e:
logging.error(f"Error processing S3 file {path}: {e}", exc_info=True) logging.error(f"Error processing S3 file {path}: {e}", exc_info=True)

View File

@@ -1,8 +1,12 @@
import hashlib import hashlib
import os
import re import re
import uuid
import tiktoken import tiktoken
from flask import jsonify, make_response from flask import jsonify, make_response
from werkzeug.utils import secure_filename
from application.core.settings import settings
_encoding = None _encoding = None
@@ -15,6 +19,31 @@ def get_encoding():
return _encoding return _encoding
def safe_filename(filename):
"""
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)
safe_name = secure_filename(filename)
# If secure_filename returns just the extension or an empty string
if not safe_name or safe_name == extension.lstrip("."):
return f"{str(uuid.uuid4())}{extension}"
return safe_name
def num_tokens_from_string(string: str) -> int: def num_tokens_from_string(string: str) -> int:
encoding = get_encoding() encoding = get_encoding()
if isinstance(string, str): if isinstance(string, str):
@@ -74,8 +103,8 @@ def limit_chat_history(history, max_token_limit=None, gpt_model="docsgpt"):
max_token_limit max_token_limit
if max_token_limit if max_token_limit
and max_token_limit and max_token_limit
< settings.MODEL_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY) < settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
else settings.MODEL_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY) else settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
) )
if not history: if not history:
@@ -109,3 +138,14 @@ def validate_function_name(function_name):
if not re.match(r"^[a-zA-Z0-9_-]+$", function_name): if not re.match(r"^[a-zA-Z0-9_-]+$", function_name):
return False return False
return True return True
def generate_image_url(image_path):
strategy = getattr(settings, "URL_STRATEGY", "backend")
if strategy == "s3":
bucket_name = getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket")
region_name = getattr(settings, "SAGEMAKER_REGION", "eu-central-1")
return f"https://{bucket_name}.s3.{region_name}.amazonaws.com/{image_path}"
else:
base_url = getattr(settings, "API_URL", "http://localhost:7091")
return f"{base_url}/api/images/{image_path}"

View File

@@ -58,10 +58,6 @@ class BaseVectorStore(ABC):
def search(self, *args, **kwargs): def search(self, *args, **kwargs):
pass pass
@abstractmethod
def search_with_scores(self, query: str, k: int, *args, **kwargs):
pass
def is_azure_configured(self): def is_azure_configured(self):
return settings.OPENAI_API_BASE and settings.OPENAI_API_VERSION and settings.AZURE_DEPLOYMENT_NAME return settings.OPENAI_API_BASE and settings.OPENAI_API_VERSION and settings.AZURE_DEPLOYMENT_NAME

View File

@@ -108,46 +108,6 @@ class ElasticsearchStore(BaseVectorStore):
doc_list.append(Document(page_content = hit['_source']['text'], metadata = hit['_source']['metadata'])) doc_list.append(Document(page_content = hit['_source']['text'], metadata = hit['_source']['metadata']))
return doc_list return doc_list
def search_with_scores(self, query: str, k: int, *args, **kwargs):
embeddings = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key)
vector = embeddings.embed_query(query)
knn = {
"filter": [{"match": {"metadata.source_id.keyword": self.source_id}}],
"field": "vector",
"k": k,
"num_candidates": 100,
"query_vector": vector,
}
full_query = {
"knn": knn,
"query": {
"bool": {
"must": [
{
"match": {
"text": {
"query": question,
}
}
}
],
"filter": [{"match": {"metadata.source_id.keyword": self.source_id}}],
}
},
"rank": {"rrf": {}},
}
resp = self.docsearch.search(index=self.index_name, query=full_query['query'], size=k, knn=full_query['knn'])
docs_with_scores = []
for hit in resp['hits']['hits']:
score = hit['_score']
# Normalize the score. Elasticsearch returns a score of 1.0 + cosine similarity.
similarity = max(0, score - 1.0)
doc = Document(page_content=hit['_source']['text'], metadata=hit['_source']['metadata'])
docs_with_scores.append((doc, similarity))
return docs_with_scores
def _create_index_if_not_exists( def _create_index_if_not_exists(
self, index_name, dims_length self, index_name, dims_length

View File

@@ -62,18 +62,6 @@ class FaissStore(BaseVectorStore):
def search(self, *args, **kwargs): def search(self, *args, **kwargs):
return self.docsearch.similarity_search(*args, **kwargs) return self.docsearch.similarity_search(*args, **kwargs)
def search_with_scores(self, query: str, k: int, *args, **kwargs):
docs_and_distances = self.docsearch.similarity_search_with_score(query, k, *args, **kwargs)
# Convert L2 distance to a normalized similarity score (0-1, higher is better)
docs_and_similarities = []
for doc, distance in docs_and_distances:
if distance < 0: distance = 0
similarity = 1 / (1 + distance)
docs_and_similarities.append((doc, similarity))
return docs_and_similarities
def add_texts(self, *args, **kwargs): def add_texts(self, *args, **kwargs):
return self.docsearch.add_texts(*args, **kwargs) return self.docsearch.add_texts(*args, **kwargs)

View File

@@ -2,8 +2,6 @@ from typing import List, Optional
import importlib import importlib
from application.vectorstore.base import BaseVectorStore from application.vectorstore.base import BaseVectorStore
from application.core.settings import settings from application.core.settings import settings
from application.vectorstore.document_class import Document
class LanceDBVectorStore(BaseVectorStore): class LanceDBVectorStore(BaseVectorStore):
"""Class for LanceDB Vector Store integration.""" """Class for LanceDB Vector Store integration."""
@@ -89,23 +87,6 @@ class LanceDBVectorStore(BaseVectorStore):
results = self.docsearch.search(query_embedding).limit(k).to_list() results = self.docsearch.search(query_embedding).limit(k).to_list()
return [(result["_distance"], result["text"], result["metadata"]) for result in results] return [(result["_distance"], result["text"], result["metadata"]) for result in results]
def search_with_scores(self, query: str, k: int, *args, **kwargs):
"""Perform a similarity search with scores."""
self.ensure_table_exists()
query_embedding = self._get_embeddings(settings.EMBEDDINGS_NAME, self.embeddings_key).embed_query(query)
results = self.docsearch.search(query_embedding).limit(k).to_list()
docs_with_scores = []
for result in results:
distance = result.get('_distance', float('inf'))
if distance < 0: distance = 0
# Convert L2 distance to a normalized similarity score
similarity = 1 / (1 + distance)
doc = Document(page_content=result['text'], metadata=result["metadata"])
docs_with_scores.append((doc, similarity))
return docs_with_scores
def delete_index(self): def delete_index(self):
"""Delete the entire LanceDB index (table).""" """Delete the entire LanceDB index (table)."""
if self.table: if self.table:

View File

@@ -25,16 +25,6 @@ class MilvusStore(BaseVectorStore):
def search(self, question, k=2, *args, **kwargs): def search(self, question, k=2, *args, **kwargs):
expr = f"source_id == '{self._source_id}'" expr = f"source_id == '{self._source_id}'"
return self._docsearch.similarity_search(query=question, k=k, expr=expr, *args, **kwargs) return self._docsearch.similarity_search(query=question, k=k, expr=expr, *args, **kwargs)
def search_with_scores(self, query: str, k: int, *args, **kwargs):
expr = f"source_id == '{self._source_id}'"
docs_and_distances = self._docsearch.similarity_search_with_score(query, k, expr=expr, *args, **kwargs)
docs_with_scores = []
for doc, distance in docs_and_distances:
similarity = 1.0 - distance
docs_with_scores.append((doc, max(0, similarity)))
return docs_with_scores
def add_texts(self, texts: List[str], metadatas: Optional[List[dict]], *args, **kwargs): def add_texts(self, texts: List[str], metadatas: Optional[List[dict]], *args, **kwargs):
ids = [str(uuid4()) for _ in range(len(texts))] ids = [str(uuid4()) for _ in range(len(texts))]

View File

@@ -62,40 +62,6 @@ class MongoDBVectorStore(BaseVectorStore):
metadata = doc metadata = doc
results.append(Document(text, metadata)) results.append(Document(text, metadata))
return results return results
def search_with_scores(self, query: str, k: int, *args, **kwargs):
query_vector = self._embedding.embed_query(query)
pipeline = [
{
"$vectorSearch": {
"queryVector": query_vector,
"path": self._embedding_key,
"limit": k,
"numCandidates": k * 10,
"index": self._index_name,
"filter": {"source_id": {"$eq": self._source_id}},
}
},
{
"$addFields": {
"score": {"$meta": "vectorSearchScore"}
}
}
]
cursor = self._collection.aggregate(pipeline)
results = []
for doc in cursor:
score = doc.pop("score", 0.0)
text = doc.pop(self._text_key)
doc.pop("_id")
doc.pop(self._embedding_key, None)
metadata = doc
doc = Document(page_content=text, metadata=metadata)
results.append((doc, score))
return results
def _insert_texts(self, texts, metadatas): def _insert_texts(self, texts, metadatas):
if not texts: if not texts:

View File

@@ -35,9 +35,6 @@ class QdrantStore(BaseVectorStore):
def search(self, *args, **kwargs): def search(self, *args, **kwargs):
return self._docsearch.similarity_search(filter=self._filter, *args, **kwargs) return self._docsearch.similarity_search(filter=self._filter, *args, **kwargs)
def search_with_scores(self, query: str, k: int, *args, **kwargs):
return self._docsearch.similarity_search_with_score(query=query, k=k, filter=self._filter, *args, **kwargs)
def add_texts(self, *args, **kwargs): def add_texts(self, *args, **kwargs):
return self._docsearch.add_texts(*args, **kwargs) return self._docsearch.add_texts(*args, **kwargs)

View File

@@ -143,8 +143,8 @@ def run_agent_logic(agent_config, input_data):
agent = AgentCreator.create_agent( agent = AgentCreator.create_agent(
agent_type, agent_type,
endpoint="webhook", endpoint="webhook",
llm_name=settings.LLM_NAME, llm_name=settings.LLM_PROVIDER,
gpt_model=settings.MODEL_NAME, gpt_model=settings.LLM_NAME,
api_key=settings.API_KEY, api_key=settings.API_KEY,
user_api_key=user_api_key, user_api_key=user_api_key,
prompt=prompt, prompt=prompt,
@@ -159,7 +159,7 @@ def run_agent_logic(agent_config, input_data):
prompt=prompt, prompt=prompt,
chunks=chunks, chunks=chunks,
token_limit=settings.DEFAULT_MAX_HISTORY, token_limit=settings.DEFAULT_MAX_HISTORY,
gpt_model=settings.MODEL_NAME, gpt_model=settings.LLM_NAME,
user_api_key=user_api_key, user_api_key=user_api_key,
decoded_token=decoded_token, decoded_token=decoded_token,
) )
@@ -194,7 +194,7 @@ def run_agent_logic(agent_config, input_data):
# Define the main function for ingesting and processing documents. # Define the main function for ingesting and processing documents.
def ingest_worker( def ingest_worker(
self, directory, formats, name_job, filename, user, retriever="classic" self, directory, formats, job_name, filename, user, dir_name=None, user_dir=None, retriever="classic"
): ):
""" """
Ingest and process documents. Ingest and process documents.
@@ -203,9 +203,11 @@ def ingest_worker(
self: Reference to the instance of the task. self: Reference to the instance of the task.
directory (str): Specifies the directory for ingesting ('inputs' or 'temp'). directory (str): Specifies the directory for ingesting ('inputs' or 'temp').
formats (list of str): List of file extensions to consider for ingestion (e.g., [".rst", ".md"]). formats (list of str): List of file extensions to consider for ingestion (e.g., [".rst", ".md"]).
name_job (str): Name of the job for this ingestion task. job_name (str): Name of the job for this ingestion task (original, unsanitized).
filename (str): Name of the file to be ingested. filename (str): Name of the file to be ingested.
user (str): Identifier for the user initiating the ingestion. user (str): Identifier for the user initiating the ingestion (original, unsanitized).
dir_name (str, optional): Sanitized directory name for filesystem operations.
user_dir (str, optional): Sanitized user ID for filesystem operations.
retriever (str): Type of retriever to use for processing the documents. retriever (str): Type of retriever to use for processing the documents.
Returns: Returns:
@@ -216,13 +218,13 @@ def ingest_worker(
limit = None limit = None
exclude = True exclude = True
sample = False sample = False
storage = StorageCreator.get_storage() storage = StorageCreator.get_storage()
full_path = os.path.join(directory, user, name_job) full_path = os.path.join(directory, user_dir, dir_name)
source_file_path = os.path.join(full_path, filename) source_file_path = os.path.join(full_path, filename)
logging.info(f"Ingest file: {full_path}", extra={"user": user, "job": name_job}) logging.info(f"Ingest file: {full_path}", extra={"user": user, "job": job_name})
# Create temporary working directory # Create temporary working directory
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
@@ -283,13 +285,14 @@ def ingest_worker(
for i in range(min(5, len(raw_docs))): for i in range(min(5, len(raw_docs))):
logging.info(f"Sample document {i}: {raw_docs[i]}") logging.info(f"Sample document {i}: {raw_docs[i]}")
file_data = { file_data = {
"name": name_job, "name": job_name, # Use original job_name
"file": filename, "file": filename,
"user": user, "user": user, # Use original user
"tokens": tokens, "tokens": tokens,
"retriever": retriever, "retriever": retriever,
"id": str(id), "id": str(id),
"type": "local", "type": "local",
"original_file_path": source_file_path,
} }
upload_index(vector_store_path, file_data) upload_index(vector_store_path, file_data)
@@ -301,9 +304,9 @@ def ingest_worker(
return { return {
"directory": directory, "directory": directory,
"formats": formats, "formats": formats,
"name_job": name_job, "name_job": job_name, # Use original job_name
"filename": filename, "filename": filename,
"user": user, "user": user, # Use original user
"limited": False, "limited": False,
} }
@@ -449,7 +452,7 @@ def attachment_worker(self, file_info, user):
try: try:
self.update_state(state="PROGRESS", meta={"current": 10}) self.update_state(state="PROGRESS", meta={"current": 10})
storage = StorageCreator.get_storage() storage = StorageCreator.get_storage()
self.update_state( self.update_state(
state="PROGRESS", meta={"current": 30, "status": "Processing content"} state="PROGRESS", meta={"current": 30, "status": "Processing content"}
) )
@@ -458,9 +461,11 @@ def attachment_worker(self, file_info, user):
relative_path, relative_path,
lambda local_path, **kwargs: SimpleDirectoryReader( lambda local_path, **kwargs: SimpleDirectoryReader(
input_files=[local_path], exclude_hidden=True, errors="ignore" input_files=[local_path], exclude_hidden=True, errors="ignore"
).load_data()[0].text )
.load_data()[0]
.text,
) )
token_count = num_tokens_from_string(content) token_count = num_tokens_from_string(content)
self.update_state( self.update_state(
@@ -475,6 +480,7 @@ def attachment_worker(self, file_info, user):
"_id": doc_id, "_id": doc_id,
"user": user, "user": user,
"path": relative_path, "path": relative_path,
"filename": filename,
"content": content, "content": content,
"token_count": token_count, "token_count": token_count,
"mime_type": mime_type, "mime_type": mime_type,
@@ -487,9 +493,7 @@ def attachment_worker(self, file_info, user):
f"Stored attachment with ID: {attachment_id}", extra={"user": user} f"Stored attachment with ID: {attachment_id}", extra={"user": user}
) )
self.update_state( self.update_state(state="PROGRESS", meta={"current": 100, "status": "Complete"})
state="PROGRESS", meta={"current": 100, "status": "Complete"}
)
return { return {
"filename": filename, "filename": filename,

View File

@@ -1,3 +1,4 @@
name: docsgpt-oss
services: services:
redis: redis:

View File

@@ -1,3 +1,4 @@
name: docsgpt-oss
services: services:
frontend: frontend:
build: ../frontend build: ../frontend
@@ -17,19 +18,19 @@ services:
environment: environment:
- API_KEY=$API_KEY - API_KEY=$API_KEY
- EMBEDDINGS_KEY=$API_KEY - EMBEDDINGS_KEY=$API_KEY
- LLM_PROVIDER=$LLM_PROVIDER
- LLM_NAME=$LLM_NAME - LLM_NAME=$LLM_NAME
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1
- MONGO_URI=mongodb://mongo:27017/docsgpt - MONGO_URI=mongodb://mongo:27017/docsgpt
- CACHE_REDIS_URL=redis://redis:6379/2 - CACHE_REDIS_URL=redis://redis:6379/2
- OPENAI_BASE_URL=$OPENAI_BASE_URL - OPENAI_BASE_URL=$OPENAI_BASE_URL
- MODEL_NAME=$MODEL_NAME
ports: ports:
- "7091:7091" - "7091:7091"
volumes: volumes:
- ../application/indexes:/app/application/indexes - ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs - ../application/inputs:/app/inputs
- ../application/vectors:/app/application/vectors - ../application/vectors:/app/vectors
depends_on: depends_on:
- redis - redis
- mongo - mongo
@@ -41,6 +42,7 @@ services:
environment: environment:
- API_KEY=$API_KEY - API_KEY=$API_KEY
- EMBEDDINGS_KEY=$API_KEY - EMBEDDINGS_KEY=$API_KEY
- LLM_PROVIDER=$LLM_PROVIDER
- LLM_NAME=$LLM_NAME - LLM_NAME=$LLM_NAME
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1
@@ -48,9 +50,9 @@ services:
- API_URL=http://backend:7091 - API_URL=http://backend:7091
- CACHE_REDIS_URL=redis://redis:6379/2 - CACHE_REDIS_URL=redis://redis:6379/2
volumes: volumes:
- ../application/indexes:/app/application/indexes - ../application/indexes:/app/indexes
- ../application/inputs:/app/inputs - ../application/inputs:/app/inputs
- ../application/vectors:/app/application/vectors - ../application/vectors:/app/vectors
depends_on: depends_on:
- redis - redis
- mongo - mongo

View File

@@ -4,7 +4,7 @@ metadata:
name: docsgpt-secrets name: docsgpt-secrets
type: Opaque type: Opaque
data: data:
LLM_NAME: ZG9jc2dwdA== LLM_PROVIDER: ZG9jc2dwdA==
INTERNAL_KEY: aW50ZXJuYWw= INTERNAL_KEY: aW50ZXJuYWw=
CELERY_BROKER_URL: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA== CELERY_BROKER_URL: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA==
CELERY_RESULT_BACKEND: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA== CELERY_RESULT_BACKEND: cmVkaXM6Ly9yZWRpcy1zZXJ2aWNlOjYzNzkvMA==

View File

@@ -2,5 +2,13 @@
"basics": { "basics": {
"title": "🤖 Agent Basics", "title": "🤖 Agent Basics",
"href": "/Agents/basics" "href": "/Agents/basics"
},
"api": {
"title": "🔌 Agent API",
"href": "/Agents/api"
},
"webhooks": {
"title": "🪝 Agent Webhooks",
"href": "/Agents/webhooks"
} }
} }

227
docs/pages/Agents/api.mdx Normal file
View File

@@ -0,0 +1,227 @@
---
title: Interacting with Agents via API
description: Learn how to programmatically interact with DocsGPT Agents using the streaming and non-streaming API endpoints.
---
import { Callout, Tabs } from 'nextra/components';
# Interacting with Agents via API
DocsGPT Agents can be accessed programmatically through a dedicated API, allowing you to integrate their specialized capabilities into your own applications, scripts, and workflows. This guide covers the two primary methods for interacting with an agent: the streaming API for real-time responses and the non-streaming API for a single, consolidated answer.
When you use an API key generated for a specific agent, you do not need to pass `prompt`, `tools` etc. The agent's configuration (including its prompt, selected tools, and knowledge sources) is already associated with its unique API key.
### API Endpoints
- **Non-Streaming:** `http://localhost:7091/api/answer`
- **Streaming:** `http://localhost:7091/stream`
<Callout type="info">
For DocsGPT Cloud, use `https://gptcloud.arc53.com/` as the base URL.
</Callout>
For more technical details, you can explore the API swagger documentation available for the cloud version or your local instance.
---
## Non-Streaming API (`/api/answer`)
This is a standard synchronous endpoint. It waits for the agent to fully process the request and returns a single JSON object with the complete answer. This is the simplest method and is ideal for backend processes where a real-time feed is not required.
### Request
- **Endpoint:** `/api/answer`
- **Method:** `POST`
- **Payload:**
- `question` (string, required): The user's query or input for the agent.
- `api_key` (string, required): The unique API key for the agent you wish to interact with.
- `history` (string, optional): A JSON string representing the conversation history, e.g., `[{\"prompt\": \"first question\", \"answer\": \"first answer\"}]`.
### Response
A single JSON object containing:
- `answer`: The complete, final answer from the agent.
- `sources`: A list of sources the agent consulted.
- `conversation_id`: The unique ID for the interaction.
### Examples
<Tabs items={['cURL', 'Python', 'JavaScript']}>
<Tabs.Tab>
```bash
curl -X POST http://localhost:7091/api/answer \
-H "Content-Type: application/json" \
-d '{
"question": "your question here",
"api_key": "your_agent_api_key"
}'
```
</Tabs.Tab>
<Tabs.Tab>
```python
import requests
API_URL = "http://localhost:7091/api/answer"
API_KEY = "your_agent_api_key"
QUESTION = "your question here"
response = requests.post(
API_URL,
json={"question": QUESTION, "api_key": API_KEY}
)
if response.status_code == 200:
print(response.json())
else:
print(f"Error: {response.status_code}")
print(response.text)
```
</Tabs.Tab>
<Tabs.Tab>
```javascript
const apiUrl = 'http://localhost:7091/api/answer';
const apiKey = 'your_agent_api_key';
const question = 'your question here';
async function getAnswer() {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question, api_key: apiKey }),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
console.log(data);
} catch (error) {
console.error("Failed to fetch answer:", error);
}
}
getAnswer();
```
</Tabs.Tab>
</Tabs>
---
## Streaming API (`/stream`)
The `/stream` endpoint uses Server-Sent Events (SSE) to push data in real-time. This is ideal for applications where you want to display the response as it's being generated, such as in a live chatbot interface.
### Request
- **Endpoint:** `/stream`
- **Method:** `POST`
- **Payload:** Same as the non-streaming API.
### Response (SSE Stream)
The stream consists of multiple `data:` events, each containing a JSON object. Your client should listen for these events and process them based on their `type`.
**Event Types:**
- `answer`: A chunk of the agent's final answer.
- `source`: A document or source used by the agent.
- `thought`: A reasoning step from the agent (for ReAct agents).
- `id`: The unique `conversation_id` for the interaction.
- `error`: An error message.
- `end`: A final message indicating the stream has concluded.
### Examples
<Tabs items={['cURL', 'Python', 'JavaScript']}>
<Tabs.Tab>
```bash
curl -X POST http://localhost:7091/stream \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{
"question": "your question here",
"api_key": "your_agent_api_key"
}'
```
</Tabs.Tab>
<Tabs.Tab>
```python
import requests
import json
API_URL = "http://localhost:7091/stream"
payload = {
"question": "your question here",
"api_key": "your_agent_api_key"
}
with requests.post(API_URL, json=payload, stream=True) as r:
for line in r.iter_lines():
if line:
decoded_line = line.decode('utf-8')
if decoded_line.startswith('data: '):
try:
data = json.loads(decoded_line[6:])
print(data)
except json.JSONDecodeError:
pass
```
</Tabs.Tab>
<Tabs.Tab>
```javascript
const apiUrl = 'http://localhost:7091/stream';
const apiKey = 'your_agent_api_key';
const question = 'your question here';
async function getStream() {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
// Corrected line: 'apiKey' is changed to 'api_key'
body: JSON.stringify({ question, api_key: apiKey }),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Note: This parsing method assumes each chunk contains whole lines.
// For a more robust production implementation, buffer the chunks
// and process them line by line.
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
console.log(data);
} catch (e) {
console.error("Failed to parse JSON from SSE event:", e);
}
}
}
}
} catch (error) {
console.error("Failed to fetch stream:", error);
}
}
getStream();
```
</Tabs.Tab>
</Tabs>

View File

@@ -0,0 +1,152 @@
---
title: Triggering Agents with Webhooks
description: Learn how to automate and integrate DocsGPT Agents using webhooks for asynchronous task execution.
---
import { Callout, Tabs } from 'nextra/components';
# Triggering Agents with Webhooks
Agent Webhooks provide a powerful mechanism to trigger an agent's execution from external systems. Unlike the direct API which provides an immediate response, webhooks are designed for **asynchronous** operations. When you call a webhook, DocsGPT enqueues the agent's task for background processing and immediately returns a `task_id`. You then use this ID to poll for the result.
This workflow is ideal for integrating with services that expect a quick initial response (e.g., form submissions) or for triggering long-running tasks without tying up a client connection.
Each agent has its own unique webhook URL, which can be generated from the agent's edit page in the DocsGPT UI. This URL includes a secure token for authentication.
### API Endpoints
- **Webhook URL:** `http://localhost:7091/api/webhooks/agents/{AGENT_WEBHOOK_TOKEN}`
- **Task Status URL:** `http://localhost:7091/api/task_status`
<Callout type="info">
For DocsGPT Cloud, use `https://gptcloud.arc53.com/` as the base URL.
</Callout>
For more technical details, you can explore the API swagger documentation available for the cloud version or your local instance.
---
## The Webhook Workflow
The process involves two main steps: triggering the task and polling for the result.
### Step 1: Trigger the Webhook
Send an HTTP `POST` request to the agent's unique webhook URL with the required payload. The structure of this payload should match what the agent's prompt and tools are designed to handle.
- **Method:** `POST`
- **Response:** A JSON object with a `task_id`. `{"task_id": "a1b2c3d4-e5f6-..."}`
<Tabs items={['cURL', 'Python', 'JavaScript']}>
<Tabs.Tab>
```bash
curl -X POST \
http://localhost:7091/api/webhooks/agents/your_webhook_token \
-H "Content-Type: application/json" \
-d '{"question": "Your message to agent"}'
```
</Tabs.Tab>
<Tabs.Tab>
```python
import requests
WEBHOOK_URL = "http://localhost:7091/api/webhooks/agents/your_webhook_token"
payload = {"question": "Your message to agent"}
try:
response = requests.post(WEBHOOK_URL, json=payload)
response.raise_for_status()
task_id = response.json().get("task_id")
print(f"Task successfully created with ID: {task_id}")
except requests.exceptions.RequestException as e:
print(f"Error triggering webhook: {e}")
```
</Tabs.Tab>
<Tabs.Tab>
```javascript
const webhookUrl = 'http://localhost:7091/api/webhooks/agents/your_webhook_token';
const payload = { question: 'Your message to agent' };
async function triggerWebhook() {
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`HTTP error! ${response.status}`);
const data = await response.json();
console.log(`Task successfully created with ID: ${data.task_id}`);
return data.task_id;
} catch (error) {
console.error('Error triggering webhook:', error);
}
}
triggerWebhook();
```
</Tabs.Tab>
</Tabs>
### Step 2: Poll for the Result
Once you have the `task_id`, periodically send a `GET` request to the `/api/task_status` endpoint until the task `status` is `SUCCESS` or `FAILURE`.
- **`status`**: The current state of the task (`PENDING`, `STARTED`, `SUCCESS`, `FAILURE`).
- **`result`**: The final output from the agent, available when the status is `SUCCESS` or `FAILURE`.
<Tabs items={['cURL', 'Python', 'JavaScript']}>
<Tabs.Tab>
```bash
# Replace the task_id with the one you received
curl http://localhost:7091/api/task_status?task_id=YOUR_TASK_ID
```
</Tabs.Tab>
<Tabs.Tab>
```python
import requests
import time
STATUS_URL = "http://localhost:7091/api/task_status"
task_id = "YOUR_TASK_ID"
while True:
response = requests.get(STATUS_URL, params={"task_id": task_id})
data = response.json()
status = data.get("status")
print(f"Current task status: {status}")
if status in ["SUCCESS", "FAILURE"]:
print("Final Result:")
print(data.get("result"))
break
time.sleep(2)
```
</Tabs.Tab>
<Tabs.Tab>
```javascript
const statusUrl = 'http://localhost:7091/api/task_status';
const taskId = 'YOUR_TASK_ID';
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function pollForResult() {
while (true) {
const response = await fetch(`${statusUrl}?task_id=${taskId}`);
const data = await response.json();
const status = data.status;
console.log(`Current task status: ${status}`);
if (status === 'SUCCESS' || status === 'FAILURE') {
console.log('Final Result:', data.result);
break;
}
await sleep(2000);
}
}
pollForResult();
```
</Tabs.Tab>
</Tabs>

View File

@@ -37,7 +37,7 @@ The fastest way to try out DocsGPT is by using the public API endpoint. This req
Open the `.env` file and add the following lines: Open the `.env` file and add the following lines:
``` ```
LLM_NAME=docsgpt LLM_PROVIDER=docsgpt
VITE_API_STREAMING=true VITE_API_STREAMING=true
``` ```
@@ -93,16 +93,16 @@ There are two Ollama optional files:
3. **Pull the Ollama Model:** 3. **Pull the Ollama Model:**
**Crucially, after launching with Ollama, you need to pull the desired model into the Ollama container.** Find the `MODEL_NAME` you configured in your `.env` file (e.g., `llama3.2:1b`). Then execute the following command to pull the model *inside* the running Ollama container: **Crucially, after launching with Ollama, you need to pull the desired model into the Ollama container.** Find the `LLM_NAME` you configured in your `.env` file (e.g., `llama3.2:1b`). Then execute the following command to pull the model *inside* the running Ollama container:
```bash ```bash
docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml exec -it ollama ollama pull <MODEL_NAME> docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-cpu.yaml exec -it ollama ollama pull <LLM_NAME>
``` ```
or (for GPU): or (for GPU):
```bash ```bash
docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml exec -it ollama ollama pull <MODEL_NAME> docker compose -f deployment/docker-compose.yaml -f deployment/optional/docker-compose.optional.ollama-gpu.yaml exec -it ollama ollama pull <LLM_NAME>
``` ```
Replace `<MODEL_NAME>` with the actual model name from your `.env` file. Replace `<LLM_NAME>` with the actual model name from your `.env` file.
4. **Access DocsGPT in your browser:** 4. **Access DocsGPT in your browser:**

View File

@@ -20,9 +20,9 @@ The easiest and recommended way to configure basic settings is by using a `.env`
**Example `.env` file structure:** **Example `.env` file structure:**
``` ```
LLM_NAME=openai LLM_PROVIDER=openai
API_KEY=YOUR_OPENAI_API_KEY API_KEY=YOUR_OPENAI_API_KEY
MODEL_NAME=gpt-4o LLM_NAME=gpt-4o
``` ```
### 2. Configuration via `settings.py` file (Advanced) ### 2. Configuration via `settings.py` file (Advanced)
@@ -37,7 +37,7 @@ While modifying `settings.py` offers more flexibility, it's generally recommende
Here are some of the most fundamental settings you'll likely want to configure: Here are some of the most fundamental settings you'll likely want to configure:
- **`LLM_NAME`**: This setting determines which Large Language Model (LLM) provider DocsGPT will use. It tells DocsGPT which API to interact with. - **`LLM_PROVIDER`**: This setting determines which Large Language Model (LLM) provider DocsGPT will use. It tells DocsGPT which API to interact with.
- **Common values:** - **Common values:**
- `docsgpt`: Use the DocsGPT Public API Endpoint (simple and free, as offered in `setup.sh` option 1). - `docsgpt`: Use the DocsGPT Public API Endpoint (simple and free, as offered in `setup.sh` option 1).
@@ -49,11 +49,11 @@ Here are some of the most fundamental settings you'll likely want to configure:
- `azure_openai`: Use Azure OpenAI Service. - `azure_openai`: Use Azure OpenAI Service.
- `openai` (when using local inference engines like Ollama, Llama.cpp, TGI, etc.): This signals DocsGPT to use an OpenAI-compatible API format, even if the actual LLM is running locally. - `openai` (when using local inference engines like Ollama, Llama.cpp, TGI, etc.): This signals DocsGPT to use an OpenAI-compatible API format, even if the actual LLM is running locally.
- **`MODEL_NAME`**: Specifies the specific model to use from the chosen LLM provider. The available models depend on the `LLM_NAME` you've selected. - **`LLM_NAME`**: Specifies the specific model to use from the chosen LLM provider. The available models depend on the `LLM_PROVIDER` you've selected.
- **Examples:** - **Examples:**
- For `LLM_NAME=openai`: `gpt-4o` - For `LLM_PROVIDER=openai`: `gpt-4o`
- For `LLM_NAME=google`: `gemini-2.0-flash` - For `LLM_PROVIDER=google`: `gemini-2.0-flash`
- For local models (e.g., Ollama): `llama3.2:1b` (or any model name available in your setup). - For local models (e.g., Ollama): `llama3.2:1b` (or any model name available in your setup).
- **`EMBEDDINGS_NAME`**: This setting defines which embedding model DocsGPT will use to generate vector embeddings for your documents. Embeddings are numerical representations of text that allow DocsGPT to understand the semantic meaning of your documents for efficient search and retrieval. - **`EMBEDDINGS_NAME`**: This setting defines which embedding model DocsGPT will use to generate vector embeddings for your documents. Embeddings are numerical representations of text that allow DocsGPT to understand the semantic meaning of your documents for efficient search and retrieval.
@@ -63,7 +63,7 @@ Here are some of the most fundamental settings you'll likely want to configure:
- **`API_KEY`**: Required for most cloud-based LLM providers. This is your authentication key to access the LLM provider's API. You'll need to obtain this key from your chosen provider's platform. - **`API_KEY`**: Required for most cloud-based LLM providers. This is your authentication key to access the LLM provider's API. You'll need to obtain this key from your chosen provider's platform.
- **`OPENAI_BASE_URL`**: Specifically used when `LLM_NAME` is set to `openai` but you are connecting to a local inference engine (like Ollama, Llama.cpp, etc.) that exposes an OpenAI-compatible API. This setting tells DocsGPT where to find your local LLM server. - **`OPENAI_BASE_URL`**: Specifically used when `LLM_PROVIDER` is set to `openai` but you are connecting to a local inference engine (like Ollama, Llama.cpp, etc.) that exposes an OpenAI-compatible API. This setting tells DocsGPT where to find your local LLM server.
## Configuration Examples ## Configuration Examples
@@ -74,9 +74,9 @@ Let's look at some concrete examples of how to configure these settings in your
To use OpenAI's `gpt-4o` model, you would configure your `.env` file like this: To use OpenAI's `gpt-4o` model, you would configure your `.env` file like this:
``` ```
LLM_NAME=openai LLM_PROVIDER=openai
API_KEY=YOUR_OPENAI_API_KEY # Replace with your actual OpenAI API key API_KEY=YOUR_OPENAI_API_KEY # Replace with your actual OpenAI API key
MODEL_NAME=gpt-4o LLM_NAME=gpt-4o
``` ```
Make sure to replace `YOUR_OPENAI_API_KEY` with your actual OpenAI API key. Make sure to replace `YOUR_OPENAI_API_KEY` with your actual OpenAI API key.
@@ -86,14 +86,14 @@ Make sure to replace `YOUR_OPENAI_API_KEY` with your actual OpenAI API key.
To use a local Ollama server with the `llama3.2:1b` model, you would configure your `.env` file like this: To use a local Ollama server with the `llama3.2:1b` model, you would configure your `.env` file like this:
``` ```
LLM_NAME=openai # Using OpenAI compatible API format for local models LLM_PROVIDER=openai # Using OpenAI compatible API format for local models
API_KEY=None # API Key is not needed for local Ollama API_KEY=None # API Key is not needed for local Ollama
MODEL_NAME=llama3.2:1b LLM_NAME=llama3.2:1b
OPENAI_BASE_URL=http://host.docker.internal:11434/v1 # Default Ollama API URL within Docker OPENAI_BASE_URL=http://host.docker.internal:11434/v1 # Default Ollama API URL within Docker
EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2 # You can also run embeddings locally if needed EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2 # You can also run embeddings locally if needed
``` ```
In this case, even though you are using Ollama locally, `LLM_NAME` is set to `openai` because Ollama (and many other local inference engines) are designed to be API-compatible with OpenAI. `OPENAI_BASE_URL` points DocsGPT to the local Ollama server. In this case, even though you are using Ollama locally, `LLM_PROVIDER` is set to `openai` because Ollama (and many other local inference engines) are designed to be API-compatible with OpenAI. `OPENAI_BASE_URL` points DocsGPT to the local Ollama server.
## Authentication Settings ## Authentication Settings

View File

@@ -32,9 +32,9 @@ Choose the LLM of your choice.
### For Open source llm change: ### For Open source llm change:
<Steps> <Steps>
### Step 1 ### Step 1
For open source version please edit `LLM_NAME`, `MODEL_NAME` and others in the .env file. Refer to [⚙️ App Configuration](/Deploying/DocsGPT-Settings) for more information. For open source version please edit `LLM_PROVIDER`, `LLM_NAME` and others in the .env file. Refer to [⚙️ App Configuration](/Deploying/DocsGPT-Settings) for more information.
### Step 2 ### Step 2
Visit [☁️ Cloud Providers](/Models/cloud-providers) for the updated list of online models. Make sure you have the right API_KEY and correct LLM_NAME. Visit [☁️ Cloud Providers](/Models/cloud-providers) for the updated list of online models. Make sure you have the right API_KEY and correct LLM_PROVIDER.
For self-hosted please visit [🖥️ Local Inference](/Models/local-inference). For self-hosted please visit [🖥️ Local Inference](/Models/local-inference).
</Steps> </Steps>

View File

@@ -13,15 +13,15 @@ The primary method for configuring your LLM provider in DocsGPT is through the `
To connect to a cloud LLM provider, you will typically need to configure the following basic settings in your `.env` file: To connect to a cloud LLM provider, you will typically need to configure the following basic settings in your `.env` file:
* **`LLM_NAME`**: This setting is essential and identifies the specific cloud provider you wish to use (e.g., `openai`, `google`, `anthropic`). * **`LLM_PROVIDER`**: This setting is essential and identifies the specific cloud provider you wish to use (e.g., `openai`, `google`, `anthropic`).
* **`MODEL_NAME`**: Specifies the exact model you want to utilize from your chosen provider (e.g., `gpt-4o`, `gemini-2.0-flash`, `claude-3-5-sonnet-latest`). Refer to your provider's documentation for a list of available models. * **`LLM_NAME`**: Specifies the exact model you want to utilize from your chosen provider (e.g., `gpt-4o`, `gemini-2.0-flash`, `claude-3-5-sonnet-latest`). Refer to your provider's documentation for a list of available models.
* **`API_KEY`**: Almost all cloud LLM providers require an API key for authentication. Obtain your API key from your chosen provider's platform and securely store it in your `.env` file. * **`API_KEY`**: Almost all cloud LLM providers require an API key for authentication. Obtain your API key from your chosen provider's platform and securely store it in your `.env` file.
## Explicitly Supported Cloud Providers ## Explicitly Supported Cloud Providers
DocsGPT offers direct, streamlined support for the following cloud LLM providers, making configuration straightforward. The table below outlines the `LLM_NAME` and example `MODEL_NAME` values to use for each provider in your `.env` file. DocsGPT offers direct, streamlined support for the following cloud LLM providers, making configuration straightforward. The table below outlines the `LLM_PROVIDER` and example `LLM_NAME` values to use for each provider in your `.env` file.
| Provider | `LLM_NAME` | Example `MODEL_NAME` | | Provider | `LLM_PROVIDER` | Example `LLM_NAME` |
| :--------------------------- | :------------- | :-------------------------- | | :--------------------------- | :------------- | :-------------------------- |
| DocsGPT Public API | `docsgpt` | `None` | | DocsGPT Public API | `docsgpt` | `None` |
| OpenAI | `openai` | `gpt-4o` | | OpenAI | `openai` | `gpt-4o` |
@@ -35,16 +35,16 @@ DocsGPT offers direct, streamlined support for the following cloud LLM providers
DocsGPT's flexible architecture allows you to connect to any cloud provider that offers an API compatible with the OpenAI API standard. This opens up a vast ecosystem of LLM services. DocsGPT's flexible architecture allows you to connect to any cloud provider that offers an API compatible with the OpenAI API standard. This opens up a vast ecosystem of LLM services.
To connect to an OpenAI-compatible cloud provider, you will still use `LLM_NAME=openai` in your `.env` file. However, you will also need to specify the API endpoint of your chosen provider using the `OPENAI_BASE_URL` setting. You will also likely need to provide an `API_KEY` and `MODEL_NAME` as required by that provider. To connect to an OpenAI-compatible cloud provider, you will still use `LLM_PROVIDER=openai` in your `.env` file. However, you will also need to specify the API endpoint of your chosen provider using the `OPENAI_BASE_URL` setting. You will also likely need to provide an `API_KEY` and `LLM_NAME` as required by that provider.
**Example for DeepSeek (OpenAI-Compatible API):** **Example for DeepSeek (OpenAI-Compatible API):**
To connect to DeepSeek, which offers an OpenAI-compatible API, your `.env` file could be configured as follows: To connect to DeepSeek, which offers an OpenAI-compatible API, your `.env` file could be configured as follows:
``` ```
LLM_NAME=openai LLM_PROVIDER=openai
API_KEY=YOUR_API_KEY # Your DeepSeek API key API_KEY=YOUR_API_KEY # Your DeepSeek API key
MODEL_NAME=deepseek-chat # Or your desired DeepSeek model name LLM_NAME=deepseek-chat # Or your desired DeepSeek model name
OPENAI_BASE_URL=https://api.deepseek.com/v1 # DeepSeek's OpenAI API URL OPENAI_BASE_URL=https://api.deepseek.com/v1 # DeepSeek's OpenAI API URL
``` ```

View File

@@ -60,7 +60,7 @@ To use OpenAI's `text-embedding-ada-002` embedding model, you need to set `EMBED
**Example `.env` configuration for OpenAI Embeddings:** **Example `.env` configuration for OpenAI Embeddings:**
``` ```
LLM_NAME=openai LLM_PROVIDER=openai
API_KEY=YOUR_OPENAI_API_KEY # Your OpenAI API Key API_KEY=YOUR_OPENAI_API_KEY # Your OpenAI API Key
EMBEDDINGS_NAME=openai_text-embedding-ada-002 EMBEDDINGS_NAME=openai_text-embedding-ada-002
``` ```

View File

@@ -15,8 +15,8 @@ Setting up a local inference engine with DocsGPT is configured through environme
To connect to a local inference engine, you will generally need to configure these settings in your `.env` file: To connect to a local inference engine, you will generally need to configure these settings in your `.env` file:
* **`LLM_NAME`**: Crucially set this to `openai`. This tells DocsGPT to use the OpenAI-compatible API format for communication, even though the LLM is local. * **`LLM_PROVIDER`**: Crucially set this to `openai`. This tells DocsGPT to use the OpenAI-compatible API format for communication, even though the LLM is local.
* **`MODEL_NAME`**: Specify the model name as recognized by your local inference engine. This might be a model identifier or left as `None` if the engine doesn't require explicit model naming in the API request. * **`LLM_NAME`**: Specify the model name as recognized by your local inference engine. This might be a model identifier or left as `None` if the engine doesn't require explicit model naming in the API request.
* **`OPENAI_BASE_URL`**: This is essential. Set this to the base URL of your local inference engine's API endpoint. This tells DocsGPT where to find your local LLM server. * **`OPENAI_BASE_URL`**: This is essential. Set this to the base URL of your local inference engine's API endpoint. This tells DocsGPT where to find your local LLM server.
* **`API_KEY`**: Generally, for local inference engines, you can set `API_KEY=None` as authentication is usually not required in local setups. * **`API_KEY`**: Generally, for local inference engines, you can set `API_KEY=None` as authentication is usually not required in local setups.
@@ -24,16 +24,16 @@ To connect to a local inference engine, you will generally need to configure the
DocsGPT is readily configurable to work with the following local inference engines, all communicating via the OpenAI API format. Here are example `OPENAI_BASE_URL` values for each, based on default setups: DocsGPT is readily configurable to work with the following local inference engines, all communicating via the OpenAI API format. Here are example `OPENAI_BASE_URL` values for each, based on default setups:
| Inference Engine | `LLM_NAME` | `OPENAI_BASE_URL` | | Inference Engine | `LLM_PROVIDER` | `OPENAI_BASE_URL` |
| :---------------------------- | :--------- | :------------------------- | | :---------------------------- | :------------- | :------------------------- |
| LLaMa.cpp | `openai` | `http://localhost:8000/v1` | | LLaMa.cpp | `openai` | `http://localhost:8000/v1` |
| Ollama | `openai` | `http://localhost:11434/v1` | | Ollama | `openai` | `http://localhost:11434/v1` |
| Text Generation Inference (TGI)| `openai` | `http://localhost:8080/v1` | | Text Generation Inference (TGI)| `openai` | `http://localhost:8080/v1` |
| SGLang | `openai` | `http://localhost:30000/v1` | | SGLang | `openai` | `http://localhost:30000/v1` |
| vLLM | `openai` | `http://localhost:8000/v1` | | vLLM | `openai` | `http://localhost:8000/v1` |
| Aphrodite | `openai` | `http://localhost:2242/v1` | | Aphrodite | `openai` | `http://localhost:2242/v1` |
| FriendliAI | `openai` | `http://localhost:8997/v1` | | FriendliAI | `openai` | `http://localhost:8997/v1` |
| LMDeploy | `openai` | `http://localhost:23333/v1` | | LMDeploy | `openai` | `http://localhost:23333/v1` |
**Important Note on `localhost` vs `host.docker.internal`:** **Important Note on `localhost` vs `host.docker.internal`:**

File diff suppressed because it is too large Load Diff

View File

@@ -19,19 +19,18 @@
] ]
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.5.1", "@reduxjs/toolkit": "^2.8.2",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^24.2.0", "i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.2", "i18next-browser-languagedetector": "^8.0.2",
"mermaid": "^11.6.0", "mermaid": "^11.6.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-copy-to-clipboard": "^5.1.0", "react-dom": "^19.0.0",
"react-dom": "^18.3.1", "react-dropzone": "^14.3.8",
"react-dropzone": "^14.3.5",
"react-helmet": "^6.1.0",
"react-i18next": "^15.4.0", "react-i18next": "^15.4.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
@@ -39,18 +38,18 @@
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"remark-math": "^6.0.0" "remark-math": "^6.0.0",
"tailwind-merge": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/mermaid": "^9.1.0", "@types/mermaid": "^9.1.0",
"@types/react": "^18.0.27", "@types/react": "^19.1.8",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^19.0.0",
"@types/react-helmet": "^6.1.11",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.13",
"eslint": "^8.57.1", "eslint": "^8.57.1",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-config-standard-with-typescript": "^34.0.0", "eslint-config-standard-with-typescript": "^34.0.0",
@@ -64,8 +63,8 @@
"lint-staged": "^15.3.0", "lint-staged": "^15.3.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.13",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.1.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0"

View File

@@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.88"><defs><style>.a{fill:#d53;}.b{fill:#fff;}.c{fill:#ddd;}.d{fill:#fc0;}.e{fill:#6b5;}.f{fill:#4a4;}.g{fill:#148;}</style></defs><title>duckduckgo</title><path class="a" d="M122.88,61.44a61.44,61.44,0,1,0-61.44,61.44,61.44,61.44,0,0,0,61.44-61.44Z"/><path class="b" d="M114.37,61.44a52.92,52.92,0,1,0-15.5,37.43,52.76,52.76,0,0,0,15.5-37.43Zm-13.12-39.8A56.29,56.29,0,1,1,61.44,5.15a56.12,56.12,0,0,1,39.81,16.49Z"/><path class="c" d="M43.24,30.15C26.17,34.13,32.43,58,32.43,58l10.81,52.9,4,1.71-4-82.49Zm-4-10.24H34.7L41,22.19s-6.26,0-6.26,4C48.36,25.6,54.61,29,54.61,29l-15.36-9.1Zm0,0Z"/><path class="b" d="M75.66,115.48S62,93.87,62,79.64c0-26.73,17.63-4,17.63-25S62,28.44,62,28.44c-8.53-10.8-25-8.53-25-8.53l4,2.28s-4,1.13-5.12,2.27,10.81-1.7,15.93,2.85C30.72,29,34.13,46.08,34.13,46.08l11.95,68.27,29.58,1.13Zm0,0Z"/><path class="d" d="M75.66,60.87l21.62-5.69C116.62,58,80.78,68.84,78.51,68.27c-17.07-2.85-12,11.37,8.53,6.82s5.12,11.38-13.65,5.12c-26.74-7.39-12.52-20.48,2.27-19.34Z"/><path class="e" d="M70,105.81l1.14-1.7c12.52,4.55,13.09,6.25,12.52-5.12s0-11.38-13.09-1.71c0-2.84-7.39-1.71-8.53,0-11.95-5.12-13.09-6.83-12.52,1.14,1.14,16.5.57,13.65,11.95,8l8.53-.57Zm0,0Z"/><path class="f" d="M60.87,99.56v6.82c.57,1.14,9.67,1.14,9.67-1.14s-4.55,1.71-7.39.57S62,98.42,62,98.42l-1.14,1.14Zm0,0Z"/><path class="g" d="M48.36,43.24c-2.85-3.42-10.24-.57-8.54,4,.57-2.28,4.55-5.69,8.54-4Zm18.2,0c.57-3.42,6.26-4,8-.57a8,8,0,0,0-8,.57Zm-18.77,9.1a1.14,1.14,0,1,1,0,.57v-.57Zm-4.55,2.27a4,4,0,1,0,0-.57v.57Zm29.58-4a1.14,1.14,0,1,1,0,.57v-.57ZM69.4,52.91a3.42,3.42,0,1,0,0-.57v.57Zm0,0Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

View File

@@ -19,9 +19,9 @@ export default function Hero({
}>; }>;
return ( return (
<div className="flex h-full w-full flex-col items-center justify-between text-black-1000 dark:text-bright-gray"> <div className="text-black-1000 dark:text-bright-gray flex h-full w-full flex-col items-center justify-between">
{/* Header Section */} {/* Header Section */}
<div className="flex flex-grow flex-col items-center justify-center pt-8 md:pt-0"> <div className="flex grow flex-col items-center justify-center pt-8 md:pt-0">
<div className="mb-4 flex items-center"> <div className="mb-4 flex items-center">
<span className="text-4xl font-semibold">DocsGPT</span> <span className="text-4xl font-semibold">DocsGPT</span>
<img className="mb-1 inline w-14" src={DocsGPT3} alt="docsgpt" /> <img className="mb-1 inline w-14" src={DocsGPT3} alt="docsgpt" />
@@ -38,9 +38,9 @@ export default function Hero({
<button <button
key={key} key={key}
onClick={() => handleQuestion({ question: demo.query })} onClick={() => handleQuestion({ question: demo.query })}
className={`w-full rounded-[66px] border border-dark-gray bg-transparent px-6 py-[14px] text-left text-just-black transition-colors hover:bg-cultured dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green ${key >= 2 ? 'hidden md:block' : ''} // Show only 2 buttons on mobile`} className={`border-dark-gray text-just-black hover:bg-cultured dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green w-full rounded-[66px] border bg-transparent px-6 py-[14px] text-left transition-colors ${key >= 2 ? 'hidden md:block' : ''} // Show only 2 buttons on mobile`}
> >
<p className="mb-2 font-semibold text-black-1000 dark:text-bright-gray"> <p className="text-black-1000 dark:text-bright-gray mb-2 font-semibold">
{demo.header} {demo.header}
</p> </p>
<span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300"> <span className="line-clamp-2 text-gray-700 opacity-60 dark:text-gray-300">

View File

@@ -48,6 +48,7 @@ import {
setConversations, setConversations,
setModalStateDeleteConv, setModalStateDeleteConv,
setSelectedAgent, setSelectedAgent,
setSharedAgents,
} from './preferences/preferenceSlice'; } from './preferences/preferenceSlice';
import Upload from './upload/Upload'; import Upload from './upload/Upload';
@@ -80,8 +81,27 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
useState<ActiveState>('INACTIVE'); useState<ActiveState>('INACTIVE');
const [recentAgents, setRecentAgents] = useState<Agent[]>([]); const [recentAgents, setRecentAgents] = useState<Agent[]>([]);
const navRef = useRef(null); const navRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
navRef.current &&
!navRef.current.contains(event.target as Node) &&
(isMobile || isTablet) &&
navOpen
) {
setNavOpen(false);
}
}
//event listener only for mobile/tablet when nav is open
if ((isMobile || isTablet) && navOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [navOpen, isMobile, isTablet, setNavOpen]);
async function fetchRecentAgents() { async function fetchRecentAgents() {
try { try {
const response = await userService.getPinnedAgents(token); const response = await userService.getPinnedAgents(token);
@@ -169,73 +189,65 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
const handleTogglePin = (agent: Agent) => { const handleTogglePin = (agent: Agent) => {
userService.togglePinAgent(agent.id ?? '', token).then((response) => { userService.togglePinAgent(agent.id ?? '', token).then((response) => {
if (response.ok) { if (response.ok) {
const updatedAgents = agents?.map((a) => const updatePinnedStatus = (a: Agent) =>
a.id === agent.id ? { ...a, pinned: !a.pinned } : a, a.id === agent.id ? { ...a, pinned: !a.pinned } : a;
); dispatch(setAgents(agents?.map(updatePinnedStatus)));
dispatch(setAgents(updatedAgents)); dispatch(setSharedAgents(sharedAgents?.map(updatePinnedStatus)));
} }
}); });
}; };
const handleConversationClick = (index: string) => { const handleConversationClick = async (index: string) => {
dispatch(setSelectedAgent(null)); try {
conversationService dispatch(setSelectedAgent(null));
.getConversation(index, token)
.then((response) => { const response = await conversationService.getConversation(index, token);
if (!response.ok) { if (!response.ok) {
navigate('/'); navigate('/');
dispatch(setSelectedAgent(null)); return;
return null; }
}
return response.json(); const data = await response.json();
}) if (!data) return;
.then((data) => {
if (!data) return; dispatch(setConversation(data.queries));
dispatch(setConversation(data.queries)); dispatch(updateConversationId({ query: { conversationId: index } }));
dispatch(
updateConversationId({ if (!data.agent_id) {
query: { conversationId: index }, navigate('/');
}), return;
}
let agent: Agent;
if (data.is_shared_usage) {
const sharedResponse = await userService.getSharedAgent(
data.shared_token,
token,
); );
if (isMobile || isTablet) { if (!sharedResponse.ok) {
setNavOpen(false);
}
if (data.agent_id) {
if (data.is_shared_usage) {
userService
.getSharedAgent(data.shared_token, token)
.then((response) => {
if (!response.ok) {
navigate('/');
dispatch(setSelectedAgent(null));
return;
}
response.json().then((agent: Agent) => {
navigate(`/agents/shared/${agent.shared_token}`);
});
});
} else {
userService.getAgent(data.agent_id, token).then((response) => {
if (!response.ok) {
navigate('/');
dispatch(setSelectedAgent(null));
return;
}
response.json().then((agent: Agent) => {
if (agent.shared_token)
navigate(`/agents/shared/${agent.shared_token}`);
else {
dispatch(setSelectedAgent(agent));
navigate('/');
}
});
});
}
} else {
navigate('/'); navigate('/');
dispatch(setSelectedAgent(null)); return;
} }
}); agent = await sharedResponse.json();
navigate(`/agents/shared/${agent.shared_token}`);
} else {
const agentResponse = await userService.getAgent(data.agent_id, token);
if (!agentResponse.ok) {
navigate('/');
return;
}
agent = await agentResponse.json();
if (agent.shared_token) {
navigate(`/agents/shared/${agent.shared_token}`);
} else {
await Promise.resolve(dispatch(setSelectedAgent(agent)));
navigate('/');
}
}
} catch (error) {
console.error('Error handling conversation click:', error);
navigate('/');
}
}; };
const resetConversation = () => { const resetConversation = () => {
@@ -281,7 +293,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
return ( return (
<> <>
{!navOpen && ( {!navOpen && (
<div className="duration-25 absolute left-3 top-3 z-20 hidden transition-all lg:block"> <div className="absolute top-3 left-3 z-20 hidden transition-all duration-25 lg:block">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => { onClick={() => {
@@ -309,7 +321,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
/> />
</button> </button>
)} )}
<div className="text-[20px] font-medium text-[#949494]"> <div className="text-gray-4000 text-[20px] font-medium">
DocsGPT DocsGPT
</div> </div>
</div> </div>
@@ -318,8 +330,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div <div
ref={navRef} ref={navRef}
className={`${ className={`${
!navOpen && '-ml-96 md:-ml-[18rem]' !navOpen && '-ml-96 md:-ml-72'
} duration-20 fixed top-0 z-20 flex h-full w-72 flex-col border-b-0 border-r-[1px] bg-lotion transition-all dark:border-r-purple-taupe dark:bg-chinese-black dark:text-white`} } bg-lotion dark:border-r-purple-taupe dark:bg-chinese-black fixed top-0 z-20 flex h-full w-72 flex-col border-r border-b-0 transition-all duration-20 dark:text-white`}
> >
<div <div
className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'} className={'visible mt-2 flex h-[6vh] w-full justify-between md:h-12'}
@@ -363,7 +375,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className={({ isActive }) => className={({ isActive }) =>
`${ `${
isActive ? 'bg-transparent' : '' isActive ? 'bg-transparent' : ''
} group sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border border-silver p-3 hover:border-rainy-gray hover:bg-transparent dark:border-purple-taupe dark:text-white` } group border-silver hover:border-rainy-gray dark:border-purple-taupe sticky mx-4 mt-4 flex cursor-pointer gap-2.5 rounded-3xl border p-3 hover:bg-transparent dark:text-white`
} }
> >
<img <img
@@ -371,16 +383,16 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
alt="Create new chat" alt="Create new chat"
className="opacity-80 group-hover:opacity-100" className="opacity-80 group-hover:opacity-100"
/> />
<p className="text-sm text-dove-gray group-hover:text-neutral-600 dark:text-chinese-silver dark:group-hover:text-bright-gray"> <p className="text-dove-gray dark:text-chinese-silver dark:group-hover:text-bright-gray text-sm group-hover:text-neutral-600">
{t('newChat')} {t('newChat')}
</p> </p>
</NavLink> </NavLink>
<div <div
id="conversationsMainDiv" id="conversationsMainDiv"
className="mb-auto h-[78vh] overflow-y-auto overflow-x-hidden dark:text-white" className="mb-auto h-[78vh] overflow-x-hidden overflow-y-auto dark:text-white"
> >
{conversations?.loading && !isDeletingConversation && ( {conversations?.loading && !isDeletingConversation && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<img <img
src={isDarkTheme ? SpinnerDark : Spinner} src={isDarkTheme ? SpinnerDark : Spinner}
className="animate-spin cursor-pointer bg-transparent" className="animate-spin cursor-pointer bg-transparent"
@@ -391,14 +403,14 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{recentAgents?.length > 0 ? ( {recentAgents?.length > 0 ? (
<div> <div>
<div className="mx-4 my-auto mt-2 flex h-6 items-center"> <div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="ml-4 mt-1 text-sm font-semibold">Agents</p> <p className="mt-1 ml-4 text-sm font-semibold">Agents</p>
</div> </div>
<div className="agents-container"> <div className="agents-container">
<div> <div>
{recentAgents.map((agent, idx) => ( {recentAgents.map((agent, idx) => (
<div <div
key={idx} key={idx}
className={`group mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal ${ className={`group hover:bg-bright-gray dark:hover:bg-dark-charcoal mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between rounded-3xl pl-4 ${
agent.id === selectedAgent?.id && !conversationId agent.id === selectedAgent?.id && !conversationId
? 'bg-bright-gray dark:bg-dark-charcoal' ? 'bg-bright-gray dark:bg-dark-charcoal'
: '' : ''
@@ -408,12 +420,16 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex w-6 justify-center"> <div className="flex w-6 justify-center">
<img <img
src={agent.image ?? Robot} src={
agent.image && agent.image.trim() !== ''
? agent.image
: Robot
}
alt="agent-logo" alt="agent-logo"
className="h-6 w-6" className="h-6 w-6 rounded-full object-contain"
/> />
</div> </div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{agent.name} {agent.name}
</p> </p>
</div> </div>
@@ -437,7 +453,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
))} ))}
</div> </div>
<div <div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal" className="hover:bg-bright-gray dark:hover:bg-dark-charcoal mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4"
onClick={() => { onClick={() => {
dispatch(setSelectedAgent(null)); dispatch(setSelectedAgent(null));
if (isMobile || isTablet) { if (isMobile || isTablet) {
@@ -453,7 +469,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="h-[18px] w-[18px]" className="h-[18px] w-[18px]"
/> />
</div> </div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')} {t('manageAgents')}
</p> </p>
</div> </div>
@@ -461,7 +477,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div> </div>
) : ( ) : (
<div <div
className="mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal" className="hover:bg-bright-gray dark:hover:bg-dark-charcoal mx-4 my-auto mt-2 flex h-9 cursor-pointer items-center gap-2 rounded-3xl pl-4"
onClick={() => { onClick={() => {
if (isMobile || isTablet) { if (isMobile || isTablet) {
setNavOpen(false); setNavOpen(false);
@@ -477,7 +493,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="h-[18px] w-[18px]" className="h-[18px] w-[18px]"
/> />
</div> </div>
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm leading-6 text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray overflow-hidden text-sm leading-6 text-ellipsis whitespace-nowrap">
{t('manageAgents')} {t('manageAgents')}
</p> </p>
</div> </div>
@@ -485,7 +501,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{conversations?.data && conversations.data.length > 0 ? ( {conversations?.data && conversations.data.length > 0 ? (
<div className="mt-7"> <div className="mt-7">
<div className="mx-4 my-auto mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl"> <div className="mx-4 my-auto mt-2 flex h-6 items-center justify-between gap-4 rounded-3xl">
<p className="ml-4 mt-1 text-sm font-semibold">{t('chats')}</p> <p className="mt-1 ml-4 text-sm font-semibold">{t('chats')}</p>
</div> </div>
<div className="conversations-container"> <div className="conversations-container">
{conversations.data?.map((conversation) => ( {conversations.data?.map((conversation) => (
@@ -510,8 +526,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<></> <></>
)} )}
</div> </div>
<div className="flex h-auto flex-col justify-end text-eerie-black dark:text-white"> <div className="text-eerie-black flex h-auto flex-col justify-end dark:text-white">
<div className="flex flex-col gap-2 border-b-[1px] py-2 dark:border-b-purple-taupe"> <div className="dark:border-b-purple-taupe flex flex-col gap-2 border-b py-2">
<NavLink <NavLink
onClick={() => { onClick={() => {
if (isMobile || isTablet) { if (isMobile || isTablet) {
@@ -521,7 +537,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}} }}
to="/settings" to="/settings"
className={({ isActive }) => className={({ isActive }) =>
`mx-4 my-auto flex h-9 cursor-pointer gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${ `mx-4 my-auto flex h-9 cursor-pointer items-center gap-4 rounded-3xl hover:bg-gray-100 dark:hover:bg-[#28292E] ${
isActive ? 'bg-gray-3000 dark:bg-transparent' : '' isActive ? 'bg-gray-3000 dark:bg-transparent' : ''
}` }`
} }
@@ -529,14 +545,16 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<img <img
src={SettingGear} src={SettingGear}
alt="Settings" alt="Settings"
className="w- ml-2 filter dark:invert" width={21}
height={21}
className="my-auto ml-2 filter dark:invert"
/> />
<p className="my-auto text-sm text-eerie-black dark:text-white"> <p className="text-eerie-black text-sm dark:text-white">
{t('settings.label')} {t('settings.label')}
</p> </p>
</NavLink> </NavLink>
</div> </div>
<div className="flex flex-col justify-end text-eerie-black dark:text-white"> <div className="text-eerie-black flex flex-col justify-end dark:text-white">
<div className="flex items-center justify-between py-1"> <div className="flex items-center justify-between py-1">
<Help /> <Help />
@@ -585,7 +603,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="sticky z-10 h-16 w-full border-b-2 bg-gray-50 dark:border-b-purple-taupe dark:bg-chinese-black lg:hidden"> <div className="dark:border-b-purple-taupe dark:bg-chinese-black sticky z-10 h-16 w-full border-b-2 bg-gray-50 lg:hidden">
<div className="ml-6 flex h-full items-center gap-6"> <div className="ml-6 flex h-full items-center gap-6">
<button <button
className="h-6 w-6 lg:hidden" className="h-6 w-6 lg:hidden"
@@ -597,7 +615,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
className="w-7 filter dark:invert" className="w-7 filter dark:invert"
/> />
</button> </button>
<div className="text-[20px] font-medium text-[#949494]">DocsGPT</div> <div className="text-gray-4000 text-[20px] font-medium">DocsGPT</div>
</div> </div>
</div> </div>
<DeleteConvModal <DeleteConvModal

View File

@@ -54,7 +54,7 @@ export default function AgentCard({
return ( return (
<div <div
className={`relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] hover:dark:bg-[#383838]/80 ${ className={`relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${
agent.status === 'published' ? 'cursor-pointer' : '' agent.status === 'published' ? 'cursor-pointer' : ''
}`} }`}
onClick={handleCardClick} onClick={handleCardClick}
@@ -65,7 +65,7 @@ export default function AgentCard({
e.stopPropagation(); e.stopPropagation();
setIsMenuOpen(true); setIsMenuOpen(true);
}} }}
className="absolute right-4 top-4 z-10 cursor-pointer" className="absolute top-4 right-4 z-10 cursor-pointer"
> >
<img src={ThreeDots} alt="options" className="h-[19px] w-[19px]" /> <img src={ThreeDots} alt="options" className="h-[19px] w-[19px]" />
{menuOptions && ( {menuOptions && (
@@ -83,9 +83,9 @@ export default function AgentCard({
<div className="w-full"> <div className="w-full">
<div className="flex w-full items-center gap-1 px-1"> <div className="flex w-full items-center gap-1 px-1">
<img <img
src={agent.image ?? Robot} src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
alt={`${agent.name}`} alt={`${agent.name}`}
className="h-7 w-7 rounded-full" className="h-7 w-7 rounded-full object-contain"
/> />
{agent.status === 'draft' && ( {agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]"> <p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">
@@ -96,11 +96,11 @@ export default function AgentCard({
<div className="mt-2"> <div className="mt-2">
<p <p
title={agent.name} title={agent.name}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-[#020617] dark:text-[#E0E0E0]" className="truncate px-1 text-[13px] leading-relaxed font-semibold text-[#020617] capitalize dark:text-[#E0E0E0]"
> >
{agent.name} {agent.name}
</p> </p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B] dark:text-sonic-silver-light"> <p className="dark:text-sonic-silver-light mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B]">
{agent.description} {agent.description}
</p> </p>
</div> </div>

View File

@@ -44,12 +44,12 @@ export default function AgentLogs() {
> >
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" /> <img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button> </button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
Back to all agents Back to all agents
</p> </p>
</div> </div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4"> <div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white"> <h1 className="text-eerie-black m-0 text-[40px] font-bold dark:text-white">
Agent Logs Agent Logs
</h1> </h1>
</div> </div>

View File

@@ -6,24 +6,23 @@ import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels'; import { Query } from '../conversation/conversationModels';
import { import {
addQuery, addQuery,
fetchAnswer, fetchPreviewAnswer,
handleAbort, handlePreviewAbort,
resendQuery, resendQuery,
resetConversation, resetPreview,
selectQueries, selectPreviewQueries,
selectStatus, selectPreviewStatus,
} from '../conversation/conversationSlice'; } from './agentPreviewSlice';
import { selectSelectedAgent } from '../preferences/preferenceSlice'; import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store'; import { AppDispatch } from '../store';
export default function AgentPreview() { export default function AgentPreview() {
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectQueries); const queries = useSelector(selectPreviewQueries);
const status = useSelector(selectStatus); const status = useSelector(selectPreviewStatus);
const selectedAgent = useSelector(selectSelectedAgent); const selectedAgent = useSelector(selectSelectedAgent);
const [input, setInput] = useState('');
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const fetchStream = useRef<any>(null); const fetchStream = useRef<any>(null);
@@ -31,7 +30,7 @@ export default function AgentPreview() {
const handleFetchAnswer = useCallback( const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => { ({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch( fetchStream.current = dispatch(
fetchAnswer({ question, indx: index, isPreview: true }), fetchPreviewAnswer({ question, indx: index }),
); );
}, },
[dispatch], [dispatch],
@@ -95,11 +94,11 @@ export default function AgentPreview() {
}; };
useEffect(() => { useEffect(() => {
dispatch(resetConversation()); dispatch(resetPreview());
return () => { return () => {
if (fetchStream.current) fetchStream.current.abort(); if (fetchStream.current) fetchStream.current.abort();
handleAbort(); handlePreviewAbort();
dispatch(resetConversation()); dispatch(resetPreview());
}; };
}, [dispatch]); }, [dispatch]);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -6,6 +6,7 @@ import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg'; import ArrowLeft from '../assets/arrow-left.svg';
import SourceIcon from '../assets/source.svg'; import SourceIcon from '../assets/source.svg';
import Dropdown from '../components/Dropdown'; import Dropdown from '../components/Dropdown';
import { FileUpload } from '../components/FileUpload';
import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup'; import MultiSelectPopup, { OptionType } from '../components/MultiSelectPopup';
import AgentDetailsModal from '../modals/AgentDetailsModal'; import AgentDetailsModal from '../modals/AgentDetailsModal';
import ConfirmationModal from '../modals/ConfirmationModal'; import ConfirmationModal from '../modals/ConfirmationModal';
@@ -48,6 +49,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
agent_type: '', agent_type: '',
status: '', status: '',
}); });
const [imageFile, setImageFile] = useState<File | null>(null);
const [prompts, setPrompts] = useState< const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[] { name: string; id: string; type: string }[]
>([]); >([]);
@@ -106,6 +108,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
); );
}; };
const handleUpload = useCallback((files: File[]) => {
if (files && files.length > 0) {
const file = files[0];
setImageFile(file);
}
}, []);
const handleCancel = () => { const handleCancel = () => {
if (selectedAgent) dispatch(setSelectedAgent(null)); if (selectedAgent) dispatch(setSelectedAgent(null));
navigate('/agents'); navigate('/agents');
@@ -118,42 +127,80 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}; };
const handleSaveDraft = async () => { const handleSaveDraft = async () => {
const response = const formData = new FormData();
effectiveMode === 'new' formData.append('name', agent.name);
? await userService.createAgent({ ...agent, status: 'draft' }, token) formData.append('description', agent.description);
: await userService.updateAgent( formData.append('source', agent.source);
agent.id || '', formData.append('chunks', agent.chunks);
{ ...agent, status: 'draft' }, formData.append('retriever', agent.retriever);
token, formData.append('prompt_id', agent.prompt_id);
); formData.append('agent_type', agent.agent_type);
if (!response.ok) throw new Error('Failed to create agent draft'); formData.append('status', 'draft');
const data = await response.json();
if (effectiveMode === 'new') { if (imageFile) formData.append('image', imageFile);
setEffectiveMode('draft');
setAgent((prev) => ({ ...prev, id: data.id })); if (agent.tools && agent.tools.length > 0)
formData.append('tools', JSON.stringify(agent.tools));
else formData.append('tools', '[]');
try {
const response =
effectiveMode === 'new'
? await userService.createAgent(formData, token)
: await userService.updateAgent(agent.id || '', formData, token);
if (!response.ok) throw new Error('Failed to create agent draft');
const data = await response.json();
if (effectiveMode === 'new') {
setEffectiveMode('draft');
setAgent((prev) => ({
...prev,
id: data.id,
image: data.image || prev.image,
}));
}
} catch (error) {
console.error('Error saving draft:', error);
throw new Error('Failed to save draft');
} }
}; };
const handlePublish = async () => { const handlePublish = async () => {
const response = const formData = new FormData();
effectiveMode === 'new' formData.append('name', agent.name);
? await userService.createAgent( formData.append('description', agent.description);
{ ...agent, status: 'published' }, formData.append('source', agent.source);
token, formData.append('chunks', agent.chunks);
) formData.append('retriever', agent.retriever);
: await userService.updateAgent( formData.append('prompt_id', agent.prompt_id);
agent.id || '', formData.append('agent_type', agent.agent_type);
{ ...agent, status: 'published' }, formData.append('status', 'published');
token,
); if (imageFile) formData.append('image', imageFile);
if (!response.ok) throw new Error('Failed to publish agent'); if (agent.tools && agent.tools.length > 0)
const data = await response.json(); formData.append('tools', JSON.stringify(agent.tools));
if (data.id) setAgent((prev) => ({ ...prev, id: data.id })); else formData.append('tools', '[]');
if (data.key) setAgent((prev) => ({ ...prev, key: data.key }));
if (effectiveMode === 'new' || effectiveMode === 'draft') { try {
setEffectiveMode('edit'); const response =
setAgent((prev) => ({ ...prev, status: 'published' })); effectiveMode === 'new'
setAgentDetails('ACTIVE'); ? await userService.createAgent(formData, token)
: await userService.updateAgent(agent.id || '', formData, token);
if (!response.ok) throw new Error('Failed to publish agent');
const data = await response.json();
if (data.id) setAgent((prev) => ({ ...prev, id: data.id }));
if (data.key) setAgent((prev) => ({ ...prev, key: data.key }));
if (effectiveMode === 'new' || effectiveMode === 'draft') {
setEffectiveMode('edit');
setAgent((prev) => ({
...prev,
status: 'published',
image: data.image || prev.image,
}));
setAgentDetails('ACTIVE');
}
} catch (error) {
console.error('Error publishing agent:', error);
throw new Error('Failed to publish agent');
} }
}; };
@@ -248,24 +295,24 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
> >
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" /> <img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button> </button>
<p className="mt-px text-sm font-semibold text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
Back to all agents Back to all agents
</p> </p>
</div> </div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4"> <div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="m-0 text-[40px] font-bold text-[#212121] dark:text-white"> <h1 className="text-eerie-black m-0 text-[40px] font-bold dark:text-white">
{modeConfig[effectiveMode].heading} {modeConfig[effectiveMode].heading}
</h1> </h1>
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<button <button
className="mr-4 rounded-3xl py-2 text-sm font-medium text-purple-30 dark:bg-transparent dark:text-light-gray" className="text-purple-30 dark:text-light-gray mr-4 rounded-3xl py-2 text-sm font-medium dark:bg-transparent"
onClick={handleCancel} onClick={handleCancel}
> >
Cancel Cancel
</button> </button>
{modeConfig[effectiveMode].showDelete && agent.id && ( {modeConfig[effectiveMode].showDelete && agent.id && (
<button <button
className="group flex items-center gap-2 rounded-3xl border border-solid border-red-2000 px-5 py-2 text-sm font-medium text-red-2000 transition-colors hover:bg-red-2000 hover:text-white" className="group border-red-2000 text-red-2000 hover:bg-red-2000 flex items-center gap-2 rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={() => setDeleteConfirmation('ACTIVE')} 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')]" /> <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')]" />
@@ -274,7 +321,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)} )}
{modeConfig[effectiveMode].showSaveDraft && ( {modeConfig[effectiveMode].showSaveDraft && (
<button <button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white" 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={handleSaveDraft} onClick={handleSaveDraft}
> >
Save Draft Save Draft
@@ -282,7 +329,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)} )}
{modeConfig[effectiveMode].showAccessDetails && ( {modeConfig[effectiveMode].showAccessDetails && (
<button <button
className="group flex items-center gap-2 rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white" className="group border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue flex items-center gap-2 rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={() => navigate(`/agents/logs/${agent.id}`)} 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')]" /> <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')]" />
@@ -291,7 +338,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)} )}
{modeConfig[effectiveMode].showAccessDetails && ( {modeConfig[effectiveMode].showAccessDetails && (
<button <button
className="hover:bg-vi</button>olets-are-blue rounded-3xl border border-solid border-violets-are-blue px-5 py-2 text-sm font-medium text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white" 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')} onClick={() => setAgentDetails('ACTIVE')}
> >
Access Details Access Details
@@ -299,7 +346,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)} )}
<button <button
disabled={!isPublishable()} disabled={!isPublishable()}
className={`${!isPublishable() && 'cursor-not-allowed opacity-30'} rounded-3xl bg-purple-30 px-5 py-2 text-sm font-medium text-white hover:bg-violets-are-blue`} className={`${!isPublishable() && 'cursor-not-allowed opacity-30'} bg-purple-30 hover:bg-violets-are-blue rounded-3xl px-5 py-2 text-sm font-medium text-white`}
onClick={handlePublish} onClick={handlePublish}
> >
Publish Publish
@@ -311,20 +358,35 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]"> <div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Meta</h2> <h2 className="text-lg font-semibold">Meta</h2>
<input <input
className="mt-3 w-full rounded-3xl border border-silver bg-white px-5 py-3 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver" 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" type="text"
value={agent.name} value={agent.name}
placeholder="Agent name" placeholder="Agent name"
onChange={(e) => setAgent({ ...agent, name: e.target.value })} onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/> />
<textarea <textarea
className="mt-3 h-32 w-full rounded-3xl border border-silver bg-white px-5 py-4 text-sm text-jet outline-none placeholder:text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-bright-gray placeholder:dark:text-silver" className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 h-32 w-full rounded-3xl border bg-white px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
placeholder="Describe your agent" placeholder="Describe your agent"
value={agent.description} value={agent.description}
onChange={(e) => onChange={(e) =>
setAgent({ ...agent, description: e.target.value }) setAgent({ ...agent, description: e.target.value })
} }
/> />
<div className="mt-3">
<FileUpload
showPreview
className="dark:bg-raisin-black"
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{ text: 'Click to upload', colorClass: 'text-[#7D54D1]' },
{
text: ' or drag and drop',
colorClass: 'text-[#525252]',
},
]}
/>
</div>
</div> </div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]"> <div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Source</h2> <h2 className="text-lg font-semibold">Source</h2>
@@ -333,7 +395,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<button <button
ref={sourceAnchorButtonRef} ref={sourceAnchorButtonRef}
onClick={() => setIsSourcePopupOpen(!isSourcePopupOpen)} onClick={() => setIsSourcePopupOpen(!isSourcePopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver" className={`border-silver dark:bg-raisin-black w-full truncate rounded-3xl border bg-white px-5 py-3 text-left text-sm dark:border-[#7E7E7E] ${
selectedSourceIds.size > 0
? 'text-jet dark:text-bright-gray'
: 'dark:text-silver text-gray-400'
}`}
> >
{selectedSourceIds.size > 0 {selectedSourceIds.size > 0
? Array.from(selectedSourceIds) ? Array.from(selectedSourceIds)
@@ -381,12 +447,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
} }
size="w-full" size="w-full"
rounded="3xl" rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border" border="border"
darkBorderColor="[#7E7E7E]" buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder="Chunks per query" placeholder="Chunks per query"
placeholderTextColor="gray-400" placeholderClassName="text-gray-400 dark:text-silver"
darkPlaceholderTextColor="silver"
contentSize="text-sm" contentSize="text-sm"
/> />
</div> </div>
@@ -395,7 +460,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]"> <div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Prompt</h2> <h2 className="text-lg font-semibold">Prompt</h2>
<div className="mt-3 flex flex-wrap items-center gap-1"> <div className="mt-3 flex flex-wrap items-center gap-1">
<div className="min-w-20 flex-grow basis-full sm:basis-0"> <div className="min-w-20 grow basis-full sm:basis-0">
<Dropdown <Dropdown
options={prompts.map((prompt) => ({ options={prompts.map((prompt) => ({
label: prompt.name, label: prompt.name,
@@ -413,17 +478,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
} }
size="w-full" size="w-full"
rounded="3xl" rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border" border="border"
darkBorderColor="[#7E7E7E]" buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E] dark:border-[#7E7E7E] dark:bg-dark-charcoal"
placeholderClassName="text-gray-400 dark:text-silver"
placeholder="Select a prompt" placeholder="Select a prompt"
placeholderTextColor="gray-400"
darkPlaceholderTextColor="silver"
contentSize="text-sm" contentSize="text-sm"
/> />
</div> </div>
<button <button
className="w-20 flex-shrink-0 basis-full rounded-3xl border-2 border-solid border-violets-are-blue px-5 py-[11px] text-sm text-violets-are-blue transition-colors hover:bg-violets-are-blue hover:text-white sm:basis-auto" 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')} onClick={() => setAddPromptModal('ACTIVE')}
> >
Add Add
@@ -436,7 +500,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<button <button
ref={toolAnchorButtonRef} ref={toolAnchorButtonRef}
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)} onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
className="w-full truncate rounded-3xl border border-silver bg-white px-5 py-3 text-left text-sm text-gray-400 dark:border-[#7E7E7E] dark:bg-[#222327] dark:text-silver" className={`border-silver dark:bg-raisin-black w-full truncate rounded-3xl border bg-white px-5 py-3 text-left text-sm dark:border-[#7E7E7E] ${
selectedToolIds.size > 0
? 'text-jet dark:text-bright-gray'
: 'dark:text-silver text-gray-400'
}`}
> >
{selectedToolIds.size > 0 {selectedToolIds.size > 0
? Array.from(selectedToolIds) ? Array.from(selectedToolIds)
@@ -478,12 +546,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
} }
size="w-full" size="w-full"
rounded="3xl" rounded="3xl"
buttonDarkBackgroundColor="[#222327]"
border="border" border="border"
darkBorderColor="[#7E7E7E]" buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder="Select type" placeholder="Select type"
placeholderTextColor="gray-400" placeholderClassName="text-gray-400 dark:text-silver"
darkPlaceholderTextColor="silver"
contentSize="text-sm" contentSize="text-sm"
/> />
</div> </div>
@@ -528,7 +595,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
function AgentPreviewArea() { function AgentPreviewArea() {
const selectedAgent = useSelector(selectSelectedAgent); const selectedAgent = useSelector(selectSelectedAgent);
return ( return (
<div className="h-full w-full rounded-[30px] border border-[#F6F6F6] bg-white dark:border-[#7E7E7E] dark:bg-[#222327] max-[1180px]:h-[48rem]"> <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' ? ( {selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]"> <div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]">
<AgentPreview /> <AgentPreview />
@@ -536,7 +603,7 @@ function AgentPreviewArea() {
) : ( ) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2"> <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')]" />{' '} <span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<p className="text-xs text-[#18181B] dark:text-[#949494]"> <p className="dark:text-gray-4000 text-xs text-[#18181B]">
Published agents can be previewed here Published agents can be previewed here
</p> </p>
</div> </div>

View File

@@ -57,9 +57,7 @@ export default function SharedAgent() {
const handleFetchAnswer = useCallback( const handleFetchAnswer = useCallback(
({ question, index }: { question: string; index?: number }) => { ({ question, index }: { question: string; index?: number }) => {
fetchStream.current = dispatch( fetchStream.current = dispatch(fetchAnswer({ question, indx: index }));
fetchAnswer({ question, indx: index, isPreview: false }),
);
}, },
[dispatch], [dispatch],
); );
@@ -145,7 +143,7 @@ export default function SharedAgent() {
alt="No agent found" alt="No agent found"
className="mx-auto mb-6 h-32 w-32" className="mx-auto mb-6 h-32 w-32"
/> />
<p className="text-center text-lg text-[#71717A] dark:text-[#949494]"> <p className="dark:text-gray-4000 text-center text-lg text-[#71717A]">
No agent found. Please ensure the agent is shared. No agent found. Please ensure the agent is shared.
</p> </p>
</div> </div>
@@ -153,13 +151,17 @@ export default function SharedAgent() {
); );
return ( return (
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<div className="absolute left-4 top-5 hidden items-center gap-3 sm:flex"> <div className="absolute top-5 left-4 hidden items-center gap-3 sm:flex">
<img <img
src={sharedAgent.image ?? Robot} src={
sharedAgent.image && sharedAgent.image.trim() !== ''
? sharedAgent.image
: Robot
}
alt="agent-logo" alt="agent-logo"
className="h-6 w-6" className="h-6 w-6 rounded-full object-contain"
/> />
<h2 className="text-lg font-semibold text-[#212121] dark:text-[#E0E0E0]"> <h2 className="text-eerie-black text-lg font-semibold dark:text-[#E0E0E0]">
{sharedAgent.name} {sharedAgent.name}
</h2> </h2>
</div> </div>
@@ -186,7 +188,7 @@ export default function SharedAgent() {
showToolButton={sharedAgent ? false : true} showToolButton={sharedAgent ? false : true}
autoFocus={false} autoFocus={false}
/> />
<p className="hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline md:w-full"> <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')} {t('tagline')}
</p> </p>
</div> </div>

View File

@@ -3,16 +3,19 @@ import { Agent } from './types';
export default function SharedAgentCard({ agent }: { agent: Agent }) { export default function SharedAgentCard({ agent }: { agent: Agent }) {
return ( return (
<div className="flex w-full max-w-[720px] flex-col rounded-3xl border border-dark-gray p-6 shadow-sm dark:border-grey sm:w-fit sm:min-w-[480px]"> <div className="border-dark-gray dark:border-grey flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1"> <div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<img src={Robot} className="h-full w-full object-contain" /> <img
src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
className="h-full w-full rounded-full object-contain"
/>
</div> </div>
<div className="flex max-h-[92px] w-[80%] flex-col gap-px"> <div className="flex max-h-[92px] w-[80%] flex-col gap-px">
<h2 className="text-base font-semibold text-[#212121] dark:text-[#E0E0E0] sm:text-lg"> <h2 className="text-eerie-black text-base font-semibold sm:text-lg dark:text-[#E0E0E0]">
{agent.name} {agent.name}
</h2> </h2>
<p className="overflow-y-auto text-wrap break-all text-xs text-[#71717A] dark:text-[#949494] sm:text-sm"> <p className="dark:text-gray-4000 overflow-y-auto text-xs text-wrap break-all text-[#71717A] sm:text-sm">
{agent.description} {agent.description}
</p> </p>
</div> </div>
@@ -20,12 +23,12 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) {
{agent.shared_metadata && ( {agent.shared_metadata && (
<div className="mt-4 flex items-center gap-8"> <div className="mt-4 flex items-center gap-8">
{agent.shared_metadata?.shared_by && ( {agent.shared_metadata?.shared_by && (
<p className="text-xs font-light text-[#212121] dark:text-[#E0E0E0] sm:text-sm"> <p className="text-eerie-black text-xs font-light sm:text-sm dark:text-[#E0E0E0]">
by {agent.shared_metadata.shared_by} by {agent.shared_metadata.shared_by}
</p> </p>
)} )}
{agent.shared_metadata?.shared_at && ( {agent.shared_metadata?.shared_at && (
<p className="text-xs font-light text-[#71717A] dark:text-[#949494] sm:text-sm"> <p className="dark:text-gray-4000 text-xs font-light text-[#71717A] sm:text-sm">
Shared on{' '} Shared on{' '}
{new Date(agent.shared_metadata.shared_at).toLocaleString( {new Date(agent.shared_metadata.shared_at).toLocaleString(
'en-US', 'en-US',
@@ -44,14 +47,14 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) {
)} )}
{agent.tool_details && agent.tool_details.length > 0 && ( {agent.tool_details && agent.tool_details.length > 0 && (
<div className="mt-8"> <div className="mt-8">
<p className="text-sm font-semibold text-[#212121] dark:text-[#E0E0E0] sm:text-base"> <p className="text-eerie-black text-sm font-semibold sm:text-base dark:text-[#E0E0E0]">
Connected Tools Connected Tools
</p> </p>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{agent.tool_details.map((tool, index) => ( {agent.tool_details.map((tool, index) => (
<span <span
key={index} key={index}
className="flex items-center gap-1 rounded-full bg-bright-gray px-3 py-1 text-xs font-light text-[#212121] dark:bg-dark-charcoal dark:text-[#E0E0E0]" className="bg-bright-gray text-eerie-black dark:bg-dark-charcoal flex items-center gap-1 rounded-full px-3 py-1 text-xs font-light dark:text-[#E0E0E0]"
> >
<img <img
src={`/toolIcons/tool_${tool.name}.svg`} src={`/toolIcons/tool_${tool.name}.svg`}

View File

@@ -0,0 +1,319 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import {
Answer,
ConversationState,
Query,
Status,
} from '../conversation/conversationModels';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from '../conversation/conversationHandlers';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
import store from '../store';
const initialState: ConversationState = {
queries: [],
status: 'idle',
conversationId: null,
};
const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true';
let abortController: AbortController | null = null;
export function handlePreviewAbort() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
export const fetchPreviewAnswer = createAsyncThunk<
Answer,
{ question: string; indx?: number }
>(
'agentPreview/fetchAnswer',
async ({ question, indx }, { dispatch, getState }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const { signal } = abortController;
const state = getState() as RootState;
const attachmentIds = selectCompletedAttachments(state)
.filter((a) => a.id)
.map((a) => a.id) as string[];
if (attachmentIds.length > 0) {
dispatch(clearAttachments());
}
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.agentPreview.queries,
null, // No conversation ID for previews
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.agentPreview.queries.length - 1;
if (data.type === 'end') {
dispatch(agentPreviewSlice.actions.setStatus('idle'));
} else if (data.type === 'thought') {
dispatch(
updateThought({
index: targetIndex,
query: { thought: data.thought },
}),
);
} else if (data.type === 'source') {
dispatch(
updateStreamingSource({
index: targetIndex,
query: { sources: data.source ?? [] },
}),
);
} else if (data.type === 'tool_call') {
dispatch(
updateToolCall({
index: targetIndex,
tool_call: data.data,
}),
);
} else if (data.type === 'error') {
dispatch(agentPreviewSlice.actions.setStatus('failed'));
dispatch(
agentPreviewSlice.actions.raiseError({
index: targetIndex,
message: data.error,
}),
);
} else {
dispatch(
updateStreamingQuery({
index: targetIndex,
query: { response: data.answer },
}),
);
}
},
indx,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
);
} else {
// Non-streaming implementation
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.agentPreview.queries,
null, // No conversation ID for previews
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
);
if (answer) {
const sourcesPrepped = answer.sources.map(
(source: { title: string }) => {
if (source && source.title) {
const titleParts = source.title.split('/');
return {
...source,
title: titleParts[titleParts.length - 1],
};
}
return source;
},
);
const targetIndex = indx ?? state.agentPreview.queries.length - 1;
dispatch(
updateQuery({
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
dispatch(agentPreviewSlice.actions.setStatus('idle'));
}
}
}
return {
conversationId: null,
title: null,
answer: '',
query: question,
result: '',
thought: '',
sources: [],
tool_calls: [],
};
},
);
export const agentPreviewSlice = createSlice({
name: 'agentPreview',
initialState,
reducers: {
addQuery(state, action: PayloadAction<Query>) {
state.queries.push(action.payload);
},
resendQuery(
state,
action: PayloadAction<{ index: number; prompt: string; query?: Query }>,
) {
state.queries = [
...state.queries.splice(0, action.payload.index),
action.payload,
];
},
updateStreamingQuery(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (state.status === 'idle') return;
if (query.response != undefined) {
state.queries[index].response =
(state.queries[index].response || '') + query.response;
}
},
updateThought(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (query.thought != undefined) {
state.queries[index].thought =
(state.queries[index].thought || '') + query.thought;
}
},
updateStreamingSource(
state,
action: PayloadAction<{
index: number;
query: Partial<Query>;
}>,
) {
const { index, query } = action.payload;
if (!state.queries[index].sources) {
state.queries[index].sources = query?.sources;
} else if (query.sources) {
state.queries[index].sources!.push(...query.sources);
}
},
updateToolCall(state, action) {
const { index, tool_call } = action.payload;
if (!state.queries[index].tool_calls) {
state.queries[index].tool_calls = [];
}
const existingIndex = state.queries[index].tool_calls.findIndex(
(call) => call.call_id === tool_call.call_id,
);
if (existingIndex !== -1) {
const existingCall = state.queries[index].tool_calls[existingIndex];
state.queries[index].tool_calls[existingIndex] = {
...existingCall,
...tool_call,
};
} else state.queries[index].tool_calls.push(tool_call);
},
updateQuery(
state,
action: PayloadAction<{ index: number; query: Partial<Query> }>,
) {
const { index, query } = action.payload;
state.queries[index] = {
...state.queries[index],
...query,
};
},
setStatus(state, action: PayloadAction<Status>) {
state.status = action.payload;
},
raiseError(
state,
action: PayloadAction<{
index: number;
message: string;
}>,
) {
const { index, message } = action.payload;
state.queries[index].error = message;
},
resetPreview: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
state.conversationId = initialState.conversationId;
handlePreviewAbort();
},
},
extraReducers(builder) {
builder
.addCase(fetchPreviewAnswer.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchPreviewAnswer.rejected, (state, action) => {
if (action.meta.aborted) {
state.status = 'idle';
return state;
}
state.status = 'failed';
state.queries[state.queries.length - 1].error = 'Something went wrong';
});
},
});
type RootState = ReturnType<typeof store.getState>;
export const selectPreviewQueries = (state: RootState) =>
state.agentPreview.queries;
export const selectPreviewStatus = (state: RootState) =>
state.agentPreview.status;
export const {
addQuery,
updateQuery,
resendQuery,
updateStreamingQuery,
updateThought,
updateStreamingSource,
updateToolCall,
setStatus,
raiseError,
resetPreview,
} = agentPreviewSlice.actions;
export default agentPreviewSlice.reducer;

View File

@@ -111,10 +111,10 @@ function AgentsList() {
}, [token]); }, [token]);
return ( return (
<div className="p-4 md:p-12"> <div className="p-4 md:p-12">
<h1 className="mb-0 text-[40px] font-bold text-[#212121] dark:text-[#E0E0E0]"> <h1 className="text-eerie-black mb-0 text-[40px] font-bold dark:text-[#E0E0E0]">
Agents Agents
</h1> </h1>
<p className="mt-5 text-[15px] text-[#71717A] dark:text-[#949494]"> <p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
Discover and create custom versions of DocsGPT that combine Discover and create custom versions of DocsGPT that combine
instructions, extra knowledge, and any combination of skills instructions, extra knowledge, and any combination of skills
</p> </p>
@@ -206,7 +206,7 @@ function AgentSection({
</div> </div>
{sectionConfig[section].showNewAgentButton && ( {sectionConfig[section].showNewAgentButton && (
<button <button
className="rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue" className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')} onClick={() => navigate('/agents/new')}
> >
New Agent New Agent
@@ -235,7 +235,7 @@ function AgentSection({
<p>{sectionConfig[section].emptyStateDescription}</p> <p>{sectionConfig[section].emptyStateDescription}</p>
{sectionConfig[section].showNewAgentButton && ( {sectionConfig[section].showNewAgentButton && (
<button <button
className="ml-2 rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue" 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')} onClick={() => navigate('/agents/new')}
> >
New Agent New Agent
@@ -324,17 +324,21 @@ function AgentCard({
iconWidth: 14, iconWidth: 14,
iconHeight: 14, iconHeight: 14,
}, },
{ ...(agent.status === 'published'
icon: agent.pinned ? UnPin : Pin, ? [
label: agent.pinned ? 'Unpin' : 'Pin agent', {
onClick: (e: SyntheticEvent) => { icon: agent.pinned ? UnPin : Pin,
e.stopPropagation(); label: agent.pinned ? 'Unpin' : 'Pin agent',
togglePin(); onClick: (e: SyntheticEvent) => {
}, e.stopPropagation();
variant: 'primary', togglePin();
iconWidth: 18, },
iconHeight: 18, variant: 'primary' as const,
}, iconWidth: 18,
iconHeight: 18,
},
]
: []),
{ {
icon: Trash, icon: Trash,
label: 'Delete', label: 'Delete',
@@ -406,7 +410,7 @@ function AgentCard({
}; };
return ( return (
<div <div
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] hover:dark:bg-[#383838]/80 md:w-48 ${agent.status === 'published' && 'cursor-pointer'}`} className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] md:w-48 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleClick(); handleClick();
@@ -418,7 +422,7 @@ function AgentCard({
e.stopPropagation(); e.stopPropagation();
setIsMenuOpen(true); setIsMenuOpen(true);
}} }}
className="absolute right-4 top-4 z-10 cursor-pointer" className="absolute top-4 right-4 z-10 cursor-pointer"
> >
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" /> <img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
<ContextMenu <ContextMenu
@@ -426,16 +430,16 @@ function AgentCard({
setIsOpen={setIsMenuOpen} setIsOpen={setIsMenuOpen}
options={menuOptions} options={menuOptions}
anchorRef={menuRef} anchorRef={menuRef}
position="top-right" position="bottom-right"
offset={{ x: 0, y: 0 }} offset={{ x: 0, y: 0 }}
/> />
</div> </div>
<div className="w-full"> <div className="w-full">
<div className="flex w-full items-center gap-1 px-1"> <div className="flex w-full items-center gap-1 px-1">
<img <img
src={agent.image ?? Robot} src={agent.image && agent.image.trim() !== '' ? agent.image : Robot}
alt={`${agent.name}`} alt={`${agent.name}`}
className="h-7 w-7 rounded-full" className="h-7 w-7 rounded-full object-contain"
/> />
{agent.status === 'draft' && ( {agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p> <p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>
@@ -444,11 +448,11 @@ function AgentCard({
<div className="mt-2"> <div className="mt-2">
<p <p
title={agent.name} title={agent.name}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-[#020617] dark:text-[#E0E0E0]" className="truncate px-1 text-[13px] leading-relaxed font-semibold text-[#020617] capitalize dark:text-[#E0E0E0]"
> >
{agent.name} {agent.name}
</p> </p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B] dark:text-sonic-silver-light"> <p className="dark:text-sonic-silver-light mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B]">
{agent.description} {agent.description}
</p> </p>
</div> </div>

View File

@@ -1,16 +1,21 @@
export const baseURL = export const baseURL =
import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
const defaultHeaders = { const getHeaders = (
'Content-Type': 'application/json', token: string | null,
}; customHeaders = {},
isFormData = false,
const getHeaders = (token: string | null, customHeaders = {}): HeadersInit => { ): HeadersInit => {
return { const headers: HeadersInit = {
...defaultHeaders,
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...customHeaders, ...customHeaders,
}; };
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
return headers;
}; };
const apiClient = { const apiClient = {
@@ -44,6 +49,21 @@ const apiClient = {
return response; return response;
}), }),
postFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'POST',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
put: ( put: (
url: string, url: string,
data: any, data: any,
@@ -60,6 +80,21 @@ const apiClient = {
return response; return response;
}), }),
putFormData: (
url: string,
formData: FormData,
token: string | null,
headers = {},
signal?: AbortSignal,
): Promise<Response> => {
return fetch(`${baseURL}${url}`, {
method: 'PUT',
headers: getHeaders(token, headers, true),
body: formData,
signal,
});
},
delete: ( delete: (
url: string, url: string,
token: string | null, token: string | null,

View File

@@ -22,13 +22,13 @@ const userService = {
getAgents: (token: string | null): Promise<any> => getAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENTS, token), apiClient.get(endpoints.USER.AGENTS, token),
createAgent: (data: any, token: string | null): Promise<any> => createAgent: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.CREATE_AGENT, data, token), apiClient.postFormData(endpoints.USER.CREATE_AGENT, data, token),
updateAgent: ( updateAgent: (
agent_id: string, agent_id: string,
data: any, data: any,
token: string | null, token: string | null,
): Promise<any> => ): Promise<any> =>
apiClient.put(endpoints.USER.UPDATE_AGENT(agent_id), data, token), apiClient.putFormData(endpoints.USER.UPDATE_AGENT(agent_id), data, token),
deleteAgent: (id: string, token: string | null): Promise<any> => deleteAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.DELETE_AGENT(id), token), apiClient.delete(endpoints.USER.DELETE_AGENT(id), token),
getPinnedAgents: (token: string | null): Promise<any> => getPinnedAgents: (token: string | null): Promise<any> =>

View File

@@ -1,3 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.16669 11.5H9.83335V13.1666H8.16669V11.5ZM8.16669 4.83329H9.83335V9.83329H8.16669V4.83329ZM8.99169 0.666626C4.39169 0.666626 0.666687 4.39996 0.666687 8.99996C0.666687 13.6 4.39169 17.3333 8.99169 17.3333C13.6 17.3333 17.3334 13.6 17.3334 8.99996C17.3334 4.39996 13.6 0.666626 8.99169 0.666626ZM9.00002 15.6666C5.31669 15.6666 2.33335 12.6833 2.33335 8.99996C2.33335 5.31663 5.31669 2.33329 9.00002 2.33329C12.6834 2.33329 15.6667 5.31663 15.6667 8.99996C15.6667 12.6833 12.6834 15.6666 9.00002 15.6666Z" fill="#F44336"/> <path d="M8.16669 11.5H9.83335V13.1666H8.16669V11.5ZM8.16669 4.83329H9.83335V9.83329H8.16669V4.83329ZM8.99169 0.666626C4.39169 0.666626 0.666687 4.39996 0.666687 8.99996C0.666687 13.6 4.39169 17.3333 8.99169 17.3333C13.6 17.3333 17.3334 13.6 17.3334 8.99996C17.3334 4.39996 13.6 0.666626 8.99169 0.666626ZM9.00002 15.6666C5.31669 15.6666 2.33335 12.6833 2.33335 8.99996C2.33335 5.31663 5.31669 2.33329 9.00002 2.33329C12.6834 2.33329 15.6667 5.31663 15.6667 8.99996C15.6667 12.6833 12.6834 15.6666 9.00002 15.6666Z" fill="#ECECF1"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 636 B

After

Width:  |  Height:  |  Size: 636 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 262 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.4028 7.35671V12.6427C12.4043 12.844 12.3655 13.0436 12.2889 13.2298C12.2122 13.4159 12.0991 13.5849 11.9564 13.7269C11.8136 13.8688 11.6439 13.9808 11.4573 14.0563C11.2706 14.1318 11.0708 14.1694 10.8695 14.1667H3.36483C3.16278 14.1693 2.96226 14.1314 2.77509 14.0553C2.58792 13.9791 2.41789 13.8663 2.27504 13.7234C2.13219 13.5804 2.01941 13.4104 1.94335 13.2232C1.86728 13.036 1.82948 12.8354 1.83217 12.6334V5.12871C1.82975 4.92668 1.86776 4.7262 1.94396 4.53908C2.02017 4.35196 2.13302 4.18196 2.27589 4.0391C2.41875 3.89623 2.58875 3.78338 2.77587 3.70717C2.963 3.63097 3.16347 3.59296 3.3655 3.59537H8.65083M14.1648 1.83337L7.1175 8.88071M14.1648 1.83337H10.6408M14.1648 1.83337V5.35737" stroke="#7D54D1" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@@ -0,0 +1,3 @@
<svg width="40" height="39" viewBox="0 0 40 39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9477 3.02295H33.8827C35.898 3.02295 37.5388 4.6819 37.5388 6.71923V22.9819C37.5388 25.0193 35.898 26.6782 33.8827 26.6782H11.9477C9.9328 26.6782 8.29192 25.0193 8.29192 22.9819V6.71923C8.29192 4.6819 9.9328 3.02295 11.9477 3.02295ZM33.8827 5.97992H11.9477C11.5442 5.97992 11.2167 6.31098 11.2167 6.71916V20.6741L15.2527 16.595C16.2515 15.5839 17.8791 15.5839 18.8792 16.595L20.6486 18.3795L26.0799 11.7888C26.5653 11.2003 27.276 10.8603 28.0335 10.856C28.7953 10.8735 29.5046 11.1841 29.9946 11.765L34.614 17.2147V6.71923C34.614 6.31104 34.2866 5.97992 33.8827 5.97992ZM6.40446 25.1242C7.16068 27.3803 9.243 28.8957 11.584 28.8957H32.8128L31.4954 33.1312C31.1223 34.5715 29.7916 35.5487 28.3352 35.5487C28.051 35.5485 27.768 35.5117 27.4929 35.4393L4.88059 29.3169C3.13614 28.8305 2.09642 27.0048 2.55267 25.2438L6.10025 13.2714V23.3516C6.10025 23.8543 6.17497 24.3567 6.35333 24.9542L6.40446 25.1242ZM18.53 10.4151C18.53 12.0459 17.2186 13.3721 15.6055 13.3721C13.9926 13.3721 12.6808 12.0458 12.6808 10.4151C12.6808 8.78445 13.9925 7.45815 15.6055 7.45815C17.2186 7.45815 18.53 8.78438 18.53 10.4151Z" fill="#A3A3A3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,19 +1,19 @@
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.394 1.001H7.982C7.32776 1.00074 6.67989 1.12939 6.07539 1.3796C5.47089 1.62982 4.92162 1.99669 4.45896 2.45926C3.9963 2.92182 3.62932 3.47102 3.37898 4.07547C3.12865 4.67992 2.99987 5.32776 3 5.982V11.394C2.99974 12.0483 3.12842 12.6963 3.3787 13.3008C3.62897 13.9054 3.99593 14.4547 4.45861 14.9174C4.92128 15.3801 5.4706 15.747 6.07516 15.9973C6.67972 16.2476 7.32768 16.3763 7.982 16.376H13.394C14.0483 16.3763 14.6963 16.2476 15.3008 15.9973C15.9054 15.747 16.4547 15.3801 16.9174 14.9174C17.3801 14.4547 17.747 13.9054 17.9973 13.3008C18.2476 12.6963 18.3763 12.0483 18.376 11.394V5.982C18.3763 5.32768 18.2476 4.67972 17.9973 4.07516C17.747 3.4706 17.3801 2.92128 16.9174 2.45861C16.4547 1.99593 15.9054 1.62897 15.3008 1.3787C14.6963 1.12842 14.0483 0.999738 13.394 1V1.001Z" stroke="url(#paint0_linear_8958_15228)" stroke-width="1.5"/> <path d="M14.394 4.001H8.982C8.32776 4.00074 7.67989 4.12939 7.07539 4.3796C6.47089 4.62982 5.92162 4.99669 5.45896 5.45926C4.9963 5.92182 4.62932 6.47102 4.37898 7.07547C4.12865 7.67992 3.99987 8.32776 4 8.982V14.394C3.99974 15.0483 4.12842 15.6963 4.3787 16.3008C4.62897 16.9054 4.99593 17.4547 5.45861 17.9174C5.92128 18.3801 6.4706 18.747 7.07516 18.9973C7.67972 19.2476 8.32768 19.3763 8.982 19.376H14.394C15.0483 19.3763 15.6963 19.2476 16.3008 18.9973C16.9054 18.747 17.4547 18.3801 17.9174 17.9174C18.3801 17.4547 18.747 16.9054 18.9973 16.3008C19.2476 15.6963 19.3763 15.0483 19.376 14.394V8.982C19.3763 8.32768 19.2476 7.67972 18.9973 7.07516C18.747 6.4706 18.3801 5.92128 17.9174 5.45861C17.4547 4.99593 16.9054 4.62897 16.3008 4.3787C15.6963 4.12842 15.0483 3.99974 14.394 4V4.001Z" stroke="url(#paint0_linear_9044_3689)" stroke-width="1.5"/>
<path d="M18.606 12.5881H20.225C20.4968 12.5881 20.7576 12.4801 20.9498 12.2879C21.142 12.0956 21.25 11.8349 21.25 11.5631V6.43809C21.25 6.16624 21.142 5.90553 20.9498 5.7133C20.7576 5.52108 20.4968 5.41309 20.225 5.41309H18.605M3.395 12.5881H1.775C1.6404 12.5881 1.50711 12.5616 1.38275 12.5101C1.25839 12.4586 1.1454 12.3831 1.05022 12.2879C0.955035 12.1927 0.879535 12.0797 0.828023 11.9553C0.776512 11.831 0.75 11.6977 0.75 11.5631V6.43809C0.75 6.16624 0.857991 5.90553 1.05022 5.7133C1.24244 5.52108 1.50315 5.41309 1.775 5.41309H3.395" stroke="url(#paint1_linear_8958_15228)" stroke-width="1.5"/> <path d="M19.606 15.5881H21.225C21.4968 15.5881 21.7576 15.4801 21.9498 15.2879C22.142 15.0956 22.25 14.8349 22.25 14.5631V9.43809C22.25 9.16624 22.142 8.90553 21.9498 8.7133C21.7576 8.52108 21.4968 8.41309 21.225 8.41309H19.605M4.395 15.5881H2.775C2.6404 15.5881 2.50711 15.5616 2.38275 15.5101C2.25839 15.4586 2.1454 15.3831 2.05022 15.2879C1.95504 15.1927 1.87953 15.0797 1.82802 14.9553C1.77651 14.831 1.75 14.6977 1.75 14.5631V9.43809C1.75 9.16624 1.85799 8.90553 2.05022 8.7133C2.24244 8.52108 2.50315 8.41309 2.775 8.41309H4.395" stroke="url(#paint1_linear_9044_3689)" stroke-width="1.5"/>
<path d="M1.76562 5.41323V1.31323M20.2256 5.41323L20.2156 1.31323M7.91562 5.76323V8.46123M14.0656 5.76323V8.46123M8.94062 12.5882H13.0406" stroke="url(#paint2_linear_8958_15228)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M2.76562 8.41323V4.31323M21.2256 8.41323L21.2156 4.31323M8.91562 8.76323V11.4612M15.0656 8.76323V11.4612M9.94062 15.5882H14.0406" stroke="url(#paint2_linear_9044_3689)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<defs> <defs>
<linearGradient id="paint0_linear_8958_15228" x1="10.688" y1="1" x2="10.688" y2="16.376" gradientUnits="userSpaceOnUse"> <linearGradient id="paint0_linear_9044_3689" x1="11.688" y1="4" x2="11.688" y2="19.376" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/> <stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/> <stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/> <stop offset="1" stop-color="#CC7871"/>
</linearGradient> </linearGradient>
<linearGradient id="paint1_linear_8958_15228" x1="11" y1="5.41309" x2="11" y2="12.5881" gradientUnits="userSpaceOnUse"> <linearGradient id="paint1_linear_9044_3689" x1="12" y1="8.41309" x2="12" y2="15.5881" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/> <stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/> <stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/> <stop offset="1" stop-color="#CC7871"/>
</linearGradient> </linearGradient>
<linearGradient id="paint2_linear_8958_15228" x1="10.9956" y1="1.31323" x2="10.9956" y2="12.5882" gradientUnits="userSpaceOnUse"> <linearGradient id="paint2_linear_9044_3689" x1="11.9956" y1="4.31323" x2="11.9956" y2="15.5882" gradientUnits="userSpaceOnUse">
<stop stop-color="#58E2E1"/> <stop stop-color="#58E2E1"/>
<stop offset="0.524038" stop-color="#657797"/> <stop offset="0.524038" stop-color="#657797"/>
<stop offset="1" stop-color="#CC7871"/> <stop offset="1" stop-color="#CC7871"/>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -32,9 +32,9 @@ export default function Accordion({
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
return ( return (
<div className={`overflow-hidden shadow-sm ${className}`}> <div className={`overflow-hidden shadow-xs ${className}`}>
<button <button
className={`flex w-full items-center justify-between focus:outline-none ${titleClassName}`} className={`flex w-full items-center justify-between focus:outline-hidden ${titleClassName}`}
onClick={toggleAccordion} onClick={toggleAccordion}
> >
<p className="break-words">{title}</p> <p className="break-words">{title}</p>

View File

@@ -9,5 +9,5 @@ export default function Avatar({
size?: 'SMALL' | 'MEDIUM' | 'LARGE'; size?: 'SMALL' | 'MEDIUM' | 'LARGE';
className: string; className: string;
}) { }) {
return <div className={`${className} flex-shrink-0`}>{avatar}</div>; return <div className={`${className} shrink-0`}>{avatar}</div>;
} }

View File

@@ -14,10 +14,10 @@ interface ContextMenuProps {
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
options: MenuOption[]; options: MenuOption[];
anchorRef: React.RefObject<HTMLElement>; anchorRef: React.RefObject<HTMLDivElement | null>;
className?: string; position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
offset?: { x: number; y: number }; offset?: { x: number; y: number };
className?: string;
} }
export default function ContextMenu({ export default function ContextMenu({
@@ -125,7 +125,7 @@ export default function ContextMenu({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div <div
className="flex flex-col rounded-xl bg-lotion text-sm shadow-xl dark:bg-charleston-green-2" className="bg-lotion dark:bg-charleston-green-2 flex flex-col rounded-xl text-sm shadow-xl"
style={{ minWidth: '144px' }} style={{ minWidth: '144px' }}
> >
{options.map((option, index) => ( {options.map((option, index) => (
@@ -144,7 +144,7 @@ export default function ContextMenu({
} `} } `}
> >
{option.icon && ( {option.icon && (
<div className="flex w-4 min-w-4 flex-shrink-0 justify-center"> <div className="flex w-4 min-w-4 shrink-0 justify-center">
<img <img
width={option.iconWidth || 16} width={option.iconWidth || 16}
height={option.iconHeight || 16} height={option.iconHeight || 16}
@@ -154,7 +154,7 @@ export default function ContextMenu({
/> />
</div> </div>
)} )}
<span className="hyphens-auto break-words">{option.label}</span> <span className="break-words hyphens-auto">{option.label}</span>
</button> </button>
))} ))}
</div> </div>

View File

@@ -61,7 +61,7 @@ export default function CopyButton({
const rootButtonClasses = clsx( const rootButtonClasses = clsx(
'flex items-center gap-2 group', 'flex items-center gap-2 group',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 rounded-full', 'focus:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 rounded-full',
className, className,
); );

View File

@@ -0,0 +1,52 @@
import React from 'react';
interface DocumentHeadProps {
title?: string;
description?: string;
keywords?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
twitterCard?: string;
twitterTitle?: string;
twitterDescription?: string;
children?: React.ReactNode;
}
export function DocumentHead({
title,
description,
keywords,
ogTitle,
ogDescription,
ogImage,
twitterCard,
twitterTitle,
twitterDescription,
children,
}: DocumentHeadProps) {
return (
<>
{title && <title>{title}</title>}
{description && <meta name="description" content={description} />}
{keywords && <meta name="keywords" content={keywords} />}
{/* Open Graph */}
{ogTitle && <meta property="og:title" content={ogTitle} />}
{ogDescription && (
<meta property="og:description" content={ogDescription} />
)}
{ogImage && <meta property="og:image" content={ogImage} />}
{/* Twitter */}
{twitterCard && <meta name="twitter:card" content={twitterCard} />}
{twitterTitle && <meta name="twitter:title" content={twitterTitle} />}
{twitterDescription && (
<meta name="twitter:description" content={twitterDescription} />
)}
{/* Additional elements */}
{children}
</>
);
}

View File

@@ -10,20 +10,15 @@ function Dropdown({
onSelect, onSelect,
size = 'w-32', size = 'w-32',
rounded = 'xl', rounded = 'xl',
buttonBackgroundColor = 'white', buttonClassName = 'border-silver bg-white dark:bg-transparent dark:border-dim-gray',
buttonDarkBackgroundColor = 'transparent', optionsClassName = 'border-silver bg-white dark:border-dim-gray dark:bg-dark-charcoal',
optionsBackgroundColor = 'white',
optionsDarkBackgroundColor = 'dark-charcoal',
border = 'border-2', border = 'border-2',
borderColor = 'silver',
darkBorderColor = 'dim-gray',
showEdit, showEdit,
onEdit, onEdit,
showDelete, showDelete,
onDelete, onDelete,
placeholder, placeholder,
placeholderTextColor = 'gray-500', placeholderClassName = 'text-gray-500 dark:text-gray-400',
darkPlaceholderTextColor = 'gray-400',
contentSize = 'text-base', contentSize = 'text-base',
}: { }: {
options: options:
@@ -44,20 +39,15 @@ function Dropdown({
| ((value: { value: number; description: string }) => void); | ((value: { value: number; description: string }) => void);
size?: string; size?: string;
rounded?: 'xl' | '3xl'; rounded?: 'xl' | '3xl';
buttonBackgroundColor?: string; buttonClassName?: string;
buttonDarkBackgroundColor?: string; optionsClassName?: string;
optionsBackgroundColor?: string;
optionsDarkBackgroundColor?: string;
border?: 'border' | 'border-2'; border?: 'border' | 'border-2';
borderColor?: string;
darkBorderColor?: string;
showEdit?: boolean; showEdit?: boolean;
onEdit?: (value: { name: string; id: string; type: string }) => void; onEdit?: (value: { name: string; id: string; type: string }) => void;
showDelete?: boolean; showDelete?: boolean | ((option: any) => boolean);
onDelete?: (value: string) => void; onDelete?: (value: string) => void;
placeholder?: string; placeholder?: string;
placeholderTextColor?: string; placeholderClassName?: string;
darkPlaceholderTextColor?: string;
contentSize?: string; contentSize?: string;
}) { }) {
const dropdownRef = React.useRef<HTMLDivElement>(null); const dropdownRef = React.useRef<HTMLDivElement>(null);
@@ -80,6 +70,7 @@ function Dropdown({
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, []); }, []);
return ( return (
<div <div
className={[ className={[
@@ -92,19 +83,18 @@ function Dropdown({
> >
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={`flex w-full cursor-pointer items-center justify-between ${border} border-${borderColor} bg-${buttonBackgroundColor} px-5 py-3 dark:border-${darkBorderColor} dark:bg-${buttonDarkBackgroundColor} ${ className={`flex w-full cursor-pointer items-center justify-between ${border} ${buttonClassName} px-5 py-3 ${
isOpen ? `${borderTopRadius}` : `${borderRadius}` isOpen ? `${borderTopRadius}` : `${borderRadius}`
}`} }`}
> >
{typeof selectedValue === 'string' ? ( {typeof selectedValue === 'string' ? (
<span className="truncate dark:text-bright-gray"> <span className="dark:text-bright-gray truncate">
{selectedValue} {selectedValue}
</span> </span>
) : ( ) : (
<span <span
className={`truncate ${selectedValue && `dark:text-bright-gray`} ${ className={`truncate ${selectedValue && `dark:text-bright-gray`} ${
!selectedValue && !selectedValue && ` ${placeholderClassName}`
`text-${placeholderTextColor} dark:text-${darkPlaceholderTextColor}`
} ${contentSize}`} } ${contentSize}`}
> >
{selectedValue && 'label' in selectedValue {selectedValue && 'label' in selectedValue
@@ -130,7 +120,7 @@ function Dropdown({
</button> </button>
{isOpen && ( {isOpen && (
<div <div
className={`absolute left-0 right-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} border-${borderColor} bg-${optionsBackgroundColor} shadow-lg dark:border-${darkBorderColor} dark:bg-${optionsDarkBackgroundColor}`} className={`absolute right-0 left-0 z-20 -mt-1 max-h-40 overflow-y-auto rounded-b-xl ${border} ${optionsClassName} shadow-lg`}
> >
{options.map((option: any, index) => ( {options.map((option: any, index) => (
<div <div
@@ -142,7 +132,7 @@ function Dropdown({
onSelect(option); onSelect(option);
setIsOpen(false); setIsOpen(false);
}} }}
className={`ml-5 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3 dark:text-light-gray ${contentSize}`} className={`dark:text-light-gray ml-5 flex-1 overflow-hidden py-3 text-ellipsis whitespace-nowrap ${contentSize}`}
> >
{typeof option === 'string' {typeof option === 'string'
? option ? option
@@ -173,8 +163,15 @@ function Dropdown({
)} )}
{showDelete && onDelete && ( {showDelete && onDelete && (
<button <button
onClick={() => onDelete(option.id)} onClick={(e) => {
disabled={option.type === 'public'} e.stopPropagation();
onDelete?.(typeof option === 'string' ? option : option.id);
}}
className={`${
typeof showDelete === 'function' && !showDelete(option)
? 'hidden'
: ''
} mr-2 h-4 w-4 cursor-pointer hover:opacity-50`}
> >
<img <img
src={Trash} src={Trash}

View File

@@ -7,12 +7,12 @@ type DropdownMenuProps = {
onSelect: (value: string) => void; onSelect: (value: string) => void;
defaultValue?: string; defaultValue?: string;
icon?: string; icon?: string;
isOpen?: boolean; isOpen: boolean;
onOpenChange?: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
anchorRef?: React.RefObject<HTMLElement>; anchorRef: React.RefObject<HTMLElement | null>;
className?: string; position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
offset?: { x: number; y: number }; offset?: { x: number; y: number };
className?: string;
}; };
export default function DropdownMenu({ export default function DropdownMenu({

View File

@@ -0,0 +1,229 @@
import React, { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { twMerge } from 'tailwind-merge';
import Cross from '../assets/cross.svg';
import ImagesIcon from '../assets/images.svg';
interface FileUploadProps {
onUpload: (files: File[]) => void;
onRemove?: (file: File) => void;
multiple?: boolean;
maxFiles?: number;
maxSize?: number; // in bytes
accept?: Record<string, string[]>; // e.g. { 'image/*': ['.png', '.jpg'] }
showPreview?: boolean;
previewSize?: number;
children?: React.ReactNode;
className?: string;
activeClassName?: string;
acceptClassName?: string;
rejectClassName?: string;
uploadText?: string | { text: string; colorClass?: string }[];
dragActiveText?: string;
fileTypeText?: string;
sizeLimitText?: string;
disabled?: boolean;
validator?: (file: File) => { isValid: boolean; error?: string };
}
export const FileUpload = ({
onUpload,
onRemove,
multiple = false,
maxFiles = 1,
maxSize = 5 * 1024 * 1024,
accept = { 'image/*': ['.jpeg', '.png', '.jpg'] },
showPreview = false,
previewSize = 80,
children,
className = 'border-2 border-dashed rounded-3xl p-6 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]',
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 = '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 [errors, setErrors] = useState<string[]>([]);
const [preview, setPreview] = useState<string | null>(null);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const validateFile = (file: File) => {
const defaultValidation = {
isValid: true,
error: '',
};
if (validator) {
const customValidation = validator(file);
if (!customValidation.isValid) {
return customValidation;
}
}
if (file.size > maxSize) {
return {
isValid: false,
error: `File exceeds ${maxSize / 1024 / 1024}MB limit`,
};
}
return defaultValidation;
};
const onDrop = useCallback(
(acceptedFiles: File[], fileRejections: any[]) => {
setErrors([]);
if (fileRejections.length > 0) {
const newErrors = fileRejections
.map(({ errors }) => errors.map((e: any) => e.message))
.flat();
setErrors(newErrors);
return;
}
const validationResults = acceptedFiles.map(validateFile);
const invalidFiles = validationResults.filter((r) => !r.isValid);
if (invalidFiles.length > 0) {
setErrors(invalidFiles.map((f) => f.error!));
return;
}
const filesToUpload = multiple ? acceptedFiles : [acceptedFiles[0]];
onUpload(filesToUpload);
const file = multiple ? acceptedFiles[0] : acceptedFiles[0];
setCurrentFile(file);
if (showPreview && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
},
[onUpload, multiple, maxSize, validator],
);
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
} = useDropzone({
onDrop,
multiple,
maxFiles,
maxSize,
accept,
disabled,
});
const currentClassName = twMerge(
'border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-colors border-silver dark:border-[#7E7E7E]',
className,
isDragActive && activeClassName,
isDragAccept && acceptClassName,
isDragReject && rejectClassName,
disabled && 'opacity-50 cursor-not-allowed',
);
const handleRemove = () => {
setPreview(null);
setCurrentFile(null);
if (onRemove && currentFile) onRemove(currentFile);
};
const renderPreview = () => (
<div
className="relative"
style={{ width: previewSize, height: previewSize }}
>
<img
src={preview ?? undefined}
alt="preview"
className="h-full w-full rounded-md object-cover"
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
className="absolute -right-2 -top-2 rounded-full bg-[#7D54D1] p-1 transition-colors hover:bg-[#714cbc]"
>
<img src={Cross} alt="remove" className="h-3 w-3" />
</button>
</div>
);
const renderUploadText = () => {
if (Array.isArray(uploadText)) {
return (
<p className="text-sm font-semibold">
{uploadText.map((segment, i) => (
<span key={i} className={segment.colorClass || ''}>
{segment.text}
</span>
))}
</p>
);
}
return <p className="text-sm font-semibold">{uploadText}</p>;
};
const defaultContent = (
<div className="flex flex-col items-center gap-2">
{showPreview && preview ? (
renderPreview()
) : (
<div
style={{ width: previewSize, height: previewSize }}
className="flex items-center justify-center"
>
<img src={ImagesIcon} className="h-10 w-10" />
</div>
)}
<div className="text-center">
<div className="text-sm font-medium">
{isDragActive ? (
<p className="text-sm font-semibold">{dragActiveText}</p>
) : (
renderUploadText()
)}
</div>
<p className="mt-1 text-xs text-[#A3A3A3]">
{fileTypeText} {maxSize / 1024 / 1024}
{sizeLimitText}
</p>
</div>
</div>
);
return (
<div className="relative">
<div {...getRootProps({ className: currentClassName })}>
<input {...getInputProps()} />
{children || defaultContent}
{errors.length > 0 && (
<div className="absolute left-0 right-0 mt-[2px] px-4 text-xs text-red-600">
{errors.map((error, i) => (
<p key={i} className="truncate">
{error}
</p>
))}
</div>
)}
</div>
</div>
);
};

View File

@@ -43,13 +43,13 @@ const Help = () => {
</button> </button>
{isOpen && ( {isOpen && (
<div <div
className={`absolute z-10 w-48 -translate-y-28 translate-x-4 rounded-xl bg-white shadow-lg dark:bg-[#444654]`} className={`dark:bg-outer-space absolute z-10 w-48 translate-x-4 -translate-y-28 rounded-xl bg-white shadow-lg`}
> >
<a <a
href="https://docs.docsgpt.cloud/" href="https://docs.docsgpt.cloud/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-start gap-4 rounded-t-xl px-4 py-2 text-black hover:bg-bright-gray dark:text-white dark:hover:bg-[#545561]" className="hover:bg-bright-gray flex items-start gap-4 rounded-t-xl px-4 py-2 text-black dark:text-white dark:hover:bg-[#545561]"
> >
<img <img
src={PageIcon} src={PageIcon}
@@ -61,7 +61,7 @@ const Help = () => {
</a> </a>
<a <a
href="mailto:support@docsgpt.cloud" href="mailto:support@docsgpt.cloud"
className="flex items-start gap-4 rounded-b-xl px-4 py-2 text-black hover:bg-bright-gray dark:text-white dark:hover:bg-[#545561]" className="hover:bg-bright-gray flex items-start gap-4 rounded-b-xl px-4 py-2 text-black dark:text-white dark:hover:bg-[#545561]"
> >
<img <img
src={EmailIcon} src={EmailIcon}

View File

@@ -42,7 +42,7 @@ const Input = ({
<div className={`relative ${className}`}> <div className={`relative ${className}`}>
<input <input
ref={inputRef} ref={inputRef}
className={`peer h-[42px] w-full rounded-full bg-transparent px-3 py-1 text-jet placeholder-transparent outline-none dark:text-bright-gray ${colorStyles[colorVariant]} ${borderStyles[borderVariant]} ${textSizeStyles[textSize]} [&:-webkit-autofill]:appearance-none [&:-webkit-autofill]:bg-transparent [&:-webkit-autofill_selected]:bg-transparent`} className={`peer text-jet dark:text-bright-gray h-[42px] w-full rounded-full bg-transparent 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} type={type}
id={id} id={id}
name={name} name={name}
@@ -62,9 +62,9 @@ const Input = ({
htmlFor={id} htmlFor={id}
className={`absolute select-none ${ className={`absolute select-none ${
hasValue ? '-top-2.5 left-3 text-xs' : '' hasValue ? '-top-2.5 left-3 text-xs' : ''
} px-2 transition-all peer-placeholder-shown:left-3 peer-placeholder-shown:top-2.5 peer-placeholder-shown:${ } px-2 transition-all peer-placeholder-shown:top-2.5 peer-placeholder-shown:left-3 peer-placeholder-shown:${
textSizeStyles[textSize] textSizeStyles[textSize]
} pointer-events-none cursor-none text-gray-4000 peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs dark:text-gray-400 ${labelBgClassName} max-w-[calc(100%-24px)] overflow-hidden text-ellipsis whitespace-nowrap`} } text-gray-4000 pointer-events-none cursor-none peer-focus:-top-2.5 peer-focus:left-3 peer-focus:text-xs dark:text-gray-400 ${labelBgClassName} max-w-[calc(100%-24px)] overflow-hidden text-ellipsis whitespace-nowrap`}
> >
{placeholder} {placeholder}
{required && ( {required && (

View File

@@ -258,9 +258,9 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
const errorRender = !isCurrentlyLoading && error; const errorRender = !isCurrentlyLoading && error;
return ( return (
<div className="w-inherit group relative rounded-lg border border-light-silver bg-white dark:border-raisin-black dark:bg-eerie-black"> <div className="w-inherit group border-light-silver dark:border-raisin-black dark:bg-eerie-black relative rounded-lg border bg-white">
<div className="flex items-center justify-between bg-platinum px-2 py-1 dark:bg-eerie-black-2"> <div className="bg-platinum dark:bg-eerie-black-2 flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-just-black dark:text-chinese-white"> <span className="text-just-black dark:text-chinese-white text-xs font-medium">
mermaid mermaid
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -270,13 +270,13 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<div className="relative" ref={downloadMenuRef}> <div className="relative" ref={downloadMenuRef}>
<button <button
onClick={() => setShowDownloadMenu(!showDownloadMenu)} onClick={() => setShowDownloadMenu(!showDownloadMenu)}
className="flex h-full items-center rounded bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700" className="flex h-full items-center rounded-sm bg-gray-100 px-2 py-1 text-xs dark:bg-gray-700"
title="Download options" title="Download options"
> >
Download <span className="ml-1"></span> Download <span className="ml-1"></span>
</button> </button>
{showDownloadMenu && ( {showDownloadMenu && (
<div className="absolute right-0 z-10 mt-1 w-40 rounded border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"> <div className="absolute right-0 z-10 mt-1 w-40 rounded-sm border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
<ul> <ul>
{downloadOptions.map((option, index) => ( {downloadOptions.map((option, index) => (
<li key={index}> <li key={index}>
@@ -314,14 +314,14 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
</div> </div>
{isCurrentlyLoading ? ( {isCurrentlyLoading ? (
<div className="flex items-center justify-center bg-white p-4 dark:bg-eerie-black"> <div className="dark:bg-eerie-black flex items-center justify-center bg-white p-4">
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
Loading diagram... Loading diagram...
</div> </div>
</div> </div>
) : errorRender ? ( ) : errorRender ? (
<div className="m-2 rounded border-2 border-red-400 dark:border-red-700"> <div className="m-2 rounded-sm border-2 border-red-400 dark:border-red-700">
<div className="overflow-auto whitespace-normal break-words bg-red-100 px-4 py-2 text-sm text-red-800 dark:bg-red-900/30 dark:text-red-300"> <div className="overflow-auto bg-red-100 px-4 py-2 text-sm break-words whitespace-normal text-red-800 dark:bg-red-900/30 dark:text-red-300">
{error} {error}
</div> </div>
</div> </div>
@@ -329,7 +329,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
<> <>
<div <div
ref={containerRef} ref={containerRef}
className="no-scrollbar relative block w-full bg-white p-4 dark:bg-eerie-black" className="no-scrollbar dark:bg-eerie-black relative block w-full bg-white p-4"
style={{ style={{
overflow: 'auto', overflow: 'auto',
scrollbarWidth: 'none', scrollbarWidth: 'none',
@@ -345,7 +345,7 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
> >
{isHovering && ( {isHovering && (
<> <>
<div className="absolute right-2 top-2 z-10 flex items-center gap-2 rounded bg-black/70 px-2 py-1 text-xs text-white"> <div className="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 px-2 py-1 text-xs text-white">
<button <button
onClick={() => onClick={() =>
setZoomFactor((prev) => Math.max(1, prev - 0.5)) setZoomFactor((prev) => Math.max(1, prev - 0.5))
@@ -395,9 +395,9 @@ const MermaidRenderer: React.FC<MermaidRendererProps> = ({
</div> </div>
{showCode && ( {showCode && (
<div className="border-t border-light-silver dark:border-raisin-black"> <div className="border-light-silver dark:border-raisin-black border-t">
<div className="bg-platinum p-2 dark:bg-eerie-black-2"> <div className="bg-platinum dark:bg-eerie-black-2 p-2">
<span className="text-xs font-medium text-just-black dark:text-chinese-white"> <span className="text-just-black dark:text-chinese-white text-xs font-medium">
Mermaid Code Mermaid Code
</span> </span>
</div> </div>

View File

@@ -9,6 +9,7 @@ import ClipIcon from '../assets/clip.svg';
import ExitIcon from '../assets/exit.svg'; import ExitIcon from '../assets/exit.svg';
import PaperPlane from '../assets/paper_plane.svg'; import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg'; import SourceIcon from '../assets/source.svg';
import DocumentationDark from '../assets/documentation-dark.svg';
import SpinnerDark from '../assets/spinner-dark.svg'; import SpinnerDark from '../assets/spinner-dark.svg';
import Spinner from '../assets/spinner.svg'; import Spinner from '../assets/spinner.svg';
import ToolIcon from '../assets/tool.svg'; import ToolIcon from '../assets/tool.svg';
@@ -17,7 +18,7 @@ import {
removeAttachment, removeAttachment,
selectAttachments, selectAttachments,
updateAttachment, updateAttachment,
} from '../conversation/conversationSlice'; } from '../upload/uploadSlice';
import { useDarkTheme } from '../hooks'; import { useDarkTheme } from '../hooks';
import { ActiveState } from '../models/misc'; import { ActiveState } from '../models/misc';
import { import {
@@ -257,76 +258,86 @@ export default function MessageInput({
}; };
return ( return (
<div className="mx-2 flex w-full flex-col"> <div className="mx-2 flex w-full flex-col">
<div className="relative flex w-full flex-col rounded-[23px] border border-dark-gray bg-lotion dark:border-grey dark:bg-transparent"> <div className="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-4 pb-0 pt-3 sm:gap-2 sm:px-6"> <div className="flex flex-wrap gap-1.5 px-4 pt-3 pb-0 sm:gap-2 sm:px-6">
{attachments.map((attachment, index) => ( {attachments.map((attachment, index) => (
<div <div
key={index} key={index}
className={`group relative flex items-center rounded-[32px] border border-[#AAAAAA] bg-white px-2 py-1 text-[12px] text-[#5D5D5D] dark:border-purple-taupe dark:bg-[#1F2028] dark:text-bright-gray sm:px-3 sm:py-1.5 sm:text-[14px] ${ 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' attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`} }`}
title={attachment.fileName} title={attachment.fileName}
> >
<div className="bg-purple-30 mr-2 items-center justify-center rounded-lg p-[5.5px]">
{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]"> <span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{attachment.fileName} {attachment.fileName}
</span> </span>
{attachment.status === 'completed' && ( <button
<button className="ml-1.5 flex items-center justify-center rounded-full p-1"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white p-1 opacity-0 transition-opacity hover:bg-white/95 focus:opacity-100 group-hover:opacity-100 dark:bg-[#1F2028] dark:hover:bg-[#1F2028]/95" onClick={() => {
onClick={() => { if (attachment.id) {
if (attachment.id) { dispatch(removeAttachment(attachment.id));
dispatch(removeAttachment(attachment.id)); } else if (attachment.taskId) {
} dispatch(removeAttachment(attachment.taskId));
}} }
aria-label={t('conversation.attachments.remove')} }}
> 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>
)}
{attachment.status === 'failed' && (
<img <img
src={AlertIcon} src={ExitIcon}
alt="Upload failed" alt={t('conversation.attachments.remove')}
className="ml-2 h-3.5 w-3.5" className="h-2.5 w-2.5 filter dark:invert"
title="Upload failed"
/> />
)} </button>
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="relative ml-2 h-4 w-4">
<svg className="h-4 w-4" viewBox="0 0 24 24">
{/* Background circle */}
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<circle
className="text-blue-600 dark:text-blue-400"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
transform="rotate(-90 12 12)"
/>
</svg>
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -342,7 +353,7 @@ export default function MessageInput({
onChange={handleChange} onChange={handleChange}
tabIndex={1} tabIndex={1}
placeholder={t('inputPlaceholder')} placeholder={t('inputPlaceholder')}
className="inputbox-style no-scrollbar w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion px-4 py-3 text-base leading-tight opacity-100 focus:outline-none dark:bg-transparent dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 sm:px-6 sm:py-5" className="inputbox-style no-scrollbar bg-lotion dark:text-bright-gray dark:placeholder:text-bright-gray/50 w-full overflow-x-hidden overflow-y-auto rounded-t-[23px] px-4 py-3 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-6 sm:py-5 dark:bg-transparent"
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')} aria-label={t('inputPlaceholder')}
@@ -350,11 +361,11 @@ export default function MessageInput({
</div> </div>
<div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2"> <div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex flex-grow flex-wrap gap-1 sm:gap-2"> <div className="flex grow flex-wrap gap-1 sm:gap-2">
{showSourceButton && ( {showSourceButton && (
<button <button
ref={sourceButtonRef} ref={sourceButtonRef}
className="xs:px-3 xs:py-1.5 flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C] sm:max-w-[150px]" className="xs:px-3 xs:py-1.5 dark:border-purple-taupe flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 sm:max-w-[150px] dark:hover:bg-[#2C2E3C]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)} onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={ title={
selectedDocs selectedDocs
@@ -365,15 +376,15 @@ export default function MessageInput({
<img <img
src={SourceIcon} src={SourceIcon}
alt="Sources" alt="Sources"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4" className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4"
/> />
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]"> <span className="xs:text-[12px] dark:text-bright-gray truncate overflow-hidden text-[10px] font-medium text-[#5D5D5D] sm:text-[14px]">
{selectedDocs {selectedDocs
? selectedDocs.name ? selectedDocs.name
: t('conversation.sources.title')} : t('conversation.sources.title')}
</span> </span>
{!isTouch && ( {!isTouch && (
<span className="ml-1 hidden text-[10px] text-gray-500 dark:text-gray-400 sm:inline-block"> <span className="ml-1 hidden text-[10px] text-gray-500 sm:inline-block dark:text-gray-400">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'} {browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
</span> </span>
)} )}
@@ -383,26 +394,26 @@ export default function MessageInput({
{showToolButton && ( {showToolButton && (
<button <button
ref={toolButtonRef} ref={toolButtonRef}
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]" className="xs:px-3 xs:py-1.5 xs:max-w-[150px] dark:border-purple-taupe flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:hover:bg-[#2C2E3C]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)} onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
> >
<img <img
src={ToolIcon} src={ToolIcon}
alt="Tools" alt="Tools"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4 sm:w-4" className="mr-1 h-3.5 w-3.5 shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
/> />
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]"> <span className="xs:text-[12px] dark:text-bright-gray truncate overflow-hidden text-[10px] font-medium text-[#5D5D5D] sm:text-[14px]">
{t('settings.tools.label')} {t('settings.tools.label')}
</span> </span>
</button> </button>
)} )}
<label className="xs:px-3 xs:py-1.5 flex cursor-pointer items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]"> <label className="xs:px-3 xs:py-1.5 dark:border-purple-taupe flex cursor-pointer items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:hover:bg-[#2C2E3C]">
<img <img
src={ClipIcon} src={ClipIcon}
alt="Attach" alt="Attach"
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
/> />
<span className="xs:text-[12px] text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]"> <span className="xs:text-[12px] dark:text-bright-gray text-[10px] font-medium text-[#5D5D5D] sm:text-[14px]">
{t('conversation.attachments.attach')} {t('conversation.attachments.attach')}
</span> </span>
<input <input
@@ -417,7 +428,7 @@ export default function MessageInput({
<button <button
onClick={loading ? undefined : handleSubmit} onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')} aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`} className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto shrink-0`}
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (

View File

@@ -17,7 +17,7 @@ export type OptionType = {
type MultiSelectPopupProps = { type MultiSelectPopupProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLElement>; anchorRef: React.RefObject<HTMLElement | null>;
options: OptionType[]; options: OptionType[];
selectedIds: Set<string | number>; selectedIds: Set<string | number>;
onSelectionChange: (newSelectedIds: Set<string | number>) => void; onSelectionChange: (newSelectedIds: Set<string | number>) => void;
@@ -79,18 +79,18 @@ export default function MultiSelectPopup({
<img <img
src={icon} src={icon}
alt="" alt=""
className="mr-3 h-5 w-5 flex-shrink-0" className="mr-3 h-5 w-5 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
); );
} }
return ( return (
<span className="mr-3 h-5 w-5 flex-shrink-0" aria-hidden="true"> <span className="mr-3 h-5 w-5 shrink-0" aria-hidden="true">
{icon} {icon}
</span> </span>
); );
} }
return <span className="mr-3 flex-shrink-0">{icon}</span>; return <span className="mr-3 shrink-0">{icon}</span>;
}; };
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -168,7 +168,7 @@ export default function MultiSelectPopup({
return ( return (
<div <div
ref={popupRef} ref={popupRef}
className="fixed z-[9999] flex flex-col rounded-lg border border-light-silver bg-lotion shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] dark:border-dim-gray dark:bg-charleston-green-2" className="border-light-silver bg-lotion dark:border-dim-gray dark:bg-charleston-green-2 fixed z-9999 flex flex-col rounded-lg border shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033]"
style={{ style={{
top: popupPosition.showAbove ? undefined : popupPosition.top, top: popupPosition.showAbove ? undefined : popupPosition.top,
bottom: popupPosition.showAbove bottom: popupPosition.showAbove
@@ -181,7 +181,7 @@ export default function MultiSelectPopup({
}} }}
> >
{(title || showSearch) && ( {(title || showSearch) && (
<div className="flex-shrink-0 p-4"> <div className="shrink-0 p-4">
{title && ( {title && (
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white"> <h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
{title} {title}
@@ -206,13 +206,13 @@ export default function MultiSelectPopup({
)} )}
</div> </div>
)} )}
<div className="mx-4 mb-4 flex-grow overflow-auto rounded-md border border-[#D9D9D9] dark:border-dim-gray"> <div className="dark:border-dim-gray mx-4 mb-4 grow overflow-auto rounded-md border border-[#D9D9D9]">
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center py-4"> <div className="flex h-full items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
</div> </div>
) : ( ) : (
<div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 dark:[&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-track]:bg-gray-200 dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C] [&::-webkit-scrollbar]:w-2"> <div className="h-full overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-400 dark:[&::-webkit-scrollbar-thumb]:bg-gray-600 [&::-webkit-scrollbar-track]:bg-gray-200 dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]">
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4 py-8 text-center"> <div className="flex h-full flex-col items-center justify-center px-4 py-8 text-center">
<img <img
@@ -233,22 +233,22 @@ export default function MultiSelectPopup({
<div <div
key={option.id} key={option.id}
onClick={() => handleOptionClick(option.id)} onClick={() => handleOptionClick(option.id)}
className="flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0 hover:bg-gray-100 dark:border-dim-gray dark:hover:bg-charleston-green-3" className="dark:border-dim-gray dark:hover:bg-charleston-green-3 flex cursor-pointer items-center justify-between border-b border-[#D9D9D9] p-3 last:border-b-0 hover:bg-gray-100"
role="option" role="option"
aria-selected={isSelected} aria-selected={isSelected}
> >
<div className="mr-3 flex flex-grow items-center overflow-hidden"> <div className="mr-3 flex grow items-center overflow-hidden">
{option.icon && renderIcon(option.icon)} {option.icon && renderIcon(option.icon)}
<p <p
className="overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white" className="overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white"
title={option.label} title={option.label}
> >
{option.label} {option.label}
</p> </p>
</div> </div>
<div className="flex-shrink-0"> <div className="shrink-0">
<div <div
className={`flex h-4 w-4 items-center justify-center rounded-sm border border-[#C6C6C6] bg-white dark:border-[#757783] dark:bg-charleston-green-2`} className={`dark:bg-charleston-green-2 flex h-4 w-4 items-center justify-center rounded-xs border border-[#C6C6C6] bg-white dark:border-[#757783]`}
aria-hidden="true" aria-hidden="true"
> >
{isSelected && ( {isSelected && (
@@ -269,7 +269,7 @@ export default function MultiSelectPopup({
)} )}
</div> </div>
{footerContent && ( {footerContent && (
<div className="flex-shrink-0 border-t border-light-silver p-4 dark:border-dim-gray"> <div className="border-light-silver dark:border-dim-gray shrink-0 border-t p-4">
{footerContent} {footerContent}
</div> </div>
)} )}

View File

@@ -50,10 +50,10 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
return ( return (
<div className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0"> <div className="relative mt-6 flex flex-row items-center space-x-1 overflow-auto md:space-x-0">
<div <div
className={`${hiddenGradient === 'left' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 left-6 w-14 bg-gradient-to-r from-white dark:from-raisin-black md:hidden`} className={`${hiddenGradient === 'left' ? 'hidden' : ''} dark:from-raisin-black pointer-events-none absolute inset-y-0 left-6 w-14 bg-linear-to-r from-white md:hidden`}
></div> ></div>
<div <div
className={`${hiddenGradient === 'right' ? 'hidden' : ''} pointer-events-none absolute inset-y-0 right-6 w-14 bg-gradient-to-l from-white dark:from-raisin-black md:hidden`} className={`${hiddenGradient === 'right' ? 'hidden' : ''} dark:from-raisin-black pointer-events-none absolute inset-y-0 right-6 w-14 bg-linear-to-l from-white md:hidden`}
></div> ></div>
<div className="z-10 md:hidden"> <div className="z-10 md:hidden">
@@ -77,7 +77,7 @@ const SettingsBar = ({ setActiveTab, activeTab }: SettingsBarProps) => {
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
className={`h-9 snap-start rounded-3xl px-4 font-bold transition-colors ${ className={`h-9 snap-start rounded-3xl px-4 font-bold transition-colors ${
activeTab === tab activeTab === tab
? 'bg-[#F4F4F5] text-neutral-900 dark:bg-dark-charcoal dark:text-white' ? 'dark:bg-dark-charcoal bg-[#F4F4F5] text-neutral-900 dark:text-white'
: 'text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-white' : 'text-neutral-700 hover:text-neutral-900 dark:text-neutral-300 dark:hover:text-white'
}`} }`}
role="tab" role="tab"

View File

@@ -33,13 +33,13 @@ export default function Sidebar({
return ( return (
<div ref={sidebarRef} className="h-vh relative"> <div ref={sidebarRef} className="h-vh relative">
<div <div
className={`fixed right-0 top-0 z-50 h-full w-72 transform bg-white shadow-xl transition-all duration-300 dark:bg-chinese-black sm:w-96 ${ 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' isOpen ? 'translate-x-[10px]' : 'translate-x-full'
} border-l border-[#9ca3af]/10`} } border-l border-[#9ca3af]/10`}
> >
<div className="flex w-full flex-row items-end justify-end px-4 pt-3"> <div className="flex w-full flex-row items-end justify-end px-4 pt-3">
<button <button
className="w-7 rounded-full p-2 hover:bg-gray-1000 hover:dark:bg-gun-metal" className="hover:bg-gray-1000 dark:hover:bg-gun-metal w-7 rounded-full p-2"
onClick={() => toggleState(!isOpen)} onClick={() => toggleState(!isOpen)}
> >
<img className="filter dark:invert" src={Exit} /> <img className="filter dark:invert" src={Exit} />

View File

@@ -43,16 +43,16 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{[...Array(4)].map((_, idx) => ( {[...Array(4)].map((_, idx) => (
<tr key={idx} className="animate-pulse"> <tr key={idx} className="animate-pulse">
<td className="w-[45%] px-4 py-4"> <td className="w-[45%] px-4 py-4">
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="w-[20%] px-4 py-4"> <td className="w-[20%] px-4 py-4">
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="w-[25%] px-4 py-4"> <td className="w-[25%] px-4 py-4">
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="w-[10%] px-4 py-4"> <td className="w-[10%] px-4 py-4">
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
</tr> </tr>
))} ))}
@@ -64,16 +64,16 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{[...Array(4)].map((_, idx) => ( {[...Array(4)].map((_, idx) => (
<tr key={idx} className="animate-pulse"> <tr key={idx} className="animate-pulse">
<td className="p-2"> <td className="p-2">
<div className="mx-auto h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mx-auto h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="p-2"> <td className="p-2">
<div className="mx-auto h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mx-auto h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="p-2"> <td className="p-2">
<div className="mx-auto h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mx-auto h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
<td className="p-2"> <td className="p-2">
<div className="mx-auto h-4 w-8 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mx-auto h-4 w-8 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</td> </td>
</tr> </tr>
))} ))}
@@ -82,10 +82,10 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
const renderDropdown = () => ( const renderDropdown = () => (
<div className="animate-pulse"> <div className="animate-pulse">
<div className="mb-2 h-4 w-24 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-24 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="flex h-14 w-[360px] items-center justify-between rounded-3xl bg-gray-300 px-4 dark:bg-gray-600"> <div className="flex h-14 w-[360px] items-center justify-between rounded-3xl bg-gray-300 px-4 dark:bg-gray-600">
<div className="h-3 w-24 rounded bg-gray-400 dark:bg-gray-700"></div> <div className="h-3 w-24 rounded-sm bg-gray-400 dark:bg-gray-700"></div>
<div className="h-3 w-3 rounded bg-gray-400 dark:bg-gray-700"></div> <div className="h-3 w-3 rounded-sm bg-gray-400 dark:bg-gray-700"></div>
</div> </div>
</div> </div>
); );
@@ -95,14 +95,14 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{[...Array(8)].map((_, idx) => ( {[...Array(8)].map((_, idx) => (
<div <div
key={idx} key={idx}
className="flex w-full items-start p-2 hover:bg-[#F9F9F9] hover:dark:bg-dark-charcoal" className="dark:hover:bg-dark-charcoal flex w-full items-start p-2 hover:bg-[#F9F9F9]"
> >
<div className="flex w-full items-center gap-2"> <div className="flex w-full items-center gap-2">
<div className="h-3 w-3 rounded-lg bg-gray-300 dark:bg-gray-600"></div> <div className="h-3 w-3 rounded-lg bg-gray-300 dark:bg-gray-600"></div>
<div className="flex w-full flex-row items-center gap-2"> <div className="flex w-full flex-row items-center gap-2">
<div className="h-3 w-[30%] rounded-lg bg-gray-300 dark:bg-gray-600 lg:w-52"></div> <div className="h-3 w-[30%] rounded-lg bg-gray-300 lg:w-52 dark:bg-gray-600"></div>
<div className="h-3 w-[16%] rounded-lg bg-gray-300 dark:bg-gray-600 lg:w-28"></div> <div className="h-3 w-[16%] rounded-lg bg-gray-300 lg:w-28 dark:bg-gray-600"></div>
<div className="h-3 w-[40%] rounded-lg bg-gray-300 dark:bg-gray-600 lg:w-64"></div> <div className="h-3 w-[40%] rounded-lg bg-gray-300 lg:w-64 dark:bg-gray-600"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -117,32 +117,32 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
key={idx} key={idx}
className={`p-6 ${ className={`p-6 ${
skeletonCount === 1 ? 'w-full' : 'w-60' skeletonCount === 1 ? 'w-full' : 'w-60'
} animate-pulse rounded-3xl dark:bg-raisin-black`} } dark:bg-raisin-black animate-pulse rounded-3xl`}
> >
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="mb-2 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-5/6 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-1/2 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-1/2 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div> <div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
<div> <div>
<div className="mb-2 h-4 w-2/3 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-2/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-1/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-1/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div> <div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
<div> <div>
<div className="mb-2 h-4 w-5/6 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-1/3 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-1/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-2/3 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-2/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
<div className="my-4 border-t border-gray-400 dark:border-gray-700"></div> <div className="my-4 border-t border-gray-400 dark:border-gray-700"></div>
<div className="mb-2 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-3/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="mb-2 h-4 w-5/6 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-2 h-4 w-5/6 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
</div> </div>
))} ))}
@@ -154,27 +154,27 @@ const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
{[...Array(skeletonCount)].map((_, idx) => ( {[...Array(skeletonCount)].map((_, idx) => (
<div <div
key={idx} key={idx}
className="w-full animate-pulse rounded-3xl p-6 dark:bg-raisin-black" className="dark:bg-raisin-black w-full animate-pulse rounded-3xl p-6"
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<div className="mb-4 h-4 w-1/3 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-4 h-4 w-1/3 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="grid grid-cols-6 items-end gap-2"> <div className="grid grid-cols-6 items-end gap-2">
<div className="h-32 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-32 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-24 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-24 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-40 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-40 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-28 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-28 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-36 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-36 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-20 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-20 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="mb-4 h-4 w-1/4 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="mb-4 h-4 w-1/4 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-32 rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-32 rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
<div className="h-4 w-full rounded bg-gray-300 dark:bg-gray-600"></div> <div className="h-4 w-full rounded-sm bg-gray-300 dark:bg-gray-600"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -79,13 +79,13 @@ function SourceDropdown({
<div className="relative w-5/6 rounded-3xl" ref={dropdownRef}> <div className="relative w-5/6 rounded-3xl" ref={dropdownRef}>
<button <button
onClick={() => setIsDocsListOpen(!isDocsListOpen)} onClick={() => setIsDocsListOpen(!isDocsListOpen)}
className={`flex w-full cursor-pointer items-center border border-silver bg-white p-[11px] dark:bg-transparent ${ className={`border-silver flex w-full cursor-pointer items-center border bg-white p-[11px] dark:bg-transparent ${
isDocsListOpen isDocsListOpen
? 'rounded-t-3xl dark:border-silver/40' ? 'dark:border-silver/40 rounded-t-3xl'
: 'rounded-3xl dark:border-purple-taupe' : 'dark:border-purple-taupe rounded-3xl'
}`} }`}
> >
<span className="ml-1 mr-2 flex-1 overflow-hidden text-ellipsis text-left dark:text-bright-gray"> <span className="dark:text-bright-gray mr-2 ml-1 flex-1 overflow-hidden text-left text-ellipsis">
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<p className="max-w-3/4 truncate whitespace-nowrap"> <p className="max-w-3/4 truncate whitespace-nowrap">
{selectedDocs?.name || 'None'} {selectedDocs?.name || 'None'}
@@ -101,14 +101,14 @@ function SourceDropdown({
/> />
</button> </button>
{isDocsListOpen && ( {isDocsListOpen && (
<div className="absolute left-0 right-0 z-20 -mt-1 max-h-28 overflow-y-auto rounded-b-xl border border-silver bg-white shadow-lg dark:border-silver/40 dark:bg-dark-charcoal"> <div className="border-silver dark:border-silver/40 dark:bg-dark-charcoal absolute right-0 left-0 z-20 -mt-1 max-h-28 overflow-y-auto rounded-b-xl border bg-white shadow-lg">
{options ? ( {options ? (
options.map((option: any, index: number) => { options.map((option: any, index: number) => {
if (option.model === embeddingsName) { if (option.model === embeddingsName) {
return ( return (
<div <div
key={index} key={index}
className="flex cursor-pointer items-center justify-between hover:bg-gray-100 dark:text-bright-gray dark:hover:bg-[#545561]" className="dark:text-bright-gray flex cursor-pointer items-center justify-between hover:bg-gray-100 dark:hover:bg-[#545561]"
onClick={() => { onClick={() => {
dispatch(setSelectedDocs(option)); dispatch(setSelectedDocs(option));
setIsDocsListOpen(false); setIsDocsListOpen(false);
@@ -119,7 +119,7 @@ function SourceDropdown({
onClick={() => { onClick={() => {
setIsDocsListOpen(false); setIsDocsListOpen(false);
}} }}
className="ml-4 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3" className="ml-4 flex-1 overflow-hidden py-3 text-ellipsis whitespace-nowrap"
> >
{option.name} {option.name}
</span> </span>
@@ -143,11 +143,11 @@ function SourceDropdown({
<></> <></>
)} )}
<div <div
className="flex cursor-pointer items-center justify-between hover:bg-gray-100 dark:text-bright-gray dark:hover:bg-purple-taupe" className="dark:text-bright-gray dark:hover:bg-purple-taupe flex cursor-pointer items-center justify-between hover:bg-gray-100"
onClick={handleEmptyDocumentSelect} onClick={handleEmptyDocumentSelect}
> >
<span <span
className="ml-4 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap py-3" className="ml-4 flex-1 overflow-hidden py-3 text-ellipsis whitespace-nowrap"
onClick={() => { onClick={() => {
handlePostDocumentSelect(null); handlePostDocumentSelect(null);
}} }}

View File

@@ -16,7 +16,7 @@ import { ActiveState } from '../models/misc';
type SourcesPopupProps = { type SourcesPopupProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement>; anchorRef: React.RefObject<HTMLButtonElement | null>;
handlePostDocumentSelect: (doc: Doc | null) => void; handlePostDocumentSelect: (doc: Doc | null) => void;
setUploadModalState: React.Dispatch<React.SetStateAction<ActiveState>>; setUploadModalState: React.Dispatch<React.SetStateAction<ActiveState>>;
}; };
@@ -110,7 +110,7 @@ export default function SourcesPopup({
return ( return (
<div <div
ref={popupRef} ref={popupRef}
className="fixed z-50 flex flex-col rounded-xl bg-lotion shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] dark:bg-charleston-green-2" 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]"
style={{ style={{
top: popupPosition.showAbove ? popupPosition.top : undefined, top: popupPosition.showAbove ? popupPosition.top : undefined,
bottom: popupPosition.showAbove bottom: popupPosition.showAbove
@@ -124,8 +124,8 @@ export default function SourcesPopup({
}} }}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex-shrink-0 px-4 py-4 md:px-6"> <div className="shrink-0 px-4 py-4 md:px-6">
<h2 className="mb-4 text-lg font-bold text-[#141414] dark:text-[20px] dark:text-bright-gray"> <h2 className="dark:text-bright-gray mb-4 text-lg font-bold text-[#141414] dark:text-[20px]">
{t('conversation.sources.text')} {t('conversation.sources.text')}
</h2> </h2>
@@ -142,7 +142,7 @@ export default function SourcesPopup({
/> />
</div> </div>
<div className="mx-4 flex-grow overflow-y-auto rounded-md border border-[#D9D9D9] dark:border-dim-gray [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]"> <div className="dark:border-dim-gray mx-4 grow overflow-y-auto rounded-md border border-[#D9D9D9] [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]">
{options ? ( {options ? (
<> <>
{filteredOptions?.map((option: any, index: number) => { {filteredOptions?.map((option: any, index: number) => {
@@ -156,7 +156,7 @@ export default function SourcesPopup({
return ( return (
<div <div
key={index} key={index}
className="flex cursor-pointer items-center border-b border-[#D9D9D9] border-opacity-80 p-3 transition-colors hover:bg-gray-100 dark:border-dim-gray dark:text-[14px] dark:hover:bg-[#2C2E3C]" className="border-opacity-80 dark:border-dim-gray flex cursor-pointer items-center border-b border-[#D9D9D9] p-3 transition-colors hover:bg-gray-100 dark:text-[14px] dark:hover:bg-[#2C2E3C]"
onClick={() => { onClick={() => {
if (isSelected) { if (isSelected) {
dispatch(setSelectedDocs(null)); dispatch(setSelectedDocs(null));
@@ -172,13 +172,13 @@ export default function SourcesPopup({
alt="Source" alt="Source"
width={14} width={14}
height={14} height={14}
className="mr-3 flex-shrink-0" className="mr-3 shrink-0"
/> />
<span className="mr-3 flex-grow overflow-hidden overflow-ellipsis whitespace-nowrap font-medium text-[#5D5D5D] dark:text-bright-gray"> <span className="dark:text-bright-gray mr-3 grow overflow-hidden font-medium text-ellipsis whitespace-nowrap text-[#5D5D5D]">
{option.name} {option.name}
</span> </span>
<div <div
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`} className={`flex h-4 w-4 shrink-0 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
> >
{isSelected && ( {isSelected && (
<img <img
@@ -195,16 +195,16 @@ export default function SourcesPopup({
})} })}
</> </>
) : ( ) : (
<div className="p-4 text-center text-gray-500 dark:text-[14px] dark:text-bright-gray"> <div className="dark:text-bright-gray p-4 text-center text-gray-500 dark:text-[14px]">
{t('noSourcesAvailable')} {t('noSourcesAvailable')}
</div> </div>
)} )}
</div> </div>
<div className="flex-shrink-0 px-4 py-4 opacity-75 transition-opacity duration-200 hover:opacity-100 md:px-6"> <div className="shrink-0 px-4 py-4 opacity-75 transition-opacity duration-200 hover:opacity-100 md:px-6">
<a <a
href="/settings/documents" href="/settings/documents"
className="inline-flex items-center gap-2 text-base font-medium text-violets-are-blue" className="text-violets-are-blue inline-flex items-center gap-2 text-base font-medium"
onClick={onClose} onClick={onClose}
> >
{t('settings.documents.goToDocuments')} {t('settings.documents.goToDocuments')}
@@ -212,10 +212,10 @@ export default function SourcesPopup({
</a> </a>
</div> </div>
<div className="flex flex-shrink-0 justify-start px-4 py-3 md:px-6"> <div className="flex shrink-0 justify-start px-4 py-3 md:px-6">
<button <button
onClick={handleUploadClick} onClick={handleUploadClick}
className="w-auto rounded-full border border-violets-are-blue px-4 py-2 text-[14px] font-medium text-violets-are-blue transition-colors duration-200 hover:bg-violets-are-blue hover:text-white" className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue w-auto rounded-full border px-4 py-2 text-[14px] font-medium transition-colors duration-200 hover:text-white"
> >
{t('settings.documents.uploadNew')} {t('settings.documents.uploadNew')}
</button> </button>

View File

@@ -14,7 +14,7 @@ import { useDarkTheme } from '../hooks';
interface ToolsPopupProps { interface ToolsPopupProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement>; anchorRef: React.RefObject<HTMLButtonElement | null>;
} }
export default function ToolsPopup({ export default function ToolsPopup({
@@ -136,7 +136,7 @@ export default function ToolsPopup({
return ( return (
<div <div
ref={popupRef} ref={popupRef}
className="fixed z-[9999] rounded-lg border border-light-silver bg-lotion shadow-[0px_9px_46px_8px_#0000001F,0px_24px_38px_3px_#00000024,0px_11px_15px_-7px_#00000033] dark:border-dim-gray dark:bg-charleston-green-2" 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={{ style={{
top: popupPosition.showAbove ? popupPosition.top : undefined, top: popupPosition.showAbove ? popupPosition.top : undefined,
bottom: popupPosition.showAbove bottom: popupPosition.showAbove
@@ -150,7 +150,7 @@ export default function ToolsPopup({
}} }}
> >
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex-shrink-0 p-4"> <div className="shrink-0 p-4">
<h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white"> <h3 className="mb-4 text-lg font-medium text-gray-900 dark:text-white">
{t('settings.tools.label')} {t('settings.tools.label')}
</h3> </h3>
@@ -169,11 +169,11 @@ export default function ToolsPopup({
</div> </div>
{loading ? ( {loading ? (
<div className="flex flex-grow justify-center py-4"> <div className="flex grow justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div> <div className="h-6 w-6 animate-spin rounded-full border-b-2 border-gray-900 dark:border-white"></div>
</div> </div>
) : ( ) : (
<div className="mx-4 flex-grow overflow-hidden rounded-md border border-[#D9D9D9] dark:border-dim-gray"> <div className="dark:border-dim-gray mx-4 grow overflow-hidden rounded-md border border-[#D9D9D9]">
<div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]"> <div className="h-full overflow-y-auto [&::-webkit-scrollbar-thumb]:bg-[#888] [&::-webkit-scrollbar-thumb]:hover:bg-[#555] [&::-webkit-scrollbar-track]:bg-[#E2E8F0] dark:[&::-webkit-scrollbar-track]:bg-[#2C2E3C]">
{filteredTools.length === 0 ? ( {filteredTools.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center py-8"> <div className="flex h-full flex-col items-center justify-center py-8">
@@ -191,21 +191,21 @@ export default function ToolsPopup({
<div <div
key={tool.id} key={tool.id}
onClick={() => updateToolStatus(tool.id, !tool.status)} onClick={() => updateToolStatus(tool.id, !tool.status)}
className="flex items-center justify-between border-b border-[#D9D9D9] p-3 hover:bg-gray-100 dark:border-dim-gray dark:hover:bg-charleston-green-3" className="dark:border-dim-gray dark:hover:bg-charleston-green-3 flex items-center justify-between border-b border-[#D9D9D9] p-3 hover:bg-gray-100"
> >
<div className="mr-3 flex flex-grow items-center"> <div className="mr-3 flex grow items-center">
<img <img
src={`/toolIcons/tool_${tool.name}.svg`} src={`/toolIcons/tool_${tool.name}.svg`}
alt={`${tool.displayName} icon`} alt={`${tool.displayName} icon`}
className="mr-4 h-5 w-5 flex-shrink-0" className="mr-4 h-5 w-5 shrink-0"
/> />
<div className="overflow-hidden"> <div className="overflow-hidden">
<p className="overflow-hidden overflow-ellipsis whitespace-nowrap text-xs font-medium text-gray-900 dark:text-white"> <p className="overflow-hidden text-xs font-medium text-ellipsis whitespace-nowrap text-gray-900 dark:text-white">
{tool.customName || tool.displayName} {tool.customName || tool.displayName}
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-shrink-0 items-center"> <div className="flex shrink-0 items-center">
<div <div
className={`flex h-4 w-4 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`} className={`flex h-4 w-4 items-center justify-center border border-[#C6C6C6] p-[0.5px] dark:border-[#757783]`}
> >
@@ -226,10 +226,10 @@ export default function ToolsPopup({
</div> </div>
)} )}
<div className="flex-shrink-0 p-4 opacity-75 transition-opacity duration-200 hover:opacity-100"> <div className="shrink-0 p-4 opacity-75 transition-opacity duration-200 hover:opacity-100">
<a <a
href="/settings/tools" href="/settings/tools"
className="inline-flex items-center text-base font-medium text-purple-30" className="text-purple-30 inline-flex items-center text-base font-medium"
> >
{t('settings.tools.manageTools')} {t('settings.tools.manageTools')}
<img <img

View File

@@ -28,6 +28,10 @@ import {
updateConversationId, updateConversationId,
updateQuery, updateQuery,
} from './conversationSlice'; } from './conversationSlice';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
export default function Conversation() { export default function Conversation() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,6 +43,7 @@ export default function Conversation() {
const status = useSelector(selectStatus); const status = useSelector(selectStatus);
const conversationId = useSelector(selectConversationId); const conversationId = useSelector(selectConversationId);
const selectedAgent = useSelector(selectSelectedAgent); const selectedAgent = useSelector(selectSelectedAgent);
const completedAttachments = useSelector(selectCompletedAttachments);
const [uploadModalState, setUploadModalState] = const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE'); useState<ActiveState>('INACTIVE');
@@ -107,15 +112,25 @@ export default function Conversation() {
const trimmedQuestion = question.trim(); const trimmedQuestion = question.trim();
if (trimmedQuestion === '') return; if (trimmedQuestion === '') return;
const filesAttached = completedAttachments
.filter((a) => a.id)
.map((a) => ({ id: a.id as string, fileName: a.fileName }));
if (index !== undefined) { if (index !== undefined) {
if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion })); if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion }));
handleFetchAnswer({ question: trimmedQuestion, index }); handleFetchAnswer({ question: trimmedQuestion, index });
} else { } else {
if (!isRetry) dispatch(addQuery({ prompt: trimmedQuestion })); if (!isRetry)
dispatch(
addQuery({
prompt: trimmedQuestion,
attachments: filesAttached,
}),
);
handleFetchAnswer({ question: trimmedQuestion, index }); handleFetchAnswer({ question: trimmedQuestion, index });
} }
}, },
[dispatch, handleFetchAnswer], [dispatch, handleFetchAnswer, completedAttachments],
); );
const handleFeedback = (query: Query, feedback: FEEDBACK, index: number) => { const handleFeedback = (query: Query, feedback: FEEDBACK, index: number) => {
@@ -178,6 +193,7 @@ export default function Conversation() {
query: { conversationId: null }, query: { conversationId: null },
}), }),
); );
dispatch(clearAttachments());
}; };
useEffect(() => { useEffect(() => {
@@ -204,7 +220,7 @@ export default function Conversation() {
} }
/> />
<div className="z-3 flex h-auto w-full max-w-[1300px] flex-col items-end self-center rounded-2xl bg-opacity-0 py-1 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12"> <div 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 <div
{...getRootProps()} {...getRootProps()}
className="flex w-full items-center rounded-[40px]" className="flex w-full items-center rounded-[40px]"
@@ -223,17 +239,17 @@ export default function Conversation() {
/> />
</div> </div>
<p className="hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline md:w-full"> <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')} {t('tagline')}
</p> </p>
</div> </div>
{handleDragActive && ( {handleDragActive && (
<div className="pointer-events-none fixed left-0 top-0 z-30 flex size-full flex-col items-center justify-center bg-white bg-opacity-50 dark:bg-gray-alpha"> <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} /> <img className="filter dark:invert" src={DragFileUpload} />
<span className="px-2 text-2xl font-bold text-outer-space dark:text-silver"> <span className="text-outer-space dark:text-silver px-2 text-2xl font-bold">
{t('modals.uploadDoc.drag.title')} {t('modals.uploadDoc.drag.title')}
</span> </span>
<span className="text-s w-48 p-2 text-center text-outer-space dark:text-silver"> <span className="text-s text-outer-space dark:text-silver w-48 p-2 text-center">
{t('modals.uploadDoc.drag.description')} {t('modals.uploadDoc.drag.description')}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,6 @@
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { forwardRef, Fragment, useRef, useState } from 'react'; import { forwardRef, Fragment, useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
@@ -12,7 +12,7 @@ import {
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import DocumentationDark from '../assets/documentation-dark.svg';
import ChevronDown from '../assets/chevron-down.svg'; import ChevronDown from '../assets/chevron-down.svg';
import Cloud from '../assets/cloud.svg'; import Cloud from '../assets/cloud.svg';
import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import DocsGPT3 from '../assets/cute_docsgpt3.svg';
@@ -26,7 +26,9 @@ import UserIcon from '../assets/user.svg';
import Accordion from '../components/Accordion'; import Accordion from '../components/Accordion';
import Avatar from '../components/Avatar'; import Avatar from '../components/Avatar';
import CopyButton from '../components/CopyButton'; import CopyButton from '../components/CopyButton';
import MermaidRenderer from '../components/MermaidRenderer';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import Spinner from '../components/Spinner';
import SpeakButton from '../components/TextToSpeechButton'; import SpeakButton from '../components/TextToSpeechButton';
import { useDarkTheme, useOutsideAlerter } from '../hooks'; import { useDarkTheme, useOutsideAlerter } from '../hooks';
import { import {
@@ -36,7 +38,6 @@ import {
import classes from './ConversationBubble.module.css'; import classes from './ConversationBubble.module.css';
import { FEEDBACK, MESSAGE_TYPE } from './conversationModels'; import { FEEDBACK, MESSAGE_TYPE } from './conversationModels';
import { ToolCallsType } from './types'; import { ToolCallsType } from './types';
import MermaidRenderer from '../components/MermaidRenderer';
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false; const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
@@ -59,6 +60,7 @@ const ConversationBubble = forwardRef<
updated?: boolean, updated?: boolean,
index?: number, index?: number,
) => void; ) => void;
filesAttached?: { id: string; fileName: string }[];
} }
>(function ConversationBubble( >(function ConversationBubble(
{ {
@@ -74,6 +76,7 @@ const ConversationBubble = forwardRef<
questionNumber, questionNumber,
isStreaming, isStreaming,
handleUpdatedQuestionSubmission, handleUpdatedQuestionSubmission,
filesAttached,
}, },
ref, ref,
) { ) {
@@ -87,14 +90,24 @@ const ConversationBubble = forwardRef<
const [isDislikeHovered, setIsDislikeHovered] = useState(false); const [isDislikeHovered, setIsDislikeHovered] = useState(false);
const [isQuestionHovered, setIsQuestionHovered] = useState(false); const [isQuestionHovered, setIsQuestionHovered] = useState(false);
const [editInputBox, setEditInputBox] = useState<string>(''); const [editInputBox, setEditInputBox] = useState<string>('');
const messageRef = useRef<HTMLDivElement>(null);
const [shouldShowToggle, setShouldShowToggle] = useState(false);
const [isLikeClicked, setIsLikeClicked] = useState(false); const [isLikeClicked, setIsLikeClicked] = useState(false);
const [isDislikeClicked, setIsDislikeClicked] = useState(false); const [isDislikeClicked, setIsDislikeClicked] = useState(false);
const [activeTooltip, setActiveTooltip] = useState<number | null>(null); const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(false);
const editableQueryRef = useRef<HTMLDivElement | null>(null); const editableQueryRef = useRef<HTMLDivElement>(null);
const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true);
useOutsideAlerter(editableQueryRef, () => setIsEditClicked(false), [], true); useOutsideAlerter(editableQueryRef, () => setIsEditClicked(false), [], true);
useEffect(() => {
if (messageRef.current) {
const height = messageRef.current.scrollHeight;
setShouldShowToggle(height > 84);
}
}, [message]);
const handleEditClick = () => { const handleEditClick = () => {
setIsEditClicked(false); setIsEditClicked(false);
handleUpdatedQuestionSubmission?.(editInputBox, true, questionNumber); handleUpdatedQuestionSubmission?.(editInputBox, true, questionNumber);
@@ -105,41 +118,83 @@ const ConversationBubble = forwardRef<
<div <div
onMouseEnter={() => setIsQuestionHovered(true)} onMouseEnter={() => setIsQuestionHovered(true)}
onMouseLeave={() => setIsQuestionHovered(false)} onMouseLeave={() => setIsQuestionHovered(false)}
className={className}
> >
<div <div className="flex flex-col items-end">
ref={ref} {filesAttached && filesAttached.length > 0 && (
className={`flex flex-row-reverse justify-items-start ${className}`} <div className="mr-12 mb-4 flex flex-wrap justify-end gap-2">
> {filesAttached.map((file, index) => (
<Avatar
size="SMALL"
className="mt-2 flex-shrink-0 text-2xl"
avatar={
<img className="mr-1 rounded-full" width={30} src={UserIcon} />
}
/>
{!isEditClicked && (
<>
<div className="mr-2 flex flex-col">
<div <div
style={{ key={index}
wordBreak: 'break-word', title={file.fileName}
}} className="dark:text-bright-gray flex items-center rounded-xl bg-[#EFF3F4] p-2 text-[14px] text-[#5D5D5D] dark:bg-[#393B3D]"
className="ml-2 mr-2 flex max-w-full items-center whitespace-pre-wrap rounded-[28px] bg-gradient-to-b from-medium-purple to-slate-blue px-[19px] py-[14px] text-sm leading-normal text-white sm:text-base"
> >
{message} <div className="bg-purple-30 mr-2 items-center justify-center rounded-lg p-[5.5px]">
<img
src={DocumentationDark}
alt="Attachment"
className="h-[15px] w-[15px] object-fill"
/>
</div>
<span className="max-w-[150px] truncate font-normal">
{file.fileName}
</span>
</div> </div>
</div> ))}
<button </div>
onClick={() => {
setIsEditClicked(true);
setEditInputBox(message ?? '');
}}
className={`mt-3 flex h-fit flex-shrink-0 cursor-pointer items-center rounded-full p-2 hover:bg-light-silver dark:hover:bg-[#35363B] ${isQuestionHovered || isEditClicked ? 'visible' : 'invisible'}`}
>
<img src={Edit} alt="Edit" className="cursor-pointer" />
</button>
</>
)} )}
<div
ref={ref}
className={`flex flex-row-reverse justify-items-start`}
>
<Avatar
size="SMALL"
className="mt-2 shrink-0 text-2xl"
avatar={
<img className="mr-1 rounded-full" width={30} src={UserIcon} />
}
/>
{!isEditClicked && (
<>
<div className="relative mr-2 flex w-full flex-col">
<div className="from-medium-purple to-slate-blue mr-2 ml-2 flex max-w-full items-start gap-2 rounded-[28px] bg-linear-to-b px-5 py-4 text-sm leading-normal break-words whitespace-pre-wrap text-white sm:text-base">
<div
ref={messageRef}
className={`${isQuestionCollapsed ? 'line-clamp-4' : ''} w-full`}
>
{message}
</div>
{shouldShowToggle && (
<button
onClick={(e) => {
e.stopPropagation();
setIsQuestionCollapsed(!isQuestionCollapsed);
}}
className="ml-1 rounded-full p-2 hover:bg-[#D9D9D933]"
>
<img
src={ChevronDown}
alt="Toggle"
width={24}
height={24}
className={`transform invert transition-transform duration-200 ${isQuestionCollapsed ? '' : 'rotate-180'}`}
/>
</button>
)}
</div>
</div>
<button
onClick={() => {
setIsEditClicked(true);
setEditInputBox(message ?? '');
}}
className={`hover:bg-light-silver mt-3 flex h-fit shrink-0 cursor-pointer items-center rounded-full p-2 dark:hover:bg-[#35363B] ${isQuestionHovered || isEditClicked ? 'visible' : 'invisible'}`}
>
<img src={Edit} alt="Edit" className="cursor-pointer" />
</button>
</>
)}
</div>
{isEditClicked && ( {isEditClicked && (
<div <div
ref={editableQueryRef} ref={editableQueryRef}
@@ -158,17 +213,17 @@ const ConversationBubble = forwardRef<
}} }}
rows={5} rows={5}
value={editInputBox} value={editInputBox}
className="w-full resize-none rounded-3xl border border-silver px-4 py-3 text-base leading-relaxed text-carbon focus:outline-none dark:border-philippine-grey dark:bg-raisin-black dark:text-chinese-white" className="border-silver text-carbon dark:border-philippine-grey dark:bg-raisin-black dark:text-chinese-white w-full resize-none rounded-3xl border px-4 py-3 text-base leading-relaxed focus:outline-hidden"
/> />
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
className="rounded-full px-4 py-2 text-sm font-semibold text-purple-30 transition-colors hover:bg-gainsboro hover:text-chinese-black-2 dark:hover:bg-onyx-2 dark:hover:text-[#B9BCBE]" className="text-purple-30 hover:bg-gainsboro hover:text-chinese-black-2 dark:hover:bg-onyx-2 rounded-full px-4 py-2 text-sm font-semibold transition-colors dark:hover:text-[#B9BCBE]"
onClick={() => setIsEditClicked(false)} onClick={() => setIsEditClicked(false)}
> >
{t('conversation.edit.cancel')} {t('conversation.edit.cancel')}
</button> </button>
<button <button
className="rounded-full bg-purple-30 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violets-are-blue dark:hover:bg-royal-purple" className="bg-purple-30 hover:bg-violets-are-blue dark:hover:bg-royal-purple rounded-full px-4 py-2 text-sm font-medium text-white transition-colors"
onClick={handleEditClick} onClick={handleEditClick}
> >
{t('conversation.edit.update')} {t('conversation.edit.update')}
@@ -228,141 +283,106 @@ const ConversationBubble = forwardRef<
bubble = ( bubble = (
<div <div
ref={ref} ref={ref}
className={`flex flex-wrap self-start ${className} group flex-col dark:text-bright-gray`} className={`flex flex-wrap self-start ${className} group dark:text-bright-gray flex-col`}
> >
{DisableSourceFE || {DisableSourceFE ||
type === 'ERROR' || type === 'ERROR' ||
sources?.length === 0 || sources?.length === 0 ||
sources?.some((source) => source.link === 'None') ? null : !sources && sources?.some((source) => source.link === 'None')
chunks !== '0' && ? null
selectedDocs ? ( : sources && (
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap"> <div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3"> <div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar <Avatar
className="h-[26px] w-[30px] text-xl" className="h-[26px] w-[30px] text-xl"
avatar={ avatar={
<img <img
src={Sources} src={Sources}
alt={t('conversation.sources.title')} alt={t('conversation.sources.title')}
className="h-full w-full object-fill" className="h-full w-full object-fill"
/>
}
/> />
} <p className="text-base font-semibold">
/> {t('conversation.sources.title')}
<p className="text-base font-semibold"> </p>
{t('conversation.sources.title')}
</p>
</div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex h-28 cursor-pointer flex-col items-start gap-1 rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
>
<span className="h-px w-10 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
<span className="h-px w-24 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
<span className="h-px w-16 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
<span className="h-px w-32 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
<span className="h-px w-24 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
<span className="h-px w-20 animate-pulse cursor-pointer rounded-[20px] bg-[#B2B2B2] p-1"></span>
</div> </div>
))} <div className="fade-in mr-5 ml-3 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
</div> <div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
</div> {sources?.slice(0, 3)?.map((source, index) => (
) : ( <div key={index} className="relative">
sources && (
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
className="h-[26px] w-[30px] text-xl"
avatar={
<img
src={Sources}
alt={t('conversation.sources.title')}
className="h-full w-full object-fill"
/>
}
/>
<p className="text-base font-semibold">
{t('conversation.sources.title')}
</p>
</div>
<div className="fade-in ml-3 mr-5 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{sources?.slice(0, 3)?.map((source, index) => (
<div key={index} className="relative">
<div
className="h-28 cursor-pointer rounded-[20px] bg-gray-1000 p-4 hover:bg-[#F1F1F1] dark:bg-gun-metal dark:hover:bg-[#2C2E3C]"
onMouseOver={() => setActiveTooltip(index)}
onMouseOut={() => setActiveTooltip(null)}
>
<p className="ellipsis-text h-12 break-words text-xs">
{source.text}
</p>
<div <div
className={`mt-[14px] flex flex-row items-center gap-[6px] underline-offset-2 ${ className="bg-gray-1000 dark:bg-gun-metal h-28 cursor-pointer rounded-[20px] p-4 hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
source.link && source.link !== 'local'
? 'hover:text-[#007DFF] hover:underline dark:hover:text-[#48A0FF]'
: ''
}`}
onClick={() =>
source.link && source.link !== 'local'
? window.open(
source.link,
'_blank',
'noopener, noreferrer',
)
: null
}
>
<img
src={Document}
alt="Document"
className="h-[17px] w-[17px] object-fill"
/>
<p
className="mt-[2px] truncate text-xs"
title={
source.link && source.link !== 'local'
? source.link
: source.title
}
>
{source.link && source.link !== 'local'
? source.link
: source.title}
</p>
</div>
</div>
{activeTooltip === index && (
<div
className={`absolute left-1/2 z-50 max-h-48 w-40 translate-x-[-50%] translate-y-[3px] rounded-xl bg-[#FBFBFB] p-4 text-black shadow-xl dark:bg-chinese-black dark:text-chinese-silver sm:w-56`}
onMouseOver={() => setActiveTooltip(index)} onMouseOver={() => setActiveTooltip(index)}
onMouseOut={() => setActiveTooltip(null)} onMouseOut={() => setActiveTooltip(null)}
> >
<p className="line-clamp-6 max-h-[164px] overflow-hidden text-ellipsis break-words rounded-md text-sm"> <p className="ellipsis-text h-12 text-xs break-words">
{source.text} {source.text}
</p> </p>
<div
className={`mt-[14px] flex flex-row items-center gap-[6px] underline-offset-2 ${
source.link && source.link !== 'local'
? 'hover:text-[#007DFF] hover:underline dark:hover:text-[#48A0FF]'
: ''
}`}
onClick={() =>
source.link && source.link !== 'local'
? window.open(
source.link,
'_blank',
'noopener, noreferrer',
)
: null
}
>
<img
src={Document}
alt="Document"
className="h-[17px] w-[17px] object-fill"
/>
<p
className="mt-[2px] truncate text-xs"
title={
source.link && source.link !== 'local'
? source.link
: source.title
}
>
{source.link && source.link !== 'local'
? source.link
: source.title}
</p>
</div>
</div> </div>
)} {activeTooltip === index && (
</div> <div
))} className={`dark:bg-chinese-black dark:text-chinese-silver absolute left-1/2 z-50 max-h-48 w-40 translate-x-[-50%] translate-y-[3px] rounded-xl bg-[#FBFBFB] p-4 text-black shadow-xl sm:w-56`}
{(sources?.length ?? 0) > 3 && ( onMouseOver={() => setActiveTooltip(index)}
<div onMouseOut={() => setActiveTooltip(null)}
className="flex h-28 cursor-pointer flex-col-reverse rounded-[20px] bg-gray-1000 p-4 text-purple-30 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:bg-gun-metal dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]" >
onClick={() => setIsSidebarOpen(true)} <p className="line-clamp-6 max-h-[164px] overflow-hidden rounded-md text-sm break-words text-ellipsis">
> {source.text}
<p className="ellipsis-text h-22 text-xs"> </p>
{t('conversation.sources.view_more', { </div>
count: sources?.length ? sources.length - 3 : 0, )}
})} </div>
</p> ))}
</div> {(sources?.length ?? 0) > 3 && (
)} <div
className="bg-gray-1000 text-purple-30 dark:bg-gun-metal flex h-28 cursor-pointer flex-col-reverse rounded-[20px] p-4 hover:bg-[#F1F1F1] hover:text-[#6D3ECC] dark:hover:bg-[#2C2E3C] dark:hover:text-[#8C67D7]"
onClick={() => setIsSidebarOpen(true)}
>
<p className="ellipsis-text h-22 text-xs">
{t('conversation.sources.view_more', {
count: sources?.length ? sources.length - 3 : 0,
})}
</p>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> )}
)
)}
{toolCalls && toolCalls.length > 0 && ( {toolCalls && toolCalls.length > 0 && (
<ToolCalls toolCalls={toolCalls} /> <ToolCalls toolCalls={toolCalls} />
)} )}
@@ -387,9 +407,9 @@ const ConversationBubble = forwardRef<
</p> </p>
</div> </div>
<div <div
className={`fade-in-bubble mr-5 flex max-w-full rounded-[28px] bg-gray-1000 px-7 py-[18px] dark:bg-gun-metal ${ className={`fade-in-bubble bg-gray-1000 dark:bg-gun-metal mr-5 flex max-w-full rounded-[28px] px-7 py-[18px] ${
type === 'ERROR' type === 'ERROR'
? 'relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal text-red-3000 dark:border-red-2000 dark:text-white' ? 'text-red-3000 dark:border-red-2000 relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal dark:text-white'
: 'flex-col rounded-3xl' : 'flex-col rounded-3xl'
}`} }`}
> >
@@ -401,7 +421,7 @@ const ConversationBubble = forwardRef<
<Fragment key={index}> <Fragment key={index}>
{segment.type === 'text' ? ( {segment.type === 'text' ? (
<ReactMarkdown <ReactMarkdown
className="fade-in whitespace-pre-wrap break-words leading-normal" className="fade-in leading-normal break-words whitespace-pre-wrap"
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
components={{ components={{
@@ -419,9 +439,9 @@ const ConversationBubble = forwardRef<
const language = match ? match[1] : ''; const language = match ? match[1] : '';
return match ? ( return match ? (
<div className="group relative overflow-hidden rounded-[14px] border border-light-silver dark:border-raisin-black"> <div className="group border-light-silver dark:border-raisin-black relative overflow-hidden rounded-[14px] border">
<div className="flex items-center justify-between bg-platinum px-2 py-1 dark:bg-eerie-black-2"> <div className="bg-platinum dark:bg-eerie-black-2 flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-just-black dark:text-chinese-white"> <span className="text-just-black dark:text-chinese-white text-xs font-medium">
{language} {language}
</span> </span>
<CopyButton <CopyButton
@@ -438,7 +458,7 @@ const ConversationBubble = forwardRef<
style={ style={
isDarkTheme ? vscDarkPlus : oneLight isDarkTheme ? vscDarkPlus : oneLight
} }
className="!mt-0" className="mt-0!"
customStyle={{ customStyle={{
margin: 0, margin: 0,
borderRadius: 0, borderRadius: 0,
@@ -449,7 +469,7 @@ const ConversationBubble = forwardRef<
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
) : ( ) : (
<code className="whitespace-pre-line rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal dark:bg-independence dark:text-bright-gray"> <code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal whitespace-pre-line">
{children} {children}
</code> </code>
); );
@@ -457,7 +477,7 @@ const ConversationBubble = forwardRef<
ul({ children }) { ul({ children }) {
return ( return (
<ul <ul
className={`list-inside list-disc whitespace-normal pl-4 ${classes.list}`} className={`list-inside list-disc pl-4 whitespace-normal ${classes.list}`}
> >
{children} {children}
</ul> </ul>
@@ -466,7 +486,7 @@ const ConversationBubble = forwardRef<
ol({ children }) { ol({ children }) {
return ( return (
<ol <ol
className={`list-inside list-decimal whitespace-normal pl-4 ${classes.list}`} className={`list-inside list-decimal pl-4 whitespace-normal ${classes.list}`}
> >
{children} {children}
</ol> </ol>
@@ -474,8 +494,8 @@ const ConversationBubble = forwardRef<
}, },
table({ children }) { table({ children }) {
return ( return (
<div className="relative overflow-x-auto rounded-lg border border-silver/40 dark:border-silver/40"> <div className="border-silver/40 dark:border-silver/40 relative overflow-x-auto rounded-lg border">
<table className="w-full text-left text-gray-700 dark:text-bright-gray"> <table className="dark:text-bright-gray w-full text-left text-gray-700">
{children} {children}
</table> </table>
</div> </div>
@@ -483,14 +503,14 @@ const ConversationBubble = forwardRef<
}, },
thead({ children }) { thead({ children }) {
return ( return (
<thead className="bg-gray-50 text-xs uppercase text-gray-900 dark:bg-[#26272E]/50 dark:text-bright-gray"> <thead className="dark:text-bright-gray bg-gray-50 text-xs text-gray-900 uppercase dark:bg-[#26272E]/50">
{children} {children}
</thead> </thead>
); );
}, },
tr({ children }) { tr({ children }) {
return ( return (
<tr className="border-b border-gray-200 odd:bg-white even:bg-gray-50 dark:border-silver/40 dark:odd:bg-[#26272E] dark:even:bg-[#26272E]/50"> <tr className="dark:border-silver/40 border-b border-gray-200 odd:bg-white even:bg-gray-50 dark:odd:bg-[#26272E] dark:even:bg-[#26272E]/50">
{children} {children}
</tr> </tr>
); );
@@ -531,14 +551,14 @@ const ConversationBubble = forwardRef<
{message && ( {message && (
<div className="my-2 ml-2 flex justify-start"> <div className="my-2 ml-2 flex justify-start">
<div <div
className={`relative mr-2 block items-center justify-center lg:invisible ${type !== 'ERROR' ? 'group-hover:lg:visible' : 'hidden'}`} className={`relative mr-2 block items-center justify-center lg:invisible ${type !== 'ERROR' ? 'lg:group-hover:visible' : 'hidden'}`}
> >
<div> <div>
<CopyButton textToCopy={message} /> <CopyButton textToCopy={message} />
</div> </div>
</div> </div>
<div <div
className={`relative mr-2 block items-center justify-center lg:invisible ${type !== 'ERROR' ? 'group-hover:lg:visible' : 'hidden'}`} className={`relative mr-2 block items-center justify-center lg:invisible ${type !== 'ERROR' ? 'lg:group-hover:visible' : 'hidden'}`}
> >
<div> <div>
<SpeakButton text={message} /> <SpeakButton text={message} />
@@ -556,21 +576,21 @@ const ConversationBubble = forwardRef<
feedback === 'LIKE' || isLikeClicked feedback === 'LIKE' || isLikeClicked
? 'visible' ? 'visible'
: 'lg:invisible' : 'lg:invisible'
} ${type !== 'ERROR' ? 'group-hover:lg:visible' : ''} ${feedback === 'DISLIKE' && type !== 'ERROR' ? 'hidden' : ''}`} } ${type !== 'ERROR' ? 'lg:group-hover:visible' : ''} ${feedback === 'DISLIKE' && type !== 'ERROR' ? 'hidden' : ''}`}
> >
<div> <div>
<div <div
className={`flex items-center justify-center rounded-full p-2 ${ className={`flex items-center justify-center rounded-full p-2 ${
isLikeHovered isLikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe' ? 'dark:bg-purple-taupe bg-[#EEEEEE]'
: 'bg-[#ffffff] dark:bg-transparent' : 'bg-white-3000 dark:bg-transparent'
}`} }`}
> >
<Like <Like
className={`cursor-pointer ${ className={`cursor-pointer ${
isLikeClicked || feedback === 'LIKE' isLikeClicked || feedback === 'LIKE'
? 'fill-white-3000 stroke-purple-30 dark:fill-transparent' ? 'fill-white-3000 stroke-purple-30 dark:fill-transparent'
: 'fill-none stroke-gray-4000' : 'stroke-gray-4000 fill-none'
}`} }`}
onClick={() => { onClick={() => {
if (feedback === undefined || feedback === null) { if (feedback === undefined || feedback === null) {
@@ -595,21 +615,21 @@ const ConversationBubble = forwardRef<
feedback === 'DISLIKE' || isLikeClicked feedback === 'DISLIKE' || isLikeClicked
? 'visible' ? 'visible'
: 'lg:invisible' : 'lg:invisible'
} ${type !== 'ERROR' ? 'group-hover:lg:visible' : ''} ${feedback === 'LIKE' && type !== 'ERROR' ? 'hidden' : ''}`} } ${type !== 'ERROR' ? 'lg:group-hover:visible' : ''} ${feedback === 'LIKE' && type !== 'ERROR' ? 'hidden' : ''}`}
> >
<div> <div>
<div <div
className={`flex items-center justify-center rounded-full p-2 ${ className={`flex items-center justify-center rounded-full p-2 ${
isDislikeHovered isDislikeHovered
? 'bg-[#EEEEEE] dark:bg-purple-taupe' ? 'dark:bg-purple-taupe bg-[#EEEEEE]'
: 'bg-[#ffffff] dark:bg-transparent' : 'bg-white-3000 dark:bg-transparent'
}`} }`}
> >
<Dislike <Dislike
className={`cursor-pointer ${ className={`cursor-pointer ${
isDislikeClicked || feedback === 'DISLIKE' isDislikeClicked || feedback === 'DISLIKE'
? 'fill-white-3000 stroke-red-2000 dark:fill-transparent' ? 'fill-white-3000 stroke-red-2000 dark:fill-transparent'
: 'fill-none stroke-gray-4000' : 'stroke-gray-4000 fill-none'
}`} }`}
onClick={() => { onClick={() => {
if (feedback === undefined || feedback === null) { if (feedback === undefined || feedback === null) {
@@ -673,7 +693,7 @@ function AllSources(sources: AllSourcesProps) {
return ( return (
<div <div
key={index} key={index}
className={`group/card relative w-full rounded-[20px] bg-gray-1000 p-4 transition-colors hover:bg-[#F1F1F1] dark:bg-[#28292E] dark:hover:bg-[#2C2E3C] ${ className={`group/card bg-gray-1000 relative w-full rounded-[20px] p-4 transition-colors hover:bg-[#F1F1F1] dark:bg-[#28292E] dark:hover:bg-[#2C2E3C] ${
isExternalSource ? 'cursor-pointer' : '' isExternalSource ? 'cursor-pointer' : ''
}`} }`}
onClick={() => onClick={() =>
@@ -682,7 +702,7 @@ function AllSources(sources: AllSourcesProps) {
> >
<p <p
title={source.title} title={source.title}
className={`ellipsis-text break-words text-left text-sm font-semibold ${ className={`ellipsis-text text-left text-sm font-semibold break-words ${
isExternalSource isExternalSource
? 'group-hover/card:text-purple-30 dark:group-hover/card:text-[#8C67D7]' ? 'group-hover/card:text-purple-30 dark:group-hover/card:text-[#8C67D7]'
: '' : ''
@@ -695,13 +715,13 @@ function AllSources(sources: AllSourcesProps) {
alt="External Link" alt="External Link"
className={`ml-1 inline h-3 w-3 object-fill dark:invert ${ className={`ml-1 inline h-3 w-3 object-fill dark:invert ${
isExternalSource isExternalSource
? 'group-hover/card:contrast-[50%] group-hover/card:hue-rotate-[235deg] group-hover/card:invert-[31%] group-hover/card:saturate-[752%] group-hover/card:sepia-[80%] group-hover/card:filter' ? 'group-hover/card:contrast-50 group-hover/card:hue-rotate-235 group-hover/card:invert-31 group-hover/card:saturate-752 group-hover/card:sepia-80 group-hover/card:filter'
: '' : ''
}`} }`}
/> />
)} )}
</p> </p>
<p className="mt-3 line-clamp-4 break-words rounded-md text-left text-xs text-black dark:text-chinese-silver"> <p className="dark:text-chinese-silver mt-3 line-clamp-4 rounded-md text-left text-xs break-words text-black">
{source.text} {source.text}
</p> </p>
</div> </div>
@@ -741,18 +761,18 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
</button> </button>
</div> </div>
{isToolCallsOpen && ( {isToolCallsOpen && (
<div className="fade-in ml-3 mr-5 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]"> <div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
{toolCalls.map((toolCall, index) => ( {toolCalls.map((toolCall, index) => (
<Accordion <Accordion
key={`tool-call-${index}`} key={`tool-call-${index}`}
title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`} title={`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
className="w-full rounded-[20px] bg-gray-1000 hover:bg-[#F1F1F1] dark:bg-gun-metal dark:hover:bg-[#2C2E3C]" className="bg-gray-1000 dark:bg-gun-metal w-full rounded-[20px] hover:bg-[#F1F1F1] dark:hover:bg-[#2C2E3C]"
titleClassName="px-6 py-2 text-sm font-semibold" titleClassName="px-6 py-2 text-sm font-semibold"
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex flex-col rounded-2xl border border-silver dark:border-silver/20"> <div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="flex flex-row items-center justify-between break-words rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold dark:bg-[#191919]"> <p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold break-words">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}> <span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Arguments Arguments
</span>{' '} </span>{' '}
@@ -760,7 +780,7 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
textToCopy={JSON.stringify(toolCall.arguments, null, 2)} textToCopy={JSON.stringify(toolCall.arguments, null, 2)}
/> />
</p> </p>
<p className="dark:tex break-words rounded-b-2xl p-2 font-mono text-sm dark:bg-[#222327]"> <p className="dark:tex dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm break-words">
<span <span
className="leading-[23px] text-black dark:text-gray-400" className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }} style={{ fontFamily: 'IBMPlexMono-Medium' }}
@@ -769,8 +789,8 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
</span> </span>
</p> </p>
</div> </div>
<div className="flex flex-col rounded-2xl border border-silver dark:border-silver/20"> <div className="border-silver dark:border-silver/20 flex flex-col rounded-2xl border">
<p className="flex flex-row items-center justify-between break-words rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold dark:bg-[#191919]"> <p className="dark:bg-eerie-black-2 flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 text-sm font-semibold break-words">
<span style={{ fontFamily: 'IBMPlexMono-Medium' }}> <span style={{ fontFamily: 'IBMPlexMono-Medium' }}>
Response Response
</span>{' '} </span>{' '}
@@ -778,14 +798,21 @@ function ToolCalls({ toolCalls }: { toolCalls: ToolCallsType[] }) {
textToCopy={JSON.stringify(toolCall.result, null, 2)} textToCopy={JSON.stringify(toolCall.result, null, 2)}
/> />
</p> </p>
<p className="dark:tex break-words rounded-b-2xl p-2 font-mono text-sm dark:bg-[#222327]"> {toolCall.status === 'pending' && (
<span <span className="dark:bg-raisin-black flex w-full items-center justify-center rounded-b-2xl p-2">
className="leading-[23px] text-black dark:text-gray-400" <Spinner size="small" />
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{JSON.stringify(toolCall.result, null, 2)}
</span> </span>
</p> )}
{toolCall.status === 'completed' && (
<p className="dark:bg-raisin-black rounded-b-2xl p-2 font-mono text-sm break-words">
<span
className="leading-[23px] text-black dark:text-gray-400"
style={{ fontFamily: 'IBMPlexMono-Medium' }}
>
{JSON.stringify(toolCall.result, null, 2)}
</span>
</p>
)}
</div> </div>
</div> </div>
</Accordion> </Accordion>
@@ -833,10 +860,10 @@ function Thought({
</button> </button>
</div> </div>
{isThoughtOpen && ( {isThoughtOpen && (
<div className="fade-in ml-2 mr-5 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]"> <div className="fade-in mr-5 ml-2 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
<div className="rounded-[28px] bg-gray-1000 px-7 py-[18px] dark:bg-gun-metal"> <div className="bg-gray-1000 dark:bg-gun-metal rounded-[28px] px-7 py-[18px]">
<ReactMarkdown <ReactMarkdown
className="fade-in whitespace-pre-wrap break-words leading-normal" className="fade-in leading-normal break-words whitespace-pre-wrap"
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]} rehypePlugins={[rehypeKatex]}
components={{ components={{
@@ -846,9 +873,9 @@ function Thought({
const language = match ? match[1] : ''; const language = match ? match[1] : '';
return match ? ( return match ? (
<div className="group relative overflow-hidden rounded-[14px] border border-light-silver dark:border-raisin-black"> <div className="group border-light-silver dark:border-raisin-black relative overflow-hidden rounded-[14px] border">
<div className="flex items-center justify-between bg-platinum px-2 py-1 dark:bg-eerie-black-2"> <div className="bg-platinum dark:bg-eerie-black-2 flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-just-black dark:text-chinese-white"> <span className="text-just-black dark:text-chinese-white text-xs font-medium">
{language} {language}
</span> </span>
<CopyButton <CopyButton
@@ -860,7 +887,7 @@ function Thought({
PreTag="div" PreTag="div"
language={language} language={language}
style={isDarkTheme ? vscDarkPlus : oneLight} style={isDarkTheme ? vscDarkPlus : oneLight}
className="!mt-0" className="mt-0!"
customStyle={{ customStyle={{
margin: 0, margin: 0,
borderRadius: 0, borderRadius: 0,
@@ -871,29 +898,29 @@ function Thought({
</SyntaxHighlighter> </SyntaxHighlighter>
</div> </div>
) : ( ) : (
<code className="whitespace-pre-line rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal dark:bg-independence dark:text-bright-gray"> <code className="dark:bg-independence dark:text-bright-gray rounded-[6px] bg-gray-200 px-[8px] py-[4px] text-xs font-normal whitespace-pre-line">
{children} {children}
</code> </code>
); );
}, },
ul({ children }) { ul({ children }) {
return ( return (
<ul className="list-inside list-disc whitespace-normal pl-4"> <ul className="list-inside list-disc pl-4 whitespace-normal">
{children} {children}
</ul> </ul>
); );
}, },
ol({ children }) { ol({ children }) {
return ( return (
<ol className="list-inside list-decimal whitespace-normal pl-4"> <ol className="list-inside list-decimal pl-4 whitespace-normal">
{children} {children}
</ol> </ol>
); );
}, },
table({ children }) { table({ children }) {
return ( return (
<div className="relative overflow-x-auto rounded-lg border border-silver/40 dark:border-silver/40"> <div className="border-silver/40 dark:border-silver/40 relative overflow-x-auto rounded-lg border">
<table className="w-full text-left text-gray-700 dark:text-bright-gray"> <table className="dark:text-bright-gray w-full text-left text-gray-700">
{children} {children}
</table> </table>
</div> </div>
@@ -901,14 +928,14 @@ function Thought({
}, },
thead({ children }) { thead({ children }) {
return ( return (
<thead className="bg-gray-50 text-xs uppercase text-gray-900 dark:bg-[#26272E]/50 dark:text-bright-gray"> <thead className="dark:text-bright-gray bg-gray-50 text-xs text-gray-900 uppercase dark:bg-[#26272E]/50">
{children} {children}
</thead> </thead>
); );
}, },
tr({ children }) { tr({ children }) {
return ( return (
<tr className="border-b border-gray-200 odd:bg-white even:bg-gray-50 dark:border-silver/40 dark:odd:bg-[#26272E] dark:even:bg-[#26272E]/50"> <tr className="dark:border-silver/40 border-b border-gray-200 odd:bg-white even:bg-gray-50 dark:odd:bg-[#26272E] dark:even:bg-[#26272E]/50">
{children} {children}
</tr> </tr>
); );

View File

@@ -131,7 +131,7 @@ export default function ConversationMessages({
? LAST_BUBBLE_MARGIN ? LAST_BUBBLE_MARGIN
: DEFAULT_BUBBLE_MARGIN; : DEFAULT_BUBBLE_MARGIN;
if (query.thought || query.response) { if (query.thought || query.response || query.tool_calls) {
const isCurrentlyStreaming = const isCurrentlyStreaming =
status === 'loading' && index === queries.length - 1; status === 'loading' && index === queries.length - 1;
return ( return (
@@ -223,6 +223,7 @@ export default function ConversationMessages({
handleUpdatedQuestionSubmission={handleQuestionSubmission} handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index} questionNumber={index}
sources={query.sources} sources={query.sources}
filesAttached={query.attachments}
/> />
{renderResponseView(query, index)} {renderResponseView(query, index)}
</Fragment> </Fragment>

View File

@@ -192,7 +192,7 @@ export default function ConversationTile({
conversationId !== conversation.id && conversationId !== conversation.id &&
selectConversation(conversation.id); selectConversation(conversation.id);
}} }}
className={`mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between gap-4 rounded-3xl pl-4 hover:bg-bright-gray dark:hover:bg-dark-charcoal ${ className={`hover:bg-bright-gray dark:hover:bg-dark-charcoal mx-4 my-auto mt-4 flex h-9 cursor-pointer items-center justify-between gap-4 rounded-3xl pl-4 ${
conversationId === conversation.id || isOpen || isHovered || isEdit conversationId === conversation.id || isOpen || isHovered || isEdit
? 'bg-bright-gray dark:bg-dark-charcoal' ? 'bg-bright-gray dark:bg-dark-charcoal'
: '' : ''
@@ -203,19 +203,19 @@ export default function ConversationTile({
<input <input
autoFocus autoFocus
type="text" type="text"
className="h-6 w-full bg-transparent px-1 text-sm font-normal leading-6 focus:outline-[#0075FF]" className="h-6 w-full bg-transparent px-1 text-sm leading-6 font-normal focus:outline-[#0075FF]"
value={conversationName} value={conversationName}
onChange={(e) => setConversationsName(e.target.value)} onChange={(e) => setConversationsName(e.target.value)}
onKeyDown={handleRenameKeyDown} onKeyDown={handleRenameKeyDown}
/> />
) : ( ) : (
<p className="my-auto overflow-hidden overflow-ellipsis whitespace-nowrap text-sm font-normal leading-6 text-eerie-black dark:text-bright-gray"> <p className="text-eerie-black dark:text-bright-gray my-auto overflow-hidden text-sm leading-6 font-normal text-ellipsis whitespace-nowrap">
{conversationName} {conversationName}
</p> </p>
)} )}
</div> </div>
{(conversationId === conversation.id || isHovered || isOpen) && ( {(conversationId === conversation.id || isHovered || isOpen) && (
<div className="flex text-white dark:text-sonic-silver" ref={menuRef}> <div className="dark:text-sonic-silver flex text-white" ref={menuRef}>
{isEdit ? ( {isEdit ? (
<div className="flex gap-1"> <div className="flex gap-1">
<img <img
@@ -234,7 +234,7 @@ export default function ConversationTile({
<img <img
src={Exit} src={Exit}
alt="Exit" alt="Exit"
className={`mr-4 mt-px h-3 w-3 cursor-pointer filter hover:opacity-50 dark:invert`} className={`mt-px mr-4 h-3 w-3 cursor-pointer filter hover:opacity-50 dark:invert`}
id={`img-${conversation.id}`} id={`img-${conversation.id}`}
onClick={(event: SyntheticEvent) => { onClick={(event: SyntheticEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -250,7 +250,7 @@ export default function ConversationTile({
}} }}
className="mr-2 flex w-4 justify-center" className="mr-2 flex w-4 justify-center"
> >
<img src={threeDots} width={8} /> <img src={threeDots} width={8} alt="menu" />
</button> </button>
)} )}
<ContextMenu <ContextMenu

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
@@ -23,6 +22,8 @@ import {
setIdentifier, setIdentifier,
updateQuery, updateQuery,
} from './sharedConversationSlice'; } from './sharedConversationSlice';
import { selectCompletedAttachments } from '../upload/uploadSlice';
import { DocumentHead } from '../components/DocumentHead';
export const SharedConversation = () => { export const SharedConversation = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,6 +35,7 @@ export const SharedConversation = () => {
const date = useSelector(selectDate); const date = useSelector(selectDate);
const apiKey = useSelector(selectClientAPIKey); const apiKey = useSelector(selectClientAPIKey);
const status = useSelector(selectStatus); const status = useSelector(selectStatus);
const completedAttachments = useSelector(selectCompletedAttachments);
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>(); const dispatch = useDispatch<AppDispatch>();
@@ -106,7 +108,19 @@ export const SharedConversation = () => {
}) => { }) => {
question = question.trim(); question = question.trim();
if (question === '') return; if (question === '') return;
!isRetry && dispatch(addQuery({ prompt: question })); //dispatch only new queries
const filesAttached = completedAttachments
.filter((a) => a.id)
.map((a) => ({ id: a.id as string, fileName: a.fileName }));
!isRetry &&
dispatch(
addQuery({
prompt: question,
attachments: filesAttached,
}),
); //dispatch only new queries
dispatch(fetchSharedAnswer({ question })); dispatch(fetchSharedAnswer({ question }));
}; };
useEffect(() => { useEffect(() => {
@@ -115,33 +129,27 @@ export const SharedConversation = () => {
return ( return (
<> <>
<Helmet> <DocumentHead
<title>{`DocsGPT | ${title}`}</title> title={`DocsGPT | ${title}`}
<meta name="description" content="Shared conversations with DocsGPT" /> description="Shared conversations with DocsGPT"
<meta property="og:title" content={title} /> ogTitle={title}
<meta ogDescription="Shared conversations with DocsGPT"
property="og:description" twitterCard="summary_large_image"
content="Shared conversations with DocsGPT" twitterTitle={title}
/> twitterDescription="Shared conversations with DocsGPT"
<meta name="twitter:card" content="summary_large_image" /> />
<meta name="twitter:title" content={title} /> <div className="dark:bg-raisin-black flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden">
<meta <div className="dark:border-b-silver w-full max-w-[1200px] border-b p-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
name="twitter:description" <h1 className="font-semi-bold text-chinese-black dark:text-chinese-silver text-4xl">
content="Shared conversations with DocsGPT"
/>
</Helmet>
<div className="flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden dark:bg-raisin-black">
<div className="w-full max-w-[1200px] border-b p-2 dark:border-b-silver md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<h1 className="font-semi-bold text-4xl text-chinese-black dark:text-chinese-silver">
{title} {title}
</h1> </h1>
<h2 className="font-semi-bold text-base text-chinese-black dark:text-chinese-silver"> <h2 className="font-semi-bold text-chinese-black dark:text-chinese-silver text-base">
{t('sharedConv.subtitle')}{' '} {t('sharedConv.subtitle')}{' '}
<a href="/" className="text-[#007DFF]"> <a href="/" className="text-[#007DFF]">
DocsGPT DocsGPT
</a> </a>
</h2> </h2>
<h2 className="font-semi-bold text-base text-chinese-black dark:text-chinese-silver"> <h2 className="font-semi-bold text-chinese-black dark:text-chinese-silver text-base">
{date} {date}
</h2> </h2>
</div> </div>
@@ -164,13 +172,13 @@ export const SharedConversation = () => {
) : ( ) : (
<button <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="mb-14 w-fit rounded-full bg-purple-30 px-5 py-3 text-white shadow-xl transition-colors duration-200 hover:bg-violets-are-blue sm:mb-0" className="bg-purple-30 hover:bg-violets-are-blue mb-14 w-fit rounded-full px-5 py-3 text-white shadow-xl transition-colors duration-200 sm:mb-0"
> >
{t('sharedConv.button')} {t('sharedConv.button')}
</button> </button>
)} )}
<p className="hidden w-[100vw] self-center bg-transparent py-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline md:w-full"> <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('sharedConv.meta')} {t('sharedConv.meta')}
</p> </p>
</div> </div>

View File

@@ -279,11 +279,12 @@ export function handleSendFeedback(
}); });
} }
export function handleFetchSharedAnswerStreaming( //for shared conversations export function handleFetchSharedAnswerStreaming(
question: string, question: string,
signal: AbortSignal, signal: AbortSignal,
apiKey: string, apiKey: string,
history: Array<any> = [], history: Array<any> = [],
attachments: string[] = [],
onEvent: (event: MessageEvent) => void, onEvent: (event: MessageEvent) => void,
): Promise<Answer> { ): Promise<Answer> {
history = history.map((item) => { history = history.map((item) => {
@@ -300,6 +301,7 @@ export function handleFetchSharedAnswerStreaming( //for shared conversations
history: JSON.stringify(history), history: JSON.stringify(history),
api_key: apiKey, api_key: apiKey,
save_conversation: false, save_conversation: false,
attachments: attachments.length > 0 ? attachments : undefined,
}; };
conversationService conversationService
.answerStream(payload, null, signal) .answerStream(payload, null, signal)
@@ -355,6 +357,7 @@ export function handleFetchSharedAnswer(
question: string, question: string,
signal: AbortSignal, signal: AbortSignal,
apiKey: string, apiKey: string,
attachments?: string[],
): Promise< ): Promise<
| { | {
result: any; result: any;
@@ -370,15 +373,15 @@ export function handleFetchSharedAnswer(
title: any; title: any;
} }
> { > {
const payload = {
question: question,
api_key: apiKey,
attachments:
attachments && attachments.length > 0 ? attachments : undefined,
};
return conversationService return conversationService
.answer( .answer(payload, null, signal)
{
question: question,
api_key: apiKey,
},
null,
signal,
)
.then((response) => { .then((response) => {
if (response.ok) { if (response.ok) {
return response.json(); return response.json();

View File

@@ -22,7 +22,6 @@ export interface ConversationState {
queries: Query[]; queries: Query[];
status: Status; status: Status;
conversationId: string | null; conversationId: string | null;
attachments: Attachment[];
} }
export interface Answer { export interface Answer {
@@ -46,7 +45,7 @@ export interface Query {
sources?: { title: string; text: string; link: string }[]; sources?: { title: string; text: string; link: string }[];
tool_calls?: ToolCallsType[]; tool_calls?: ToolCallsType[];
error?: string; error?: string;
attachments?: { fileName: string; id: string }[]; attachments?: { id: string; fileName: string }[];
} }
export interface RetrievalPayload { export interface RetrievalPayload {

View File

@@ -3,23 +3,21 @@ import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { getConversations } from '../preferences/preferenceApi'; import { getConversations } from '../preferences/preferenceApi';
import { setConversations } from '../preferences/preferenceSlice'; import { setConversations } from '../preferences/preferenceSlice';
import store from '../store'; import store from '../store';
import {
clearAttachments,
selectCompletedAttachments,
} from '../upload/uploadSlice';
import { import {
handleFetchAnswer, handleFetchAnswer,
handleFetchAnswerSteaming, handleFetchAnswerSteaming,
} from './conversationHandlers'; } from './conversationHandlers';
import { import { Answer, ConversationState, Query, Status } from './conversationModels';
Answer, import { ToolCallsType } from './types';
Query,
Status,
ConversationState,
Attachment,
} from './conversationModels';
const initialState: ConversationState = { const initialState: ConversationState = {
queries: [], queries: [],
status: 'idle', status: 'idle',
conversationId: null, conversationId: null,
attachments: [],
}; };
const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true';
@@ -34,60 +32,64 @@ export function handleAbort() {
export const fetchAnswer = createAsyncThunk< export const fetchAnswer = createAsyncThunk<
Answer, Answer,
{ question: string; indx?: number; isPreview?: boolean } { question: string; indx?: number }
>( >('fetchAnswer', async ({ question, indx }, { dispatch, getState }) => {
'fetchAnswer', if (abortController) abortController.abort();
async ({ question, indx, isPreview = false }, { dispatch, getState }) => { abortController = new AbortController();
if (abortController) abortController.abort(); const { signal } = abortController;
abortController = new AbortController();
const { signal } = abortController;
let isSourceUpdated = false; let isSourceUpdated = false;
const state = getState() as RootState; const state = getState() as RootState;
const attachmentIds = state.conversation.attachments const attachmentIds = selectCompletedAttachments(state)
.filter((a) => a.id && a.status === 'completed') .filter((a) => a.id)
.map((a) => a.id) as string[]; .map((a) => a.id) as string[];
const currentConversationId = state.conversation.conversationId;
const conversationIdToSend = isPreview ? null : currentConversationId;
const save_conversation = isPreview ? false : true;
if (state.preference) { if (attachmentIds.length > 0) {
if (API_STREAMING) { dispatch(clearAttachments());
await handleFetchAnswerSteaming( }
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
conversationIdToSend,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.conversation.queries.length - 1;
const currentConversationId = state.conversation.conversationId;
if (state.preference) {
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
currentConversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
(event) => {
const data = JSON.parse(event.data);
const targetIndex = indx ?? state.conversation.queries.length - 1;
// Only process events if they match the current conversation
if (currentConversationId === state.conversation.conversationId) {
if (data.type === 'end') { if (data.type === 'end') {
dispatch(conversationSlice.actions.setStatus('idle')); dispatch(conversationSlice.actions.setStatus('idle'));
if (!isPreview) { getConversations(state.preference.token)
getConversations(state.preference.token) .then((fetchedConversations) => {
.then((fetchedConversations) => { dispatch(setConversations(fetchedConversations));
dispatch(setConversations(fetchedConversations)); })
}) .catch((error) => {
.catch((error) => { console.error('Failed to fetch conversations: ', error);
console.error('Failed to fetch conversations: ', error); });
});
}
if (!isSourceUpdated) { if (!isSourceUpdated) {
dispatch( dispatch(
updateStreamingSource({ updateStreamingSource({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { sources: [] }, query: { sources: [] },
}), }),
); );
} }
} else if (data.type === 'id') { } else if (data.type === 'id') {
if (!isPreview) { // Only update the conversationId if it's currently null
const currentState = getState() as RootState;
if (currentState.conversation.conversationId === null) {
dispatch( dispatch(
updateConversationId({ updateConversationId({
query: { conversationId: data.id }, query: { conversationId: data.id },
@@ -98,6 +100,7 @@ export const fetchAnswer = createAsyncThunk<
const result = data.thought; const result = data.thought;
dispatch( dispatch(
updateThought({ updateThought({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { thought: result }, query: { thought: result },
}), }),
@@ -106,15 +109,16 @@ export const fetchAnswer = createAsyncThunk<
isSourceUpdated = true; isSourceUpdated = true;
dispatch( dispatch(
updateStreamingSource({ updateStreamingSource({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { sources: data.source ?? [] }, query: { sources: data.source ?? [] },
}), }),
); );
} else if (data.type === 'tool_calls') { } else if (data.type === 'tool_call') {
dispatch( dispatch(
updateToolCalls({ updateToolCall({
index: targetIndex, index: targetIndex,
query: { tool_calls: data.tool_calls }, tool_call: data.data as ToolCallsType,
}), }),
); );
} else if (data.type === 'error') { } else if (data.type === 'error') {
@@ -122,6 +126,7 @@ export const fetchAnswer = createAsyncThunk<
dispatch(conversationSlice.actions.setStatus('failed')); dispatch(conversationSlice.actions.setStatus('failed'));
dispatch( dispatch(
conversationSlice.actions.raiseError({ conversationSlice.actions.raiseError({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
message: data.error, message: data.error,
}), }),
@@ -129,88 +134,87 @@ export const fetchAnswer = createAsyncThunk<
} else { } else {
dispatch( dispatch(
updateStreamingQuery({ updateStreamingQuery({
conversationId: currentConversationId,
index: targetIndex, index: targetIndex,
query: { response: data.answer }, query: { response: data.answer },
}), }),
); );
} }
},
indx,
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
} else {
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
save_conversation,
);
if (answer) {
let sourcesPrepped = [];
sourcesPrepped = answer.sources.map((source: { title: string }) => {
if (source && source.title) {
const titleParts = source.title.split('/');
return {
...source,
title: titleParts[titleParts.length - 1],
};
}
return source;
});
const targetIndex = indx ?? state.conversation.queries.length - 1;
dispatch(
updateQuery({
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
if (!isPreview) {
dispatch(
updateConversationId({
query: { conversationId: answer.conversationId },
}),
);
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
})
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
} }
dispatch(conversationSlice.actions.setStatus('idle')); },
} indx,
state.preference.selectedAgent?.id,
attachmentIds,
true, // Always save conversation
);
} else {
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.conversation.queries,
state.conversation.conversationId,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
true, // Always save conversation
);
if (answer) {
let sourcesPrepped = [];
sourcesPrepped = answer.sources.map((source: { title: string }) => {
if (source && source.title) {
const titleParts = source.title.split('/');
return {
...source,
title: titleParts[titleParts.length - 1],
};
}
return source;
});
const targetIndex = indx ?? state.conversation.queries.length - 1;
dispatch(
updateQuery({
index: targetIndex,
query: {
response: answer.answer,
thought: answer.thought,
sources: sourcesPrepped,
tool_calls: answer.toolCalls,
},
}),
);
dispatch(
updateConversationId({
query: { conversationId: answer.conversationId },
}),
);
getConversations(state.preference.token)
.then((fetchedConversations) => {
dispatch(setConversations(fetchedConversations));
})
.catch((error) => {
console.error('Failed to fetch conversations: ', error);
});
dispatch(conversationSlice.actions.setStatus('idle'));
} }
} }
return { }
conversationId: null, return {
title: null, conversationId: null,
answer: '', title: null,
query: question, answer: '',
result: '', query: question,
thought: '', result: '',
sources: [], thought: '',
tool_calls: [], sources: [],
}; tool_calls: [],
}, };
); });
export const conversationSlice = createSlice({ export const conversationSlice = createSlice({
name: 'conversation', name: 'conversation',
@@ -233,18 +237,20 @@ export const conversationSlice = createSlice({
}, },
updateStreamingQuery( updateStreamingQuery(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
if (state.status === 'idle') return; const { conversationId, index, query } = action.payload;
const { index, query } = action.payload; // Only update if this update is for the current conversation
if (state.status === 'idle' || state.conversationId !== conversationId)
return;
if (query.response != undefined) { if (query.response != undefined) {
state.queries[index].response = state.queries[index].response =
(state.queries[index].response || '') + query.response; (state.queries[index].response || '') + query.response;
} else {
state.queries[index] = {
...state.queries[index],
...query,
};
} }
}, },
updateConversationId( updateConversationId(
@@ -256,36 +262,50 @@ export const conversationSlice = createSlice({
}, },
updateThought( updateThought(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
const { index, query } = action.payload; const { conversationId, index, query } = action.payload;
if (state.conversationId !== conversationId) return;
if (query.thought != undefined) { if (query.thought != undefined) {
state.queries[index].thought = state.queries[index].thought =
(state.queries[index].thought || '') + query.thought; (state.queries[index].thought || '') + query.thought;
} else {
state.queries[index] = {
...state.queries[index],
...query,
};
} }
}, },
updateStreamingSource( updateStreamingSource(
state, state,
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{
conversationId: string | null;
index: number;
query: Partial<Query>;
}>,
) { ) {
const { index, query } = action.payload; const { index, query } = action.payload;
if (!state.queries[index].sources) { if (query.sources !== undefined)
state.queries[index].sources = query?.sources; state.queries[index].sources = query.sources;
} else {
state.queries[index].sources!.push(query.sources![0]);
}
}, },
updateToolCalls( updateToolCall(state, action) {
state, const { index, tool_call } = action.payload;
action: PayloadAction<{ index: number; query: Partial<Query> }>,
) { if (!state.queries[index].tool_calls) {
const { index, query } = action.payload; state.queries[index].tool_calls = [];
state.queries[index].tool_calls = query?.tool_calls ?? []; }
const existingIndex = state.queries[index].tool_calls.findIndex(
(call) => call.call_id === tool_call.call_id,
);
if (existingIndex !== -1) {
const existingCall = state.queries[index].tool_calls[existingIndex];
state.queries[index].tool_calls[existingIndex] = {
...existingCall,
...tool_call,
};
} else state.queries[index].tool_calls.push(tool_call);
}, },
updateQuery( updateQuery(
state, state,
@@ -302,44 +322,22 @@ export const conversationSlice = createSlice({
}, },
raiseError( raiseError(
state, state,
action: PayloadAction<{ index: number; message: string }>, action: PayloadAction<{
conversationId: string | null;
index: number;
message: string;
}>,
) { ) {
const { index, message } = action.payload; const { conversationId, index, message } = action.payload;
if (state.conversationId !== conversationId) return;
state.queries[index].error = message; state.queries[index].error = message;
}, },
setAttachments: (state, action: PayloadAction<Attachment[]>) => {
state.attachments = action.payload;
},
addAttachment: (state, action: PayloadAction<Attachment>) => {
state.attachments.push(action.payload);
},
updateAttachment: (
state,
action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>,
) => {
const index = state.attachments.findIndex(
(att) => att.taskId === action.payload.taskId,
);
if (index !== -1) {
state.attachments[index] = {
...state.attachments[index],
...action.payload.updates,
};
}
},
removeAttachment: (state, action: PayloadAction<string>) => {
state.attachments = state.attachments.filter(
(att) => att.taskId !== action.payload && att.id !== action.payload,
);
},
resetConversation: (state) => { resetConversation: (state) => {
state.queries = initialState.queries; state.queries = initialState.queries;
state.status = initialState.status; state.status = initialState.status;
state.conversationId = initialState.conversationId; state.conversationId = initialState.conversationId;
state.attachments = initialState.attachments;
handleAbort(); handleAbort();
}, },
}, },
@@ -365,11 +363,6 @@ export const selectQueries = (state: RootState) => state.conversation.queries;
export const selectStatus = (state: RootState) => state.conversation.status; export const selectStatus = (state: RootState) => state.conversation.status;
export const selectAttachments = (state: RootState) =>
state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter((att) => att.status === 'completed');
export const { export const {
addQuery, addQuery,
updateQuery, updateQuery,
@@ -378,12 +371,10 @@ export const {
updateConversationId, updateConversationId,
updateThought, updateThought,
updateStreamingSource, updateStreamingSource,
updateToolCalls, updateToolCall,
setConversation, setConversation,
setAttachments, setStatus,
addAttachment, raiseError,
updateAttachment,
removeAttachment,
resetConversation, resetConversation,
} = conversationSlice.actions; } = conversationSlice.actions;
export default conversationSlice.reducer; export default conversationSlice.reducer;

View File

@@ -7,6 +7,10 @@ import {
handleFetchSharedAnswer, handleFetchSharedAnswer,
handleFetchSharedAnswerStreaming, handleFetchSharedAnswerStreaming,
} from './conversationHandlers'; } from './conversationHandlers';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true'; const API_STREAMING = import.meta.env.VITE_API_STREAMING === 'true';
interface SharedConversationsType { interface SharedConversationsType {
@@ -29,6 +33,14 @@ export const fetchSharedAnswer = createAsyncThunk<Answer, { question: string }>(
async ({ question }, { dispatch, getState, signal }) => { async ({ question }, { dispatch, getState, signal }) => {
const state = getState() as RootState; const state = getState() as RootState;
const attachmentIds = selectCompletedAttachments(state)
.filter((a) => a.id)
.map((a) => a.id) as string[];
if (attachmentIds.length > 0) {
dispatch(clearAttachments());
}
if (state.preference && state.sharedConversation.apiKey) { if (state.preference && state.sharedConversation.apiKey) {
if (API_STREAMING) { if (API_STREAMING) {
await handleFetchSharedAnswerStreaming( await handleFetchSharedAnswerStreaming(
@@ -36,7 +48,7 @@ export const fetchSharedAnswer = createAsyncThunk<Answer, { question: string }>(
signal, signal,
state.sharedConversation.apiKey, state.sharedConversation.apiKey,
state.sharedConversation.queries, state.sharedConversation.queries,
attachmentIds,
(event) => { (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
// check if the 'end' event has been received // check if the 'end' event has been received
@@ -92,6 +104,7 @@ export const fetchSharedAnswer = createAsyncThunk<Answer, { question: string }>(
question, question,
signal, signal,
state.sharedConversation.apiKey, state.sharedConversation.apiKey,
attachmentIds,
); );
if (answer) { if (answer) {
let sourcesPrepped = []; let sourcesPrepped = [];

View File

@@ -3,5 +3,6 @@ export type ToolCallsType = {
action_name: string; action_name: string;
call_id: string; call_id: string;
arguments: Record<string, any>; arguments: Record<string, any>;
result: Record<string, any>; result?: Record<string, any>;
status?: 'pending' | 'completed';
}; };

View File

@@ -1,7 +1,7 @@
import { useEffect, RefObject, useState } from 'react'; import { useEffect, RefObject, useState } from 'react';
export function useOutsideAlerter<T extends HTMLElement>( export function useOutsideAlerter<T extends HTMLElement>(
ref: RefObject<T>, ref: RefObject<T | null>,
handler: () => void, handler: () => void,
additionalDeps: unknown[], additionalDeps: unknown[],
handleEscapeKey?: boolean, handleEscapeKey?: boolean,
@@ -30,7 +30,7 @@ export function useOutsideAlerter<T extends HTMLElement>(
document.removeEventListener('keydown', handleEscape); document.removeEventListener('keydown', handleEscape);
} }
}; };
}, [ref, ...additionalDeps]); }, [ref, handler, handleEscapeKey, ...additionalDeps]);
} }
export function useMediaQuery() { export function useMediaQuery() {

View File

@@ -1,105 +1,169 @@
@tailwind base; @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap')
@tailwind components; layer(base);
@tailwind utilities;
:root { @import 'tailwindcss';
--viewport-height: 100vh;
font-synthesis: none !important; @custom-variant dark (&:is(.dark *));
@theme {
--font-roboto: Roboto, sans-serif;
--color-eerie-black: #212121;
--color-black-1000: #343541;
--color-jet: #343541;
--color-gray-alpha: rgba(0, 0, 0, 0.64);
--color-gray-1000: #f6f6f6;
--color-gray-2000: rgba(0, 0, 0, 0.5);
--color-gray-3000: rgba(243, 243, 243, 1);
--color-gray-4000: #949494;
--color-gray-5000: #bbbbbb;
--color-gray-6000: #757575;
--color-red-1000: rgb(254, 202, 202);
--color-red-2000: #f44336;
--color-red-3000: #621b16;
--color-blue-1000: #7d54d1;
--color-blue-2000: #002b49;
--color-blue-3000: #4b02e2;
--color-purple-30: #7d54d1;
--color-purple-3000: rgb(230, 222, 247);
--color-blue-4000: rgba(0, 125, 255, 0.36);
--color-blue-5000: rgba(0, 125, 255);
--color-green-2000: #0fff50;
--color-light-gray: #edeef0;
--color-white-3000: #ffffff;
--color-just-black: #00000;
--color-purple-taupe: #464152;
--color-dove-gray: #6c6c6c;
--color-silver: #c4c4c4;
--color-rainy-gray: #a4a4a4;
--color-raisin-black: #222327;
--color-chinese-black: #161616;
--color-chinese-silver: #cdcdcd;
--color-dark-charcoal: #2f3036;
--color-bright-gray: #ebebeb;
--color-outer-space: #444654;
--color-gun-metal: #2e303e;
--color-sonic-silver: #747474;
--color-soap: #d8ccf1;
--color-independence: #54546d;
--color-philippine-yellow: #ffc700;
--color-chinese-white: #e0e0e0;
--color-dark-gray: #aaaaaa;
--color-dim-gray: #6a6a6a;
--color-cultured: #f4f4f4;
--color-charleston-green: #2b2c31;
--color-charleston-green-2: #26272e;
--color-charleston-green-3: #26272a;
--color-grey: #7e7e7e;
--color-lotion: #fbfbfb;
--color-platinum: #e6e6e6;
--color-eerie-black-2: #191919;
--color-light-silver: #d9d9d9;
--color-carbon: #2e2e2e;
--color-onyx: #35363b;
--color-royal-purple: #6c4ab0;
--color-chinese-black-2: #0f1419;
--color-gainsboro: #d9dcde;
--color-onyx-2: #35383c;
--color-philippine-grey: #929292;
--color-charcoal-grey: #53545d;
--color-rosso-corsa: #d30000;
--color-north-texas-green: #0c9d35;
--color-medium-purple: #8d66dd;
--color-slate-blue: #6f5fca;
--color-old-silver: #848484;
--color-arsenic: #4d4e58;
--color-light-gainsboro: #d7d7d7;
--color-raisin-black-light: #18181b;
--color-gunmetal: #32333b;
--color-sonic-silver-light: #7f7f82;
--color-violets-are-blue: #976af3;
} }
@supports (height: 100dvh) { /*
:root { The default border color has changed to `currentcolor` in Tailwind CSS v4,
--viewport-height: 100dvh; /* Use dvh where supported */ so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
} }
} }
body.dark { @utility no-scrollbar {
background-color: #202124; /* raisin-black */
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dark ::-webkit-scrollbar-track {
background: #2f3036;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 40px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #b1afaf;
}
@layer utilities {
/* Chrome, Safari and Opera */ /* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.no-scrollbar { @utility scrollbar-thin {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Thin scrollbar utility */ /* Thin scrollbar utility */
.scrollbar-thin::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
.scrollbar-thin::-webkit-scrollbar-track { &::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scrollbar-thin::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: rgba(156, 163, 175, 0.5); background: rgba(156, 163, 175, 0.5);
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.7); background: rgba(156, 163, 175, 0.7);
} }
/* For Firefox */ /* For Firefox */
.scrollbar-thin { scrollbar-width: thin;
scrollbar-width: thin; scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
} }
@layer components { @utility table-default {
.table-default { @apply block w-full table-auto justify-center overflow-auto rounded-xl border border-silver text-center dark:border-silver/40 dark:text-bright-gray;
@apply block w-full table-auto justify-center overflow-auto rounded-xl border border-silver text-center dark:border-silver/40 dark:text-bright-gray;
}
.table-default th { & th {
@apply text-nowrap p-4 font-normal text-gray-400; @apply text-nowrap p-4 font-normal text-gray-400;
} }
.table-default th { & th {
flex: 1; flex: 1;
} }
.table-default th:last-child { & th:last-child {
flex: 0; flex: 0;
} }
.table-default td { & td {
@apply w-full border-t border-silver px-4 py-2 dark:border-silver/40; @apply w-full border-t border-silver px-4 py-2 dark:border-silver/40;
} }
.table-default td:last-child { & td:last-child {
@apply border-r-0; @apply border-r-0;
} }
.table-default th, & th {
.table-default td { min-width: 150px;
max-width: 320px;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: grey transparent;
}
& td {
min-width: 150px; min-width: 150px;
max-width: 320px; max-width: 320px;
overflow: auto; overflow: auto;
@@ -108,7 +172,44 @@ body.dark {
} }
} }
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ @layer utilities {
:root {
--viewport-height: 100vh;
font-synthesis: none !important;
}
@supports (height: 100dvh) {
:root {
--viewport-height: 100dvh; /* Use dvh where supported */
}
}
body.dark {
background-color: #202124; /* raisin-black */
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
.dark ::-webkit-scrollbar-track {
background: #2f3036;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 40px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #b1afaf;
}
}
@layer base{
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document /* Document
========================================================================== */ ========================================================================== */
@@ -324,6 +425,7 @@ button,
[type='reset'], [type='reset'],
[type='submit'] { [type='submit'] {
-webkit-appearance: button; -webkit-appearance: button;
cursor: pointer;
} }
/** /**
@@ -584,3 +686,5 @@ input:-webkit-autofill:active {
transform: translateY(0); transform: translateY(0);
} }
} }
}

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