diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index abc1f9ba..2d889b34 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -88,19 +88,28 @@ def run_async_chain(chain, question, chat_history): def get_agent_key(agent_id, user_id): if not agent_id: - return None + return None, False, None try: agent = agents_collection.find_one({"_id": ObjectId(agent_id)}) if agent is None: raise Exception("Agent not found", 404) - if agent.get("user") == user_id: + is_owner = agent.get("user") == user_id + + if is_owner: agents_collection.update_one( {"_id": ObjectId(agent_id)}, {"$set": {"lastUsedAt": datetime.datetime.now(datetime.timezone.utc)}}, ) - return str(agent["key"]) + return str(agent["key"]), False, None + + is_shared_with_user = agent.get( + "shared_publicly", False + ) or user_id in agent.get("shared_with", []) + + if is_shared_with_user: + return str(agent["key"]), True, agent.get("shared_token") raise Exception("Unauthorized access to the agent", 403) @@ -153,6 +162,8 @@ def save_conversation( index=None, api_key=None, agent_id=None, + is_shared_usage=False, + shared_token=None, ): current_time = datetime.datetime.now(datetime.timezone.utc) if conversation_id is not None and index is not None: @@ -228,6 +239,9 @@ def save_conversation( if api_key: if agent_id: conversation_data["agent_id"] = agent_id + if is_shared_usage: + conversation_data["is_shared_usage"] = is_shared_usage + conversation_data["shared_token"] = shared_token api_key_doc = agents_collection.find_one({"key": api_key}) if api_key_doc: conversation_data["api_key"] = api_key_doc["key"] @@ -261,6 +275,8 @@ def complete_stream( should_save_conversation=True, attachments=None, agent_id=None, + is_shared_usage=False, + shared_token=None, ): try: response_full, thought, source_log_docs, tool_calls = "", "", [], [] @@ -325,6 +341,8 @@ def complete_stream( index, api_key=user_api_key, agent_id=agent_id, + is_shared_usage=is_shared_usage, + shared_token=shared_token, ) else: conversation_id = None @@ -433,7 +451,9 @@ class Stream(Resource): retriever_name = data.get("retriever", "classic") agent_id = data.get("agent_id", None) agent_type = settings.AGENT_NAME - agent_key = get_agent_key(agent_id, request.decoded_token.get("sub")) + agent_key, is_shared_usage, shared_token = get_agent_key( + agent_id, request.decoded_token.get("sub") + ) if agent_key: data.update({"api_key": agent_key}) @@ -448,7 +468,10 @@ class Stream(Resource): retriever_name = data_key.get("retriever", retriever_name) user_api_key = data["api_key"] agent_type = data_key.get("agent_type", agent_type) - decoded_token = {"sub": data_key.get("user")} + if is_shared_usage: + decoded_token = request.decoded_token + else: + decoded_token = {"sub": data_key.get("user")} elif "active_docs" in data: source = {"active_docs": data["active_docs"]} @@ -514,6 +537,8 @@ class Stream(Resource): index=index, should_save_conversation=save_conv, agent_id=agent_id, + is_shared_usage=is_shared_usage, + shared_token=shared_token, ), mimetype="text/event-stream", ) @@ -881,6 +906,8 @@ def get_attachments_content(attachment_ids, user): if attachment_doc: attachments.append(attachment_doc) except Exception as e: - logger.error(f"Error retrieving attachment {attachment_id}: {e}", exc_info=True) + logger.error( + f"Error retrieving attachment {attachment_id}: {e}", exc_info=True + ) return attachments diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 93268102..2192241c 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -41,6 +41,12 @@ shared_conversations_collections = db["shared_conversations"] user_logs_collection = db["user_logs"] user_tools_collection = db["user_tools"] +agents_collection.create_index( + [("shared", 1)], + name="shared_index", + background=True, +) + user = Blueprint("user", __name__) user_ns = Namespace("user", description="User related operations", path="/") api.add_namespace(user_ns) @@ -166,6 +172,8 @@ class GetConversations(Resource): "id": str(conversation["_id"]), "name": conversation["name"], "agent_id": conversation.get("agent_id", None), + "is_shared_usage": conversation.get("is_shared_usage", False), + "shared_token": conversation.get("shared_token", None), } for conversation in conversations ] @@ -208,6 +216,8 @@ class GetSingleConversation(Resource): data = { "queries": conversation["queries"], "agent_id": conversation.get("agent_id"), + "is_shared_usage": conversation.get("is_shared_usage", False), + "shared_token": conversation.get("shared_token", None), } return make_response(jsonify(data), 200) @@ -1034,6 +1044,9 @@ class GetAgent(Resource): else "" ), "pinned": agent.get("pinned", False), + "shared": agent.get("shared_publicly", False), + "shared_metadata": agent.get("shared_metadata", {}), + "shared_token": agent.get("shared_token", ""), } except Exception as err: current_app.logger.error(f"Error retrieving agent: {err}", exc_info=True) @@ -1077,6 +1090,9 @@ class GetAgents(Resource): else "" ), "pinned": agent.get("pinned", False), + "shared": agent.get("shared_publicly", False), + "shared_metadata": agent.get("shared_metadata", {}), + "shared_token": agent.get("shared_token", ""), } for agent in agents if "source" in agent or "retriever" in agent @@ -1478,6 +1494,195 @@ class PinAgent(Resource): return make_response(jsonify({"success": True}), 200) +@user_ns.route("/api/shared_agent") +class SharedAgent(Resource): + @api.doc( + params={ + "token": "Shared token of the agent", + }, + description="Get a shared agent by token or ID", + ) + def get(self): + shared_token = request.args.get("token") + + if not shared_token: + return make_response( + jsonify({"success": False, "message": "Token or ID is required"}), 400 + ) + + try: + query = {} + query["shared_publicly"] = True + query["shared_token"] = shared_token + + shared_agent = agents_collection.find_one(query) + if not shared_agent: + return make_response( + jsonify({"success": False, "message": "Shared agent not found"}), + 404, + ) + + data = { + "id": str(shared_agent["_id"]), + "user": shared_agent.get("user", ""), + "name": shared_agent.get("name", ""), + "description": shared_agent.get("description", ""), + "tools": shared_agent.get("tools", []), + "agent_type": shared_agent.get("agent_type", ""), + "status": shared_agent.get("status", ""), + "created_at": shared_agent.get("createdAt", ""), + "updated_at": shared_agent.get("updatedAt", ""), + "shared": shared_agent.get("shared_publicly", False), + "shared_token": shared_agent.get("shared_token", ""), + "shared_metadata": shared_agent.get("shared_metadata", {}), + } + + if data["tools"]: + enriched_tools = [] + for tool in data["tools"]: + tool_data = user_tools_collection.find_one({"_id": ObjectId(tool)}) + if tool_data: + enriched_tools.append(tool_data.get("displayName", "")) + data["tools"] = enriched_tools + + except Exception as err: + current_app.logger.error(f"Error retrieving shared agent: {err}") + return make_response(jsonify({"success": False}), 400) + + return make_response(jsonify(data), 200) + + +@user_ns.route("/api/shared_agents") +class SharedAgents(Resource): + @api.doc(description="Get shared agents") + def get(self): + try: + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + shared_agents = agents_collection.find( + {"shared_publicly": True, "user": {"$ne": user}} + ) + list_shared_agents = [ + { + "id": str(shared_agent["_id"]), + "name": shared_agent.get("name", ""), + "description": shared_agent.get("description", ""), + "tools": shared_agent.get("tools", []), + "agent_type": shared_agent.get("agent_type", ""), + "status": shared_agent.get("status", ""), + "created_at": shared_agent.get("createdAt", ""), + "updated_at": shared_agent.get("updatedAt", ""), + "shared": shared_agent.get("shared_publicly", False), + "shared_token": shared_agent.get("shared_token", ""), + "shared_metadata": shared_agent.get("shared_metadata", {}), + } + for shared_agent in shared_agents + ] + except Exception as err: + current_app.logger.error(f"Error retrieving shared agents: {err}") + return make_response(jsonify({"success": False}), 400) + return make_response(jsonify(list_shared_agents), 200) + + +@user_ns.route("/api/share_agent") +class ShareAgent(Resource): + @api.expect( + api.model( + "ShareAgentModel", + { + "id": fields.String(required=True, description="ID of the agent"), + "shared": fields.Boolean( + required=True, description="Share or unshare the agent" + ), + "username": fields.String( + required=False, description="Name of the user" + ), + }, + ) + ) + @api.doc(description="Share or unshare an agent") + def put(self): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + + user = decoded_token.get("sub") + + data = request.get_json() + if not data: + return make_response( + jsonify({"success": False, "message": "Missing JSON body"}), 400 + ) + + agent_id = data.get("id") + shared = data.get("shared") + username = data.get("username", "") + + if not agent_id: + return make_response( + jsonify({"success": False, "message": "ID is required"}), 400 + ) + + if shared is None: + return make_response( + jsonify( + { + "success": False, + "message": "Shared parameter is required and must be true or false", + } + ), + 400, + ) + + try: + try: + agent_oid = ObjectId(agent_id) + except Exception: + return make_response( + jsonify({"success": False, "message": "Invalid agent ID"}), 400 + ) + + agent = agents_collection.find_one({"_id": agent_oid, "user": user}) + if not agent: + return make_response( + jsonify({"success": False, "message": "Agent not found"}), 404 + ) + + if shared: + shared_metadata = { + "shared_by": username, + "shared_at": datetime.datetime.now(datetime.timezone.utc), + } + shared_token = secrets.token_urlsafe(32) + agents_collection.update_one( + {"_id": agent_oid, "user": user}, + { + "$set": { + "shared_publicly": shared, + "shared_metadata": shared_metadata, + "shared_token": shared_token, + } + }, + ) + else: + agents_collection.update_one( + {"_id": agent_oid, "user": user}, + {"$set": {"shared_publicly": shared, "shared_token": None}}, + {"$unset": {"shared_metadata": ""}}, + ) + + except Exception as err: + current_app.logger.error(f"Error sharing/unsharing agent: {err}") + return make_response(jsonify({"success": False, "error": str(err)}), 400) + + shared_token = shared_token if shared else None + return make_response( + jsonify({"success": True, "shared_token": shared_token}), 200 + ) + + @user_ns.route("/api/agent_webhook") class AgentWebhook(Resource): @api.doc( diff --git a/frontend/.env.development b/frontend/.env.development index 7a87f762..4083d677 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1,3 +1,4 @@ # Please put appropriate value -VITE_API_HOST=http://0.0.0.0:7091 +VITE_BASE_URL=http://localhost:5173 +VITE_API_HOST=http://127.0.0.1:7091 VITE_API_STREAMING=true \ No newline at end of file diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index c0710b9c..3fdcf5fd 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -110,7 +110,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { ) .slice(0, 3 - pinnedAgents.length); setRecentAgents([...pinnedAgents, ...additionalAgents]); - console.log(additionalAgents); } catch (error) { console.error('Failed to fetch recent agents: ', error); } @@ -181,22 +180,37 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) { .getConversation(index, token) .then((response) => response.json()) .then((data) => { - navigate('/'); + if (data.agent_id) { + if (data.is_shared_usage) { + userService + .getSharedAgent(data.shared_token, token) + .then((response) => { + if (response.ok) { + response.json().then((agent: Agent) => { + navigate(`/agents/shared/${agent.shared_token}`); + }); + } + }); + } else { + userService.getAgent(data.agent_id, token).then((response) => { + if (response.ok) { + response.json().then((agent: Agent) => { + navigate('/'); + dispatch(setSelectedAgent(agent)); + }); + } + }); + } + } else { + navigate('/'); + dispatch(setSelectedAgent(null)); + } dispatch(setConversation(data.queries)); dispatch( updateConversationId({ query: { conversationId: index }, }), ); - if (data.agent_id) { - userService.getAgent(data.agent_id, token).then((response) => { - if (response.ok) { - response.json().then((agent: Agent) => { - dispatch(setSelectedAgent(agent)); - }); - } - }); - } else dispatch(setSelectedAgent(null)); }); }; diff --git a/frontend/src/agents/AgentCard.tsx b/frontend/src/agents/AgentCard.tsx new file mode 100644 index 00000000..33691a52 --- /dev/null +++ b/frontend/src/agents/AgentCard.tsx @@ -0,0 +1,123 @@ +import { useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import userService from '../api/services/userService'; +import Robot from '../assets/robot.svg'; +import ThreeDots from '../assets/three-dots.svg'; +import ContextMenu, { MenuOption } from '../components/ContextMenu'; +import ConfirmationModal from '../modals/ConfirmationModal'; +import { ActiveState } from '../models/misc'; +import { + selectToken, + setAgents, + setSelectedAgent, +} from '../preferences/preferenceSlice'; +import { Agent } from './types'; + +type AgentCardProps = { + agent: Agent; + agents: Agent[]; + menuOptions?: MenuOption[]; + onDelete?: (agentId: string) => void; +}; + +export default function AgentCard({ + agent, + agents, + menuOptions, + onDelete, +}: AgentCardProps) { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const token = useSelector(selectToken); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [deleteConfirmation, setDeleteConfirmation] = + useState('INACTIVE'); + + const menuRef = useRef(null); + + const handleCardClick = () => { + if (agent.status === 'published') { + dispatch(setSelectedAgent(agent)); + navigate('/'); + } + }; + + const defaultDelete = async (agentId: string) => { + const response = await userService.deleteAgent(agentId, token); + if (!response.ok) throw new Error('Failed to delete agent'); + const data = await response.json(); + dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id))); + }; + + return ( +
+
{ + e.stopPropagation(); + setIsMenuOpen(true); + }} + className="absolute right-4 top-4 z-10 cursor-pointer" + > + options + {menuOptions && ( + + )} +
+ +
+
+ {`${agent.name}`} + {agent.status === 'draft' && ( +

+ (Draft) +

+ )} +
+
+

