feat: shared and pinning agents + fix for streaming tools

This commit is contained in:
Siddhant Rai
2025-05-12 06:06:11 +05:30
parent 07fa656e7c
commit 6520be5b85
16 changed files with 1015 additions and 169 deletions

View File

@@ -88,19 +88,28 @@ def run_async_chain(chain, question, chat_history):
def get_agent_key(agent_id, user_id): def get_agent_key(agent_id, user_id):
if not agent_id: if not agent_id:
return None return None, False, None
try: try:
agent = agents_collection.find_one({"_id": ObjectId(agent_id)}) agent = agents_collection.find_one({"_id": ObjectId(agent_id)})
if agent is None: if agent is None:
raise Exception("Agent not found", 404) 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( agents_collection.update_one(
{"_id": ObjectId(agent_id)}, {"_id": ObjectId(agent_id)},
{"$set": {"lastUsedAt": datetime.datetime.now(datetime.timezone.utc)}}, {"$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) raise Exception("Unauthorized access to the agent", 403)
@@ -153,6 +162,8 @@ def save_conversation(
index=None, index=None,
api_key=None, api_key=None,
agent_id=None, agent_id=None,
is_shared_usage=False,
shared_token=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:
@@ -228,6 +239,9 @@ def save_conversation(
if api_key: if api_key:
if agent_id: if agent_id:
conversation_data["agent_id"] = 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}) api_key_doc = agents_collection.find_one({"key": api_key})
if api_key_doc: if api_key_doc:
conversation_data["api_key"] = api_key_doc["key"] conversation_data["api_key"] = api_key_doc["key"]
@@ -261,6 +275,8 @@ def complete_stream(
should_save_conversation=True, should_save_conversation=True,
attachments=None, attachments=None,
agent_id=None, agent_id=None,
is_shared_usage=False,
shared_token=None,
): ):
try: try:
response_full, thought, source_log_docs, tool_calls = "", "", [], [] response_full, thought, source_log_docs, tool_calls = "", "", [], []
@@ -325,6 +341,8 @@ def complete_stream(
index, index,
api_key=user_api_key, api_key=user_api_key,
agent_id=agent_id, agent_id=agent_id,
is_shared_usage=is_shared_usage,
shared_token=shared_token,
) )
else: else:
conversation_id = None conversation_id = None
@@ -433,7 +451,9 @@ class Stream(Resource):
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
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: if agent_key:
data.update({"api_key": agent_key}) data.update({"api_key": agent_key})
@@ -448,7 +468,10 @@ class Stream(Resource):
retriever_name = data_key.get("retriever", retriever_name) retriever_name = data_key.get("retriever", retriever_name)
user_api_key = data["api_key"] user_api_key = data["api_key"]
agent_type = data_key.get("agent_type", agent_type) 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: elif "active_docs" in data:
source = {"active_docs": data["active_docs"]} source = {"active_docs": data["active_docs"]}
@@ -514,6 +537,8 @@ class Stream(Resource):
index=index, index=index,
should_save_conversation=save_conv, should_save_conversation=save_conv,
agent_id=agent_id, agent_id=agent_id,
is_shared_usage=is_shared_usage,
shared_token=shared_token,
), ),
mimetype="text/event-stream", mimetype="text/event-stream",
) )
@@ -881,6 +906,8 @@ def get_attachments_content(attachment_ids, user):
if attachment_doc: if attachment_doc:
attachments.append(attachment_doc) attachments.append(attachment_doc)
except Exception as e: 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 return attachments

View File