+ {agent.name} +

+

+ {agent.description} +

+
+
+ + { + onDelete ? onDelete(agent.id || '') : defaultDelete(agent.id || ''); + setDeleteConfirmation('INACTIVE'); + }} + cancelLabel="Cancel" + variant="danger" + /> +
+ ); +} diff --git a/frontend/src/agents/SharedAgent.tsx b/frontend/src/agents/SharedAgent.tsx new file mode 100644 index 00000000..65b60e3c --- /dev/null +++ b/frontend/src/agents/SharedAgent.tsx @@ -0,0 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import userService from '../api/services/userService'; +import NoFilesDarkIcon from '../assets/no-files-dark.svg'; +import NoFilesIcon from '../assets/no-files.svg'; +import Robot from '../assets/robot.svg'; +import MessageInput from '../components/MessageInput'; +import Spinner from '../components/Spinner'; +import ConversationMessages from '../conversation/ConversationMessages'; +import { Query } from '../conversation/conversationModels'; +import { + addQuery, + fetchAnswer, + resendQuery, + selectQueries, + selectStatus, + updateConversationId, +} from '../conversation/conversationSlice'; +import { useDarkTheme } from '../hooks'; +import { + selectToken, + setConversations, + setSelectedAgent, +} from '../preferences/preferenceSlice'; +import { AppDispatch } from '../store'; +import { Agent } from './types'; + +export default function SharedAgent() { + const { agentId } = useParams(); + const dispatch = useDispatch(); + const [isDarkTheme] = useDarkTheme(); + + const token = useSelector(selectToken); + const queries = useSelector(selectQueries); + const status = useSelector(selectStatus); + + const [sharedAgent, setSharedAgent] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [input, setInput] = useState(''); + const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); + + const fetchStream = useRef(null); + + const getSharedAgent = async () => { + try { + setIsLoading(true); + const response = await userService.getSharedAgent(agentId ?? '', token); + if (!response.ok) throw new Error('Failed to fetch Shared Agent'); + const agent: Agent = await response.json(); + setSharedAgent(agent); + } catch (error) { + console.error('Error: ', error); + } finally { + setIsLoading(false); + } + }; + + const handleFetchAnswer = useCallback( + ({ question, index }: { question: string; index?: number }) => { + fetchStream.current = dispatch( + fetchAnswer({ question, indx: index, isPreview: false }), + ); + }, + [dispatch], + ); + + const handleQuestion = useCallback( + ({ + question, + isRetry = false, + index = undefined, + }: { + question: string; + isRetry?: boolean; + index?: number; + }) => { + const trimmedQuestion = question.trim(); + if (trimmedQuestion === '') return; + + if (index !== undefined) { + if (!isRetry) dispatch(resendQuery({ index, prompt: trimmedQuestion })); + handleFetchAnswer({ question: trimmedQuestion, index }); + } else { + if (!isRetry) { + const newQuery: Query = { prompt: trimmedQuestion }; + dispatch(addQuery(newQuery)); + } + handleFetchAnswer({ question: trimmedQuestion, index: undefined }); + } + }, + [dispatch, handleFetchAnswer], + ); + + const handleQuestionSubmission = ( + updatedQuestion?: string, + updated?: boolean, + indx?: number, + ) => { + if ( + updated === true && + updatedQuestion !== undefined && + indx !== undefined + ) { + handleQuestion({ + question: updatedQuestion, + index: indx, + isRetry: false, + }); + } else if (input.trim() && status !== 'loading') { + const currentInput = input.trim(); + if (lastQueryReturnedErr && queries.length > 0) { + const lastQueryIndex = queries.length - 1; + handleQuestion({ + question: currentInput, + isRetry: true, + index: lastQueryIndex, + }); + } else { + handleQuestion({ + question: currentInput, + isRetry: false, + index: undefined, + }); + } + setInput(''); + } + }; + + useEffect(() => { + if (agentId) getSharedAgent(); + }, [agentId, token]); + + useEffect(() => { + if (sharedAgent) dispatch(setSelectedAgent(sharedAgent)); + }, [sharedAgent, dispatch]); + + if (isLoading) + return ( +
+ +
+ ); + if (!sharedAgent) + return ( +
+
+ No agent found +

+ No agent found. Please ensure the agent is shared. +

+
+
+ ); + return ( +
+
+
+ + +
+ } + /> +
+
+ setInput(e.target.value)} + onSubmit={() => handleQuestionSubmission()} + loading={status === 'loading'} + showSourceButton={sharedAgent ? false : true} + showToolButton={sharedAgent ? false : true} + autoFocus={false} + /> +