@@ -41,6 +41,12 @@ shared_conversations_collections = db["shared_conversations"]
user_logs_collection = db["user_logs"] user_logs_collection = db["user_logs"]
user_tools_collection = db["user_tools"] user_tools_collection = db["user_tools"]
agents_collection.create_index(
[("shared", 1)],
name="shared_index",
background=True,
)
user = Blueprint("user", __name__) user = Blueprint("user", __name__)
user_ns = Namespace("user", description="User related operations", path="/") user_ns = Namespace("user", description="User related operations", path="/")
api.add_namespace(user_ns) api.add_namespace(user_ns)
@@ -166,6 +172,8 @@ class GetConversations(Resource):
"id": str(conversation["_id"]), "id": str(conversation["_id"]),
"name": conversation["name"], "name": conversation["name"],
"agent_id": conversation.get("agent_id", None), "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 for conversation in conversations
] ]
@@ -208,6 +216,8 @@ class GetSingleConversation(Resource):
data = { data = {
"queries": conversation["queries"], "queries": conversation["queries"],
"agent_id": conversation.get("agent_id"), "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) return make_response(jsonify(data), 200)
@@ -1034,6 +1044,9 @@ class GetAgent(Resource):
else "" else ""
), ),
"pinned": agent.get("pinned", False), "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: except Exception as err:
current_app.logger.error(f"Error retrieving agent: {err}", exc_info=True) current_app.logger.error(f"Error retrieving agent: {err}", exc_info=True)
@@ -1077,6 +1090,9 @@ class GetAgents(Resource):
else "" else ""
), ),
"pinned": agent.get("pinned", False), "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 for agent in agents
if "source" in agent or "retriever" in agent if "source" in agent or "retriever" in agent
@@ -1478,6 +1494,195 @@ class PinAgent(Resource):
return make_response(jsonify({"success": True}), 200) 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") @user_ns.route("/api/agent_webhook")
class AgentWebhook(Resource): class AgentWebhook(Resource):
@api.doc( @api.doc(

View File

@@ -1,3 +1,4 @@
# Please put appropriate value # 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 VITE_API_STREAMING=true

View File

@@ -110,7 +110,6 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
) )
.slice(0, 3 - pinnedAgents.length); .slice(0, 3 - pinnedAgents.length);
setRecentAgents([...pinnedAgents, ...additionalAgents]); setRecentAgents([...pinnedAgents, ...additionalAgents]);
console.log(additionalAgents);
} catch (error) { } catch (error) {
console.error('Failed to fetch recent agents: ', error); console.error('Failed to fetch recent agents: ', error);
} }
@@ -181,22 +180,37 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
.getConversation(index, token) .getConversation(index, token)
.then((response) => response.json()) .then((response) => response.json())
.then((data) => { .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(setConversation(data.queries));
dispatch( dispatch(
updateConversationId({ updateConversationId({
query: { conversationId: index }, 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));
}); });
}; };

View File