+ This is a preview of the agent. You can publish it to start using it + in conversations. +

+
+
+ + ); +} + +function SharedAgentCard({ agent }: { agent: Agent }) { + return ( +
+
+
+ +
+
+

+ {agent.name} +

+

+ {agent.description} +

+
+
+
+ {agent.shared_metadata?.shared_by && ( +

+ by {agent.shared_metadata.shared_by} +

+ )} + {agent.shared_metadata?.shared_at && ( +

+ Shared on{' '} + {new Date(agent.shared_metadata.shared_at).toLocaleString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + })} +

+ )} +
+
+

+ Connected Tools +

+
+ {agent.tools.map((tool, index) => ( + + {tool} + + ))} +
+
+ {/* */} +
+ ); +} diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx index 9034e808..710f810f 100644 --- a/frontend/src/agents/index.tsx +++ b/frontend/src/agents/index.tsx @@ -9,10 +9,15 @@ import Monitoring from '../assets/monitoring.svg'; import Pin from '../assets/pin.svg'; import Trash from '../assets/red-trash.svg'; import Robot from '../assets/robot.svg'; +import Link from '../assets/link-gray.svg'; import ThreeDots from '../assets/three-dots.svg'; import UnPin from '../assets/unpin.svg'; import ContextMenu, { MenuOption } from '../components/ContextMenu'; import Spinner from '../components/Spinner'; +import { + setConversation, + updateConversationId, +} from '../conversation/conversationSlice'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState } from '../models/misc'; import { @@ -24,6 +29,7 @@ import { } from '../preferences/preferenceSlice'; import AgentLogs from './AgentLogs'; import NewAgent from './NewAgent'; +import SharedAgent from './SharedAgent'; import { Agent } from './types'; export default function Agents() { @@ -33,10 +39,26 @@ export default function Agents() { } /> } /> } /> + } /> ); } +const sectionConfig = { + user: { + title: 'By me', + description: 'Agents created or published by you', + showNewAgentButton: true, + emptyStateDescription: 'You don’t have any created agents yet', + }, + shared: { + title: 'Shared with me', + description: 'Agents imported by using a public link', + showNewAgentButton: false, + emptyStateDescription: 'No shared agents found', + }, +}; + function AgentsList() { const navigate = useNavigate(); const dispatch = useDispatch(); @@ -44,24 +66,47 @@ function AgentsList() { const agents = useSelector(selectAgents); const selectedAgent = useSelector(selectSelectedAgent); - const [loading, setLoading] = useState(true); + const [sharedAgents, setSharedAgents] = useState([]); + const [loadingUserAgents, setLoadingUserAgents] = useState(true); + const [loadingSharedAgents, setLoadingSharedAgents] = useState(true); const getAgents = async () => { try { - setLoading(true); + setLoadingUserAgents(true); const response = await userService.getAgents(token); if (!response.ok) throw new Error('Failed to fetch agents'); const data = await response.json(); dispatch(setAgents(data)); - setLoading(false); + setLoadingUserAgents(false); } catch (error) { console.error('Error:', error); - setLoading(false); + setLoadingUserAgents(false); + } + }; + + const getSharedAgents = async () => { + try { + setLoadingSharedAgents(true); + const response = await userService.getSharedAgents(token); + if (!response.ok) throw new Error('Failed to fetch shared agents'); + const data = await response.json(); + setSharedAgents(data); + setLoadingSharedAgents(false); + } catch (error) { + console.error('Error:', error); + setLoadingSharedAgents(false); } }; useEffect(() => { getAgents(); + getSharedAgents(); + dispatch(setConversation([])); + dispatch( + updateConversationId({ + query: { conversationId: null }, + }), + ); if (selectedAgent) dispatch(setSelectedAgent(null)); }, [token]); return ( @@ -71,7 +116,7 @@ function AgentsList() {

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

{/* Premade agents section */} {/*
@@ -116,45 +161,91 @@ function AgentsList() { ))}
*/} -
-
+ + +
+ ); +} + +function AgentSection({ + agents, + loading, + section, +}: { + agents: Agent[]; + loading: boolean; + section: keyof typeof sectionConfig; +}) { + const navigate = useNavigate(); + return ( +
+
+

- Created by You + {sectionConfig[section].title}

+

+ {sectionConfig[section].description} +

+
+ {sectionConfig[section].showNewAgentButton && ( -
-
- {loading ? ( -
- -
- ) : agents && agents.length > 0 ? ( - agents.map((agent) => ( - - )) - ) : ( -
-

You don’t have any created agents yet

+ )} +
+
+ {loading ? ( +
+ +
+ ) : agents && agents.length > 0 ? ( + agents.map((agent) => ( + + )) + ) : ( +
+

{sectionConfig[section].emptyStateDescription}

+ {sectionConfig[section].showNewAgentButton && ( -
- )} -
+ )} +
+ )}
); } -function AgentCard({ agent, agents }: { agent: Agent; agents: Agent[] }) { +function AgentCard({ + agent, + agents, + section, +}: { + agent: Agent; + agents: Agent[]; + section: keyof typeof sectionConfig; +}) { const navigate = useNavigate(); const dispatch = useDispatch(); const token = useSelector(selectToken); @@ -180,57 +271,79 @@ function AgentCard({ agent, agents }: { agent: Agent; agents: Agent[] }) { } }; - const menuOptions: MenuOption[] = [ - { - icon: Monitoring, - label: 'Logs', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - navigate(`/agents/logs/${agent.id}`); + const menuOptionsConfig: Record = { + user: [ + { + icon: Monitoring, + label: 'Logs', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/logs/${agent.id}`); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, }, - variant: 'primary', - iconWidth: 14, - iconHeight: 14, - }, - { - icon: Edit, - label: 'Edit', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - navigate(`/agents/edit/${agent.id}`); + { + icon: Edit, + label: 'Edit', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/edit/${agent.id}`); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, }, - variant: 'primary', - iconWidth: 14, - iconHeight: 14, - }, - { - icon: agent.pinned ? UnPin : Pin, - label: agent.pinned ? 'Unpin' : 'Pin agent', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - togglePin(); + { + icon: agent.pinned ? UnPin : Pin, + label: agent.pinned ? 'Unpin' : 'Pin agent', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + togglePin(); + }, + variant: 'primary', + iconWidth: 18, + iconHeight: 18, }, - variant: 'primary', - iconWidth: 18, - iconHeight: 18, - }, - { - icon: Trash, - label: 'Delete', - onClick: (e: SyntheticEvent) => { - e.stopPropagation(); - setDeleteConfirmation('ACTIVE'); + { + icon: Trash, + label: 'Delete', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + setDeleteConfirmation('ACTIVE'); + }, + variant: 'danger', + iconWidth: 13, + iconHeight: 13, }, - variant: 'danger', - iconWidth: 13, - iconHeight: 13, - }, - ]; + ], + shared: [ + { + icon: Link, + label: 'Open', + onClick: (e: SyntheticEvent) => { + e.stopPropagation(); + navigate(`/agents/shared/${agent.shared_token}`); + }, + variant: 'primary', + iconWidth: 14, + iconHeight: 14, + }, + ], + }; + + const menuOptions = menuOptionsConfig[section] || []; const handleClick = () => { - if (agent.status === 'published') { - dispatch(setSelectedAgent(agent)); - navigate(`/`); + if (section === 'user') { + if (agent.status === 'published') { + dispatch(setSelectedAgent(agent)); + navigate(`/`); + } + } + if (section === 'shared') { + navigate(`/agents/shared/${agent.shared_token}`); } }; diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 5b05e92c..fe3cb418 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -13,6 +13,9 @@ export type Agent = { key?: string; incoming_webhook_token?: string; pinned?: boolean; + shared?: boolean; + shared_token?: string; + shared_metadata?: any; created_at?: string; updated_at?: string; last_used_at?: string; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3db613fc..28707012 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,5 @@ -const baseURL = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; +export const baseURL = + import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com'; const defaultHeaders = { 'Content-Type': 'application/json', diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 20f769c9..248a88cb 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -15,6 +15,9 @@ const endpoints = { DELETE_AGENT: (id: string) => `/api/delete_agent?id=${id}`, PINNED_AGENTS: '/api/pinned_agents', TOGGLE_PIN_AGENT: (id: string) => `/api/pin_agent?id=${id}`, + SHARED_AGENT: (id: string) => `/api/shared_agent?token=${id}`, + SHARED_AGENTS: '/api/shared_agents', + SHARE_AGENT: `/api/share_agent`, AGENT_WEBHOOK: (id: string) => `/api/agent_webhook?id=${id}`, PROMPTS: '/api/get_prompts', CREATE_PROMPT: '/api/create_prompt', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index ae2bbe8e..f9950835 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -35,6 +35,12 @@ const userService = { apiClient.get(endpoints.USER.PINNED_AGENTS, token), togglePinAgent: (id: string, token: string | null): Promise => apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token), + getSharedAgent: (id: string, token: string | null): Promise => + apiClient.get(endpoints.USER.SHARED_AGENT(id), token), + getSharedAgents: (token: string | null): Promise => + apiClient.get(endpoints.USER.SHARED_AGENTS, token), + shareAgent: (data: any, token: string | null): Promise => + apiClient.put(endpoints.USER.SHARE_AGENT, data, token), getAgentWebhook: (id: string, token: string | null): Promise => apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token), getPrompts: (token: string | null): Promise => diff --git a/frontend/src/assets/link-gray.svg b/frontend/src/assets/link-gray.svg new file mode 100644 index 00000000..75a24932 --- /dev/null +++ b/frontend/src/assets/link-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/conversation/ConversationMessages.tsx b/frontend/src/conversation/ConversationMessages.tsx index d1a9a24e..feb0fac4 100644 --- a/frontend/src/conversation/ConversationMessages.tsx +++ b/frontend/src/conversation/ConversationMessages.tsx @@ -1,4 +1,11 @@ -import { Fragment, useEffect, useRef, useState } from 'react'; +import { + Fragment, + ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import ArrowDown from '../assets/arrow-down.svg'; @@ -8,7 +15,12 @@ import { useDarkTheme } from '../hooks'; import ConversationBubble from './ConversationBubble'; import { FEEDBACK, Query, Status } from './conversationModels'; -interface ConversationMessagesProps { +const SCROLL_THRESHOLD = 10; +const LAST_BUBBLE_MARGIN = 'mb-32'; +const DEFAULT_BUBBLE_MARGIN = 'mb-7'; +const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5'; + +type ConversationMessagesProps = { handleQuestion: (params: { question: string; isRetry?: boolean; @@ -24,7 +36,8 @@ interface ConversationMessagesProps { queries: Query[]; status: Status; showHeroOnEmpty?: boolean; -} + headerContent?: ReactNode; +}; export default function ConversationMessages({ handleQuestion, @@ -33,22 +46,23 @@ export default function ConversationMessages({ status, handleFeedback, showHeroOnEmpty = true, + headerContent, }: ConversationMessagesProps) { const [isDarkTheme] = useDarkTheme(); const { t } = useTranslation(); const conversationRef = useRef(null); const [hasScrolledToLast, setHasScrolledToLast] = useState(true); - const [eventInterrupt, setEventInterrupt] = useState(false); + const [userInterruptedScroll, setUserInterruptedScroll] = useState(false); - const handleUserInterruption = () => { - if (!eventInterrupt && status === 'loading') { - setEventInterrupt(true); + const handleUserScrollInterruption = useCallback(() => { + if (!userInterruptedScroll && status === 'loading') { + setUserInterruptedScroll(true); } - }; + }, [userInterruptedScroll, status]); - const scrollIntoView = () => { - if (!conversationRef?.current || eventInterrupt) return; + const scrollConversationToBottom = useCallback(() => { + if (!conversationRef.current || userInterruptedScroll) return; if (status === 'idle' || !queries[queries.length - 1]?.response) { conversationRef.current.scrollTo({ @@ -58,39 +72,65 @@ export default function ConversationMessages({ } else { conversationRef.current.scrollTop = conversationRef.current.scrollHeight; } - }; + }, [userInterruptedScroll, status, queries]); - const checkScroll = () => { + const checkScrollPosition = useCallback(() => { const el = conversationRef.current; if (!el) return; - const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10; - setHasScrolledToLast(isBottom); - }; + const isAtBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD; + setHasScrolledToLast(isAtBottom); + }, [setHasScrolledToLast]); useEffect(() => { - !eventInterrupt && scrollIntoView(); - }, [queries.length, queries[queries.length - 1]]); + if (!userInterruptedScroll) { + scrollConversationToBottom(); + } + }, [ + queries.length, + queries[queries.length - 1]?.response, + queries[queries.length - 1]?.error, + queries[queries.length - 1]?.thought, + userInterruptedScroll, + scrollConversationToBottom, + ]); useEffect(() => { if (status === 'idle') { - setEventInterrupt(false); + setUserInterruptedScroll(false); } }, [status]); useEffect(() => { - conversationRef.current?.addEventListener('scroll', checkScroll); + const currentConversationRef = conversationRef.current; + currentConversationRef?.addEventListener('scroll', checkScrollPosition); return () => { - conversationRef.current?.removeEventListener('scroll', checkScroll); + currentConversationRef?.removeEventListener( + 'scroll', + checkScrollPosition, + ); }; - }, []); + }, [checkScrollPosition]); + + const retryIconProps = { + width: 12, + height: 12, + fill: isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)', + stroke: isDarkTheme ? 'rgb(236 236 241)' : 'rgb(107 114 120)', + strokeWidth: 10, + }; + + const renderResponseView = (query: Query, index: number) => { + const isLastMessage = index === queries.length - 1; + const bubbleMargin = isLastMessage + ? LAST_BUBBLE_MARGIN + : DEFAULT_BUBBLE_MARGIN; - const prepResponseView = (query: Query, index: number) => { - let responseView; if (query.thought || query.response) { - responseView = ( + return ( ); - } else if (query.error) { - const retryBtn = ( + } + + if (query.error) { + const retryButton = ( ); - responseView = ( + return ( ); } - return responseView; + return null; }; return (
{queries.length > 0 && !hasScrolledToLast && ( )} -
+
+ {headerContent && headerContent} + {queries.length > 0 ? ( queries.map((query, index) => ( - + - {prepResponseView(query, index)} + {renderResponseView(query, index)} )) ) : showHeroOnEmpty ? ( diff --git a/frontend/src/conversation/conversationHandlers.ts b/frontend/src/conversation/conversationHandlers.ts index 2fcb2e28..717a5186 100644 --- a/frontend/src/conversation/conversationHandlers.ts +++ b/frontend/src/conversation/conversationHandlers.ts @@ -142,6 +142,7 @@ export function handleFetchAnswerSteaming( .then((response) => { if (!response.body) throw Error('No response body'); + let buffer = ''; const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let counterrr = 0; @@ -157,22 +158,24 @@ export function handleFetchAnswerSteaming( counterrr += 1; const chunk = decoder.decode(value); + buffer += chunk; - const lines = chunk.split('\n'); + const events = buffer.split('\n\n'); + buffer = events.pop() ?? ''; - for (let line of lines) { - if (line.trim() == '') { - continue; + for (let event of events) { + if (event.trim().startsWith('data:')) { + const dataLine: string = event + .split('\n') + .map((line: string) => line.replace(/^data:\s?/, '')) + .join(''); + + const messageEvent = new MessageEvent('message', { + data: dataLine.trim(), + }); + + onEvent(messageEvent); } - if (line.startsWith('data:')) { - line = line.substring(5); - } - - const messageEvent: MessageEvent = new MessageEvent('message', { - data: line, - }); - - onEvent(messageEvent); // handle each message } reader.read().then(processStream).catch(reject); diff --git a/frontend/src/conversation/conversationSlice.ts b/frontend/src/conversation/conversationSlice.ts index 261f9db7..961260ea 100644 --- a/frontend/src/conversation/conversationSlice.ts +++ b/frontend/src/conversation/conversationSlice.ts @@ -285,9 +285,7 @@ export const conversationSlice = createSlice({ action: PayloadAction<{ index: number; query: Partial }>, ) { const { index, query } = action.payload; - if (!state.queries[index].tool_calls) { - state.queries[index].tool_calls = query?.tool_calls; - } + state.queries[index].tool_calls = query?.tool_calls ?? []; }, updateQuery( state, diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx index c10a837b..7d47204c 100644 --- a/frontend/src/modals/AgentDetailsModal.tsx +++ b/frontend/src/modals/AgentDetailsModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import { Agent } from '../agents/types'; @@ -9,6 +9,8 @@ import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import WrapperModal from './WrapperModal'; +const baseURL = import.meta.env.VITE_BASE_URL; + type AgentDetailsModalProps = { agent: Agent; mode: 'new' | 'edit' | 'draft'; @@ -24,7 +26,9 @@ export default function AgentDetailsModal({ }: AgentDetailsModalProps) { const token = useSelector(selectToken); - const [publicLink, setPublicLink] = useState(null); + const [sharedToken, setSharedToken] = useState( + agent.shared_token ?? null, + ); const [apiKey, setApiKey] = useState(null); const [webhookUrl, setWebhookUrl] = useState(null); const [loadingStates, setLoadingStates] = useState({ @@ -40,6 +44,21 @@ export default function AgentDetailsModal({ setLoadingStates((prev) => ({ ...prev, [key]: state })); }; + const handleGeneratePublicLink = async () => { + setLoading('publicLink', true); + const response = await userService.shareAgent( + { id: agent.id ?? '', shared: true }, + token, + ); + if (!response.ok) { + setLoading('publicLink', false); + return; + } + const data = await response.json(); + setSharedToken(data.shared_token); + setLoading('publicLink', false); + }; + const handleGenerateWebhook = async () => { setLoading('webhook', true); const response = await userService.getAgentWebhook(agent.id ?? '', token); @@ -52,6 +71,11 @@ export default function AgentDetailsModal({ setLoading('webhook', false); }; + useEffect(() => { + setSharedToken(agent.shared_token ?? null); + setApiKey(agent.key ?? null); + }, [agent]); + if (modalState !== 'ACTIVE') return null; return (
-

- Public link -

- +
+

+ Public Link +

+ {sharedToken && ( +
+ +
+ )} +
+ {sharedToken ? ( +
+

+ {`${baseURL}/agents/shared/${sharedToken}`} +

+
+ ) : ( + + )}

API Key

- {agent.key ? ( + {apiKey ? ( - {agent.key} + {apiKey} ) : (