@@ -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<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(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 (
<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 ${
agent.status === 'published' ? 'cursor-pointer' : ''
}`}
onClick={handleCardClick}
>
<div
ref={menuRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(true);
}}
className="absolute right-4 top-4 z-10 cursor-pointer"
>
<img src={ThreeDots} alt="options" className="h-[19px] w-[19px]" />
{menuOptions && (
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="top-right"
offset={{ x: 0, y: 0 }}
/>
)}
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<img
src={agent.image ?? Robot}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">
(Draft)
</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-[#020617] dark:text-[#E0E0E0]"
>
{agent.name}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B] dark:text-sonic-silver-light">
{agent.description}
</p>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
onDelete ? onDelete(agent.id || '') : defaultDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
</div>
);
}

View File

@@ -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<AppDispatch>();
const [isDarkTheme] = useDarkTheme();
const token = useSelector(selectToken);
const queries = useSelector(selectQueries);
const status = useSelector(selectStatus);
const [sharedAgent, setSharedAgent] = useState<Agent>();
const [isLoading, setIsLoading] = useState(true);
const [input, setInput] = useState('');
const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false);
const fetchStream = useRef<any>(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 (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
);
if (!sharedAgent)
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col items-center justify-center gap-4">
<img
src={isDarkTheme ? NoFilesDarkIcon : NoFilesIcon}
alt="No agent found"
className="mx-auto mb-6 h-32 w-32"
/>
<p className="text-center text-lg text-[#71717A] dark:text-[#949494]">
No agent found. Please ensure the agent is shared.
</p>
</div>
</div>
);
return (
<div className="h-full w-full pt-10">
<div className="flex h-full w-full flex-col items-center justify-between">
<div className="flex w-full flex-col items-center overflow-y-auto">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
headerContent={
<div className="flex w-full items-center justify-center py-4">
<SharedAgentCard agent={sharedAgent} />
</div>
}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<MessageInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSubmit={() => handleQuestionSubmission()}
loading={status === 'loading'}
showSourceButton={sharedAgent ? false : true}
showToolButton={sharedAgent ? false : true}
autoFocus={false}
/>
<p className="w-full self-center bg-transparent pt-2 text-center text-xs text-gray-4000 dark:text-sonic-silver md:inline">
This is a preview of the agent. You can publish it to start using it
in conversations.
</p>
</div>
</div>
</div>
);
}
function SharedAgentCard({ agent }: { agent: Agent }) {
return (
<div className="flex max-w-[720px] flex-col rounded-3xl border border-dark-gray p-6 shadow-md dark:border-grey">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full p-1">
<img src={Robot} className="h-full w-full object-contain" />
</div>
<div className="flex max-h-[92px] w-[80%] flex-col gap-px">
<h2 className="text-lg font-semibold text-[#212121] dark:text-[#E0E0E0]">
{agent.name}
</h2>
<p className="overflow-y-auto text-wrap break-all text-sm text-[#71717A] dark:text-[#949494]">
{agent.description}
</p>
</div>
</div>
<div className="mt-4 flex items-center gap-8">
{agent.shared_metadata?.shared_by && (
<p className="text-sm font-light text-[#212121] dark:text-[#E0E0E0]">
by {agent.shared_metadata.shared_by}
</p>
)}
{agent.shared_metadata?.shared_at && (
<p className="text-sm font-light text-[#71717A] dark:text-[#949494]">
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,
})}
</p>
)}
</div>
<div className="mt-8">
<p className="font-semibold text-[#212121] dark:text-[#E0E0E0]">
Connected Tools
</p>
<div className="mt-2 flex flex-wrap gap-2">
{agent.tools.map((tool, index) => (
<span
key={index}
className="rounded-full bg-[#E0E0E0] px-3 py-1 text-xs font-light text-[#212121] dark:bg-[#4B5563] dark:text-[#E0E0E0]"
>
{tool}
</span>
))}
</div>
</div>
{/* <button className="mt-8 w-full rounded-xl bg-gradient-to-b from-violets-are-blue to-[#6e45c2] px-4 py-2 text-sm text-white shadow-lg transition duration-300 ease-in-out hover:shadow-xl">
Start using
</button> */}
</div>
);
}

View File

@@ -9,10 +9,15 @@ import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg'; import Pin from '../assets/pin.svg';
import Trash from '../assets/red-trash.svg'; import Trash from '../assets/red-trash.svg';
import Robot from '../assets/robot.svg'; import Robot from '../assets/robot.svg';
import Link from '../assets/link-gray.svg';
import ThreeDots from '../assets/three-dots.svg'; import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg'; import UnPin from '../assets/unpin.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu'; import ContextMenu, { MenuOption } from '../components/ContextMenu';
import Spinner from '../components/Spinner'; import Spinner from '../components/Spinner';
import {
setConversation,
updateConversationId,
} from '../conversation/conversationSlice';
import ConfirmationModal from '../modals/ConfirmationModal'; import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc'; import { ActiveState } from '../models/misc';
import { import {
@@ -24,6 +29,7 @@ import {
} from '../preferences/preferenceSlice'; } from '../preferences/preferenceSlice';
import AgentLogs from './AgentLogs'; import AgentLogs from './AgentLogs';
import NewAgent from './NewAgent'; import NewAgent from './NewAgent';
import SharedAgent from './SharedAgent';
import { Agent } from './types'; import { Agent } from './types';
export default function Agents() { export default function Agents() {
@@ -33,10 +39,26 @@ export default function Agents() {
<Route path="/new" element={<NewAgent mode="new" />} /> <Route path="/new" element={<NewAgent mode="new" />} />
<Route path="/edit/:agentId" element={<NewAgent mode="edit" />} /> <Route path="/edit/:agentId" element={<NewAgent mode="edit" />} />
<Route path="/logs/:agentId" element={<AgentLogs />} /> <Route path="/logs/:agentId" element={<AgentLogs />} />
<Route path="/shared/:agentId" element={<SharedAgent />} />
</Routes> </Routes>
); );
} }
const sectionConfig = {
user: {
title: 'By me',
description: 'Agents created or published by you',
showNewAgentButton: true,
emptyStateDescription: 'You dont 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() { function AgentsList() {
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -44,24 +66,47 @@ function AgentsList() {
const agents = useSelector(selectAgents); const agents = useSelector(selectAgents);
const selectedAgent = useSelector(selectSelectedAgent); const selectedAgent = useSelector(selectSelectedAgent);
const [loading, setLoading] = useState<boolean>(true); const [sharedAgents, setSharedAgents] = useState<Agent[]>([]);
const [loadingUserAgents, setLoadingUserAgents] = useState<boolean>(true);
const [loadingSharedAgents, setLoadingSharedAgents] = useState<boolean>(true);
const getAgents = async () => { const getAgents = async () => {
try { try {
setLoading(true); setLoadingUserAgents(true);
const response = await userService.getAgents(token); const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents'); if (!response.ok) throw new Error('Failed to fetch agents');
const data = await response.json(); const data = await response.json();
dispatch(setAgents(data)); dispatch(setAgents(data));
setLoading(false); setLoadingUserAgents(false);
} catch (error) { } catch (error) {
console.error('Error:', 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(() => { useEffect(() => {
getAgents(); getAgents();
getSharedAgents();
dispatch(setConversation([]));
dispatch(
updateConversationId({
query: { conversationId: null },
}),
);
if (selectedAgent) dispatch(setSelectedAgent(null)); if (selectedAgent) dispatch(setSelectedAgent(null));
}, [token]); }, [token]);
return ( return (
@@ -71,7 +116,7 @@ function AgentsList() {
</h1> </h1>
<p className="mt-5 text-[15px] text-[#71717A] dark:text-[#949494]"> <p className="mt-5 text-[15px] text-[#71717A] dark:text-[#949494]">
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>
{/* Premade agents section */} {/* Premade agents section */}
{/* <div className="mt-6"> {/* <div className="mt-6">
@@ -116,45 +161,91 @@ function AgentsList() {
))} ))}
</div> </div>
</div> */} </div> */}
<div className="mt-8 flex flex-col gap-4"> <AgentSection
<div className="flex w-full items-center justify-between"> agents={agents ?? []}
loading={loadingUserAgents}
section="user"
/>
<AgentSection
agents={sharedAgents ?? []}
loading={loadingSharedAgents}
section="shared"
/>
</div>
);
}
function AgentSection({
agents,
loading,
section,
}: {
agents: Agent[];
loading: boolean;
section: keyof typeof sectionConfig;
}) {
const navigate = useNavigate();
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]"> <h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Created by You {sectionConfig[section].title}
</h2> </h2>
<p className="text-[13px] text-[#71717A]">
{sectionConfig[section].description}
</p>
</div>
{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="rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')} onClick={() => navigate('/agents/new')}
> >
New Agent New Agent
</button> </button>
</div> )}
<div className="flex w-full flex-wrap gap-4"> </div>
{loading ? ( <div className="flex w-full flex-wrap gap-4">
<div className="flex h-72 w-full items-center justify-center"> {loading ? (
<Spinner /> <div className="flex h-72 w-full items-center justify-center">
</div> <Spinner />
) : agents && agents.length > 0 ? ( </div>
agents.map((agent) => ( ) : agents && agents.length > 0 ? (
<AgentCard key={agent.id} agent={agent} agents={agents} /> agents.map((agent) => (
)) <AgentCard
) : ( key={agent.id}
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]"> agent={agent}
<p>You dont have any created agents yet </p> agents={agents}
section={section}
/>
))
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>{sectionConfig[section].emptyStateDescription}</p>
{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="ml-2 rounded-full bg-purple-30 px-4 py-2 text-sm text-white hover:bg-violets-are-blue"
onClick={() => navigate('/agents/new')} onClick={() => navigate('/agents/new')}
> >
New Agent New Agent
</button> </button>
</div> )}
)} </div>
</div> )}
</div> </div>
</div> </div>
); );
} }
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 navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const token = useSelector(selectToken); const token = useSelector(selectToken);
@@ -180,57 +271,79 @@ function AgentCard({ agent, agents }: { agent: Agent; agents: Agent[] }) {
} }
}; };
const menuOptions: MenuOption[] = [ const menuOptionsConfig: Record<string, MenuOption[]> = {
{ user: [
icon: Monitoring, {
label: 'Logs', icon: Monitoring,
onClick: (e: SyntheticEvent) => { label: 'Logs',
e.stopPropagation(); onClick: (e: SyntheticEvent) => {
navigate(`/agents/logs/${agent.id}`); e.stopPropagation();
navigate(`/agents/logs/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
}, },
variant: 'primary', {
iconWidth: 14, icon: Edit,
iconHeight: 14, label: 'Edit',
}, onClick: (e: SyntheticEvent) => {
{ e.stopPropagation();
icon: Edit, navigate(`/agents/edit/${agent.id}`);
label: 'Edit', },
onClick: (e: SyntheticEvent) => { variant: 'primary',
e.stopPropagation(); iconWidth: 14,
navigate(`/agents/edit/${agent.id}`); iconHeight: 14,
}, },
variant: 'primary', {
iconWidth: 14, icon: agent.pinned ? UnPin : Pin,
iconHeight: 14, label: agent.pinned ? 'Unpin' : 'Pin agent',
}, onClick: (e: SyntheticEvent) => {
{ e.stopPropagation();
icon: agent.pinned ? UnPin : Pin, togglePin();
label: agent.pinned ? 'Unpin' : 'Pin agent', },
onClick: (e: SyntheticEvent) => { variant: 'primary',
e.stopPropagation(); iconWidth: 18,
togglePin(); iconHeight: 18,
}, },
variant: 'primary', {
iconWidth: 18, icon: Trash,
iconHeight: 18, label: 'Delete',
}, onClick: (e: SyntheticEvent) => {
{ e.stopPropagation();
icon: Trash, setDeleteConfirmation('ACTIVE');
label: 'Delete', },
onClick: (e: SyntheticEvent) => { variant: 'danger',
e.stopPropagation(); iconWidth: 13,
setDeleteConfirmation('ACTIVE'); iconHeight: 13,
}, },
variant: 'danger', ],
iconWidth: 13, shared: [
iconHeight: 13, {
}, 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 = () => { const handleClick = () => {
if (agent.status === 'published') { if (section === 'user') {
dispatch(setSelectedAgent(agent)); if (agent.status === 'published') {
navigate(`/`); dispatch(setSelectedAgent(agent));
navigate(`/`);
}
}
if (section === 'shared') {
navigate(`/agents/shared/${agent.shared_token}`);
} }
}; };

View File

@@ -13,6 +13,9 @@ export type Agent = {
key?: string; key?: string;
incoming_webhook_token?: string; incoming_webhook_token?: string;
pinned?: boolean; pinned?: boolean;
shared?: boolean;
shared_token?: string;
shared_metadata?: any;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
last_used_at?: string; last_used_at?: string;

View File

@@ -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 = { const defaultHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -15,6 +15,9 @@ const endpoints = {
DELETE_AGENT: (id: string) => `/api/delete_agent?id=${id}`, DELETE_AGENT: (id: string) => `/api/delete_agent?id=${id}`,
PINNED_AGENTS: '/api/pinned_agents', PINNED_AGENTS: '/api/pinned_agents',
TOGGLE_PIN_AGENT: (id: string) => `/api/pin_agent?id=${id}`, 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}`, AGENT_WEBHOOK: (id: string) => `/api/agent_webhook?id=${id}`,
PROMPTS: '/api/get_prompts', PROMPTS: '/api/get_prompts',
CREATE_PROMPT: '/api/create_prompt', CREATE_PROMPT: '/api/create_prompt',

View File

@@ -35,6 +35,12 @@ const userService = {
apiClient.get(endpoints.USER.PINNED_AGENTS, token), apiClient.get(endpoints.USER.PINNED_AGENTS, token),
togglePinAgent: (id: string, token: string | null): Promise<any> => togglePinAgent: (id: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token), apiClient.post(endpoints.USER.TOGGLE_PIN_AGENT(id), {}, token),
getSharedAgent: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.SHARED_AGENT(id), token),
getSharedAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.SHARED_AGENTS, token),
shareAgent: (data: any, token: string | null): Promise<any> =>
apiClient.put(endpoints.USER.SHARE_AGENT, data, token),
getAgentWebhook: (id: string, token: string | null): Promise<any> => getAgentWebhook: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token), apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token),
getPrompts: (token: string | null): Promise<any> => getPrompts: (token: string | null): Promise<any> =>

View File

@@ -0,0 +1,3 @@
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1.5H3C2.46957 1.5 1.96086 1.71071 1.58579 2.08579C1.21071 2.46086 1 2.96957 1 3.5V15.5C1 16.0304 1.21071 16.5391 1.58579 16.9142C1.96086 17.2893 2.46957 17.5 3 17.5H15C15.5304 17.5 16.0391 17.2893 16.4142 16.9142C16.7893 16.5391 17 16.0304 17 15.5V11.5M9 9.5L17 1.5M17 1.5V6.5M17 1.5H12" stroke="#949494" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@@ -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 { useTranslation } from 'react-i18next';
import ArrowDown from '../assets/arrow-down.svg'; import ArrowDown from '../assets/arrow-down.svg';
@@ -8,7 +15,12 @@ import { useDarkTheme } from '../hooks';
import ConversationBubble from './ConversationBubble'; import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels'; 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: { handleQuestion: (params: {
question: string; question: string;
isRetry?: boolean; isRetry?: boolean;
@@ -24,7 +36,8 @@ interface ConversationMessagesProps {
queries: Query[]; queries: Query[];
status: Status; status: Status;
showHeroOnEmpty?: boolean; showHeroOnEmpty?: boolean;
} headerContent?: ReactNode;
};
export default function ConversationMessages({ export default function ConversationMessages({
handleQuestion, handleQuestion,
@@ -33,22 +46,23 @@ export default function ConversationMessages({
status, status,
handleFeedback, handleFeedback,
showHeroOnEmpty = true, showHeroOnEmpty = true,
headerContent,
}: ConversationMessagesProps) { }: ConversationMessagesProps) {
const [isDarkTheme] = useDarkTheme(); const [isDarkTheme] = useDarkTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const conversationRef = useRef<HTMLDivElement>(null); const conversationRef = useRef<HTMLDivElement>(null);
const [hasScrolledToLast, setHasScrolledToLast] = useState(true); const [hasScrolledToLast, setHasScrolledToLast] = useState(true);
const [eventInterrupt, setEventInterrupt] = useState(false); const [userInterruptedScroll, setUserInterruptedScroll] = useState(false);
const handleUserInterruption = () => { const handleUserScrollInterruption = useCallback(() => {
if (!eventInterrupt && status === 'loading') { if (!userInterruptedScroll && status === 'loading') {
setEventInterrupt(true); setUserInterruptedScroll(true);
} }
}; }, [userInterruptedScroll, status]);
const scrollIntoView = () => { const scrollConversationToBottom = useCallback(() => {
if (!conversationRef?.current || eventInterrupt) return; if (!conversationRef.current || userInterruptedScroll) return;
if (status === 'idle' || !queries[queries.length - 1]?.response) { if (status === 'idle' || !queries[queries.length - 1]?.response) {
conversationRef.current.scrollTo({ conversationRef.current.scrollTo({
@@ -58,39 +72,65 @@ export default function ConversationMessages({
} else { } else {
conversationRef.current.scrollTop = conversationRef.current.scrollHeight; conversationRef.current.scrollTop = conversationRef.current.scrollHeight;
} }
}; }, [userInterruptedScroll, status, queries]);
const checkScroll = () => { const checkScrollPosition = useCallback(() => {
const el = conversationRef.current; const el = conversationRef.current;
if (!el) return; if (!el) return;
const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10; const isAtBottom =
setHasScrolledToLast(isBottom); el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
}; setHasScrolledToLast(isAtBottom);
}, [setHasScrolledToLast]);
useEffect(() => { useEffect(() => {
!eventInterrupt && scrollIntoView(); if (!userInterruptedScroll) {
}, [queries.length, queries[queries.length - 1]]); scrollConversationToBottom();
}
}, [
queries.length,
queries[queries.length - 1]?.response,
queries[queries.length - 1]?.error,
queries[queries.length - 1]?.thought,
userInterruptedScroll,
scrollConversationToBottom,
]);
useEffect(() => { useEffect(() => {
if (status === 'idle') { if (status === 'idle') {
setEventInterrupt(false); setUserInterruptedScroll(false);
} }
}, [status]); }, [status]);
useEffect(() => { useEffect(() => {
conversationRef.current?.addEventListener('scroll', checkScroll); const currentConversationRef = conversationRef.current;
currentConversationRef?.addEventListener('scroll', checkScrollPosition);
return () => { 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) { if (query.thought || query.response) {
responseView = ( return (
<ConversationBubble <ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'}`} className={bubbleMargin}
key={`${index}ANSWER`} key={`${index}-ANSWER`}
message={query.response} message={query.response}
type={'ANSWER'} type={'ANSWER'}
thought={query.thought} thought={query.thought}
@@ -104,75 +144,79 @@ export default function ConversationMessages({
} }
/> />
); );
} else if (query.error) { }
const retryBtn = (
if (query.error) {
const retryButton = (
<button <button
className="flex items-center justify-center gap-3 self-center rounded-full py-3 px-5 text-lg text-gray-500 transition-colors delay-100 hover:border-gray-500 disabled:cursor-not-allowed dark:text-bright-gray" className="flex items-center justify-center gap-3 self-center rounded-full px-5 py-3 text-lg text-gray-500 transition-colors delay-100 hover:border-gray-500 disabled:cursor-not-allowed dark:text-bright-gray"
disabled={status === 'loading'} disabled={status === 'loading'}
onClick={() => { onClick={() => {
const questionToRetry = queries[index].prompt;
handleQuestion({ handleQuestion({
question: queries[queries.length - 1].prompt, question: questionToRetry,
isRetry: true, isRetry: true,
indx: index,
}); });
}} }}
aria-label={t('Retry') || 'Retry'}
> >
<RetryIcon <RetryIcon {...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}
/>
</button> </button>
); );
responseView = ( return (
<ConversationBubble <ConversationBubble
className={`${index === queries.length - 1 ? 'mb-32' : 'mb-7'} `} className={bubbleMargin}
key={`${index}ERROR`} key={`${index}-ERROR`}
message={query.error} message={query.error}
type="ERROR" type="ERROR"
retryBtn={retryBtn} retryBtn={retryButton}
/> />
); );
} }
return responseView; return null;
}; };
return ( return (
<div <div
ref={conversationRef} ref={conversationRef}
onWheel={handleUserInterruption} onWheel={handleUserScrollInterruption}
onTouchMove={handleUserInterruption} onTouchMove={handleUserScrollInterruption}
className="flex justify-center w-full overflow-y-auto h-full sm:pt-12" className="flex h-full w-full justify-center overflow-y-auto sm:pt-12"
> >
{queries.length > 0 && !hasScrolledToLast && ( {queries.length > 0 && !hasScrolledToLast && (
<button <button
onClick={scrollIntoView} onClick={() => {
aria-label="scroll to bottom" setUserInterruptedScroll(false);
scrollConversationToBottom();
}}
aria-label={t('Scroll to bottom') || 'Scroll to bottom'}
className="fixed bottom-40 right-14 z-10 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-gray-alpha bg-gray-100 bg-opacity-50 dark:bg-gunmetal md:h-9 md:w-9 md:bg-opacity-100" className="fixed bottom-40 right-14 z-10 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-gray-alpha bg-gray-100 bg-opacity-50 dark:bg-gunmetal md:h-9 md:w-9 md:bg-opacity-100"
> >
<img <img
src={ArrowDown} src={ArrowDown}
alt="arrow down" alt=""
className="h-4 w-4 opacity-50 md:h-5 md:w-5 filter dark:invert" className="h-4 w-4 opacity-50 filter dark:invert md:h-5 md:w-5"
/> />
</button> </button>
)} )}
<div className="w-full md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12 max-w-[1300px] px-2"> <div className="w-full max-w-[1300px] px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
{headerContent && headerContent}
{queries.length > 0 ? ( {queries.length > 0 ? (
queries.map((query, index) => ( queries.map((query, index) => (
<Fragment key={index}> <Fragment key={`${index}-query-fragment`}>
<ConversationBubble <ConversationBubble
className={'first:mt-5'} className={index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''}
key={`${index}QUESTION`} key={`${index}-QUESTION`}
message={query.prompt} message={query.prompt}
type="QUESTION" type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission} handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index} questionNumber={index}
sources={query.sources} sources={query.sources}
/> />
{prepResponseView(query, index)} {renderResponseView(query, index)}
</Fragment> </Fragment>
)) ))
) : showHeroOnEmpty ? ( ) : showHeroOnEmpty ? (

View File

@@ -142,6 +142,7 @@ export function handleFetchAnswerSteaming(
.then((response) => { .then((response) => {
if (!response.body) throw Error('No response body'); if (!response.body) throw Error('No response body');
let buffer = '';
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
let counterrr = 0; let counterrr = 0;
@@ -157,22 +158,24 @@ export function handleFetchAnswerSteaming(
counterrr += 1; counterrr += 1;
const chunk = decoder.decode(value); 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) { for (let event of events) {
if (line.trim() == '') { if (event.trim().startsWith('data:')) {
continue; 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); reader.read().then(processStream).catch(reject);

View File

@@ -285,9 +285,7 @@ export const conversationSlice = createSlice({
action: PayloadAction<{ index: number; query: Partial<Query> }>, action: PayloadAction<{ index: number; query: Partial<Query> }>,
) { ) {
const { index, query } = action.payload; 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( updateQuery(
state, state,

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Agent } from '../agents/types'; import { Agent } from '../agents/types';
@@ -9,6 +9,8 @@ import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice'; import { selectToken } from '../preferences/preferenceSlice';
import WrapperModal from './WrapperModal'; import WrapperModal from './WrapperModal';
const baseURL = import.meta.env.VITE_BASE_URL;
type AgentDetailsModalProps = { type AgentDetailsModalProps = {
agent: Agent; agent: Agent;
mode: 'new' | 'edit' | 'draft'; mode: 'new' | 'edit' | 'draft';
@@ -24,7 +26,9 @@ export default function AgentDetailsModal({
}: AgentDetailsModalProps) { }: AgentDetailsModalProps) {
const token = useSelector(selectToken); const token = useSelector(selectToken);
const [publicLink, setPublicLink] = useState<string | null>(null); const [sharedToken, setSharedToken] = useState<string | null>(
agent.shared_token ?? null,
);
const [apiKey, setApiKey] = useState<string | null>(null); const [apiKey, setApiKey] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string | null>(null); const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
const [loadingStates, setLoadingStates] = useState({ const [loadingStates, setLoadingStates] = useState({
@@ -40,6 +44,21 @@ export default function AgentDetailsModal({
setLoadingStates((prev) => ({ ...prev, [key]: state })); 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 () => { const handleGenerateWebhook = async () => {
setLoading('webhook', true); setLoading('webhook', true);
const response = await userService.getAgentWebhook(agent.id ?? '', token); const response = await userService.getAgentWebhook(agent.id ?? '', token);
@@ -52,6 +71,11 @@ export default function AgentDetailsModal({
setLoading('webhook', false); setLoading('webhook', false);
}; };
useEffect(() => {
setSharedToken(agent.shared_token ?? null);
setApiKey(agent.key ?? null);
}, [agent]);
if (modalState !== 'ACTIVE') return null; if (modalState !== 'ACTIVE') return null;
return ( return (
<WrapperModal <WrapperModal
@@ -66,20 +90,45 @@ export default function AgentDetailsModal({
</h2> </h2>
<div className="mt-8 flex flex-col gap-6"> <div className="mt-8 flex flex-col gap-6">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray"> <div className="flex items-center gap-2">
Public link <h2 className="text-base font-semibold text-jet dark:text-bright-gray">
</h2> Public Link
<button className="hover:bg-vi</button>olets-are-blue w-28 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"> </h2>
Generate {sharedToken && (
</button> <div className="mb-1">
<CopyButton
textToCopy={`${baseURL}/agents/shared/${sharedToken}`}
padding="p-1"
/>
</div>
)}
</div>
{sharedToken ? (
<div className="flex flex-col flex-wrap items-start gap-2">
<p className="f break-all font-mono text-sm text-gray-700 dark:text-[#ECECF1]">
{`${baseURL}/agents/shared/${sharedToken}`}
</p>
</div>
) : (
<button
className="hover:bg-vi</button>olets-are-blue flex w-28 items-center justify-center 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"
onClick={handleGeneratePublicLink}
>
{loadingStates.publicLink ? (
<Spinner size="small" color="#976af3" />
) : (
'Generate'
)}
</button>
)}
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-jet dark:text-bright-gray"> <h2 className="text-base font-semibold text-jet dark:text-bright-gray">
API Key API Key
</h2> </h2>
{agent.key ? ( {apiKey ? (
<span className="font-mono text-sm text-gray-700 dark:text-[#ECECF1]"> <span className="font-mono text-sm text-gray-700 dark:text-[#ECECF1]">
{agent.key} {apiKey}
</span> </span>
) : ( ) : (
<button className="hover:bg-vi</button>olets-are-blue w-28 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"> <button className="hover:bg-vi</button>olets-are-blue w-28 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">