diff --git a/application/api/answer/routes.py b/application/api/answer/routes.py index ea8f6741..873a0ad7 100644 --- a/application/api/answer/routes.py +++ b/application/api/answer/routes.py @@ -203,19 +203,21 @@ def complete_stream( # send data.type = "end" to indicate that the stream has ended as json data = json.dumps({"type": "id", "id": str(conversation_id)}) yield f"data: {data}\n\n" - + retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "stream_answer", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "response": response_full, - "sources": source_log_docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "stream_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) data = json.dumps({"type": "end"}) yield f"data: {data}\n\n" @@ -281,8 +283,9 @@ def stream(): else: retriever_name = source["active_docs"] - current_app.logger.info(f"/stream - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + current_app.logger.info( + f"/stream - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) prompt = get_prompt(prompt_id) @@ -319,8 +322,9 @@ def stream(): mimetype="text/event-stream", ) except Exception as e: - current_app.logger.error(f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}", - extra={"error": str(e), "traceback": traceback.format_exc()} + current_app.logger.error( + f"/stream - error: {str(e)} - traceback: {traceback.format_exc()}", + extra={"error": str(e), "traceback": traceback.format_exc()}, ) message = e.args[0] status_code = 400 @@ -384,8 +388,9 @@ def api_answer(): prompt = get_prompt(prompt_id) - current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + current_app.logger.info( + f"/api/answer - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) retriever = RetrieverCreator.create_retriever( @@ -420,22 +425,25 @@ def api_answer(): conversation_id, question, response_full, source_log_docs, llm ) retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "api_answer", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "response": response_full, - "sources": source_log_docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "api_answer", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "response": response_full, + "sources": source_log_docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) return result except Exception as e: - current_app.logger.error(f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}", - extra={"error": str(e), "traceback": traceback.format_exc()} + current_app.logger.error( + f"/api/answer - error: {str(e)} - traceback: {traceback.format_exc()}", + extra={"error": str(e), "traceback": traceback.format_exc()}, ) return bad_request(500, str(e)) @@ -468,9 +476,10 @@ def api_search(): token_limit = data["token_limit"] else: token_limit = settings.DEFAULT_MAX_HISTORY - - current_app.logger.info(f"/api/answer - request_data: {data}, source: {source}", - extra={"data": json.dumps({"request_data": data, "source": source})} + + current_app.logger.info( + f"/api/answer - request_data: {data}, source: {source}", + extra={"data": json.dumps({"request_data": data, "source": source})}, ) retriever = RetrieverCreator.create_retriever( @@ -487,16 +496,18 @@ def api_search(): docs = retriever.search() retriever_params = retriever.get_params() - user_logs_collection.insert_one({ - "action": "api_search", - "level": "info", - "user": "local", - "api_key": user_api_key, - "date": datetime.datetime.utcnow(), - "question": question, - "sources": docs, - "retriever_params": retriever_params - }) + user_logs_collection.insert_one( + { + "action": "api_search", + "level": "info", + "user": "local", + "api_key": user_api_key, + "question": question, + "sources": docs, + "retriever_params": retriever_params, + "timestamp": datetime.datetime.now(datetime.timezone.utc), + } + ) if data.get("isNoneDoc"): for doc in docs: diff --git a/application/api/user/routes.py b/application/api/user/routes.py index dbe8a0b2..1b86135c 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -26,6 +26,7 @@ feedback_collection = db["feedback"] api_key_collection = db["api_keys"] token_usage_collection = db["token_usage"] shared_conversations_collections = db["shared_conversations"] +user_logs_collection = db["user_logs"] user = Blueprint("user", __name__) @@ -1127,3 +1128,62 @@ def get_feedback_analytics(): return jsonify({"success": False, "error": str(err)}), 400 return jsonify({"success": True, "feedback": daily_feedback}), 200 + + +@user.route("/api/get_user_logs", methods=["POST"]) +def get_user_logs(): + data = request.get_json() + page = int(data.get("page", 1)) + api_key_id = data.get("api_key_id") + page_size = int(data.get("page_size", 10)) + skip = (page - 1) * page_size + + try: + api_key = ( + api_key_collection.find_one({"_id": ObjectId(api_key_id)})["key"] + if api_key_id + else None + ) + except Exception as err: + print(err) + return jsonify({"success": False, "error": str(err)}), 400 + + query = {} + if api_key: + query = {"api_key": api_key} + items_cursor = ( + user_logs_collection.find(query) + .sort("timestamp", -1) + .skip(skip) + .limit(page_size + 1) + ) + items = list(items_cursor) + + results = [] + for item in items[:page_size]: + results.append( + { + "id": str(item.get("_id")), + "action": item.get("action"), + "level": item.get("level"), + "user": item.get("user"), + "question": item.get("question"), + "sources": item.get("sources"), + "retriever_params": item.get("retriever_params"), + "timestamp": item.get("timestamp"), + } + ) + has_more = len(items) > page_size + + return ( + jsonify( + { + "success": True, + "logs": results, + "page": page, + "page_size": page_size, + "has_more": has_more, + } + ), + 200, + ) diff --git a/frontend/public/fonts/IBMPlexMono-Medium.ttf b/frontend/public/fonts/IBMPlexMono-Medium.ttf new file mode 100644 index 00000000..39f178db Binary files /dev/null and b/frontend/public/fonts/IBMPlexMono-Medium.ttf differ diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 09ddd2f1..2b67a58c 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -15,6 +15,7 @@ const endpoints = { MESSAGE_ANALYTICS: '/api/get_message_analytics', TOKEN_ANALYTICS: '/api/get_token_analytics', FEEDBACK_ANALYTICS: '/api/get_feedback_analytics', + LOGS: `/api/get_user_logs`, }, CONVERSATION: { ANSWER: '/api/answer', diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index ba9d7bb8..6d228177 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -29,6 +29,8 @@ const userService = { apiClient.post(endpoints.USER.TOKEN_ANALYTICS, data), getFeedbackAnalytics: (data: any): Promise => apiClient.post(endpoints.USER.FEEDBACK_ANALYTICS, data), + getLogs: (data: any): Promise => + apiClient.post(endpoints.USER.LOGS, data), }; export default userService; diff --git a/frontend/src/assets/chevron-right.svg b/frontend/src/assets/chevron-right.svg new file mode 100644 index 00000000..1463b6f7 --- /dev/null +++ b/frontend/src/assets/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx index e28fdcaf..e13f9133 100644 --- a/frontend/src/components/CopyButton.tsx +++ b/frontend/src/components/CopyButton.tsx @@ -1,16 +1,24 @@ -import { useState } from 'react'; -import Copy from './../assets/copy.svg?react'; -import CheckMark from './../assets/checkmark.svg?react'; import copy from 'copy-to-clipboard'; +import { useState } from 'react'; -export default function CoppyButton({ text }: { text: string }) { +import CheckMark from '../assets/checkmark.svg?react'; +import Copy from '../assets/copy.svg?react'; + +export default function CoppyButton({ + text, + colorLight, + colorDark, +}: { + text: string; + colorLight?: string; + colorDark?: string; +}) { const [copied, setCopied] = useState(false); const [isCopyHovered, setIsCopyHovered] = useState(false); const handleCopyClick = (text: string) => { copy(text); setCopied(true); - // Reset copied to false after a few seconds setTimeout(() => { setCopied(false); }, 3000); @@ -20,8 +28,8 @@ export default function CoppyButton({ text }: { text: string }) {
{copied ? ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 025059ac..2009ec9c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -420,6 +420,12 @@ template { src: url('/fonts/Inter-Variable.ttf'); } +@font-face { + font-family: 'IBMPlexMono-Medium'; + font-weight: 500; + src: url('/fonts/IBMPlexMono-Medium.ttf'); +} + ::-webkit-scrollbar { width: 0; } @@ -461,3 +467,7 @@ input:-webkit-autofill:focus { -webkit-box-orient: vertical; text-overflow: ellipsis; } + +.logs-table { + font-family: 'IBMPlexMono-Medium', system-ui; +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7361d212..645703a2 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "Analytics" + }, + "logs": { + "label": "Logs" } }, "modals": { diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 293c4117..49aa5d53 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "Analítica" + }, + "logs": { + "label": "Registros" } }, "modals": { diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 99fa85dd..9e367330 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "分析" + }, + "logs": { + "label": "ログ" } }, "modals": { diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index cbd82056..81eff996 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -65,6 +65,9 @@ }, "analytics": { "label": "分析" + }, + "logs": { + "label": "日志" } }, "modals": { diff --git a/frontend/src/settings/Logs.tsx b/frontend/src/settings/Logs.tsx new file mode 100644 index 00000000..58ab930d --- /dev/null +++ b/frontend/src/settings/Logs.tsx @@ -0,0 +1,175 @@ +import React from 'react'; + +import userService from '../api/services/userService'; +import ChevronRight from '../assets/chevron-right.svg'; +import Dropdown from '../components/Dropdown'; +import { APIKeyData, LogData } from './types'; +import CoppyButton from '../components/CopyButton'; + +export default function Logs() { + const [chatbots, setChatbots] = React.useState([]); + const [selectedChatbot, setSelectedChatbot] = + React.useState(); + const [logs, setLogs] = React.useState([]); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(true); + + const fetchChatbots = async () => { + try { + const response = await userService.getAPIKeys(); + if (!response.ok) { + throw new Error('Failed to fetch Chatbots'); + } + const chatbots = await response.json(); + setChatbots(chatbots); + } catch (error) { + console.error(error); + } + }; + + const fetchLogs = async () => { + try { + const response = await userService.getLogs({ + page: page, + api_key_id: selectedChatbot?.id, + page_size: 10, + }); + if (!response.ok) { + throw new Error('Failed to fetch logs'); + } + const olderLogs = await response.json(); + setLogs([...logs, ...olderLogs.logs]); + setHasMore(olderLogs.has_more); + } catch (error) { + console.error(error); + } + }; + + React.useEffect(() => { + fetchChatbots(); + }, []); + + React.useEffect(() => { + if (hasMore) fetchLogs(); + }, [page, selectedChatbot]); + return ( +
+
+
+

+ Filter by chatbot +

+ ({ + label: chatbot.name, + value: chatbot.id, + })), + { label: 'None', value: '' }, + ]} + placeholder="Select chatbot" + onSelect={(chatbot: { label: string; value: string }) => { + setSelectedChatbot( + chatbots.find((item) => item.id === chatbot.value), + ); + setLogs([]); + setPage(1); + setHasMore(true); + }} + selectedValue={ + (selectedChatbot && { + label: selectedChatbot.name, + value: selectedChatbot.id, + }) || + null + } + rounded="3xl" + border="border" + /> +
+
+
+ +
+
+ ); +} + +type LogsTableProps = { + logs: LogData[]; + setPage: React.Dispatch>; +}; + +function LogsTable({ logs, setPage }: LogsTableProps) { + const observerRef = React.useRef(); + const firstObserver = React.useCallback((node: HTMLDivElement) => { + if (observerRef.current) { + observerRef.current = new IntersectionObserver((enteries) => { + if (enteries[0].isIntersecting) setPage((prev) => prev + 1); + }); + } + if (node && observerRef.current) observerRef.current.observe(node); + }, []); + return ( +
+
+

+ API generated / chatbot conversations +

+
+
+ {logs.map((log, index) => { + if (index === logs.length - 1) { + return ( +
+ +
+ ); + } else return ; + })} +
+
+ ); +} + +function Log({ log }: { log: LogData }) { + const logLevelColor = { + info: 'text-green-500', + error: 'text-red-500', + warning: 'text-yellow-500', + }; + const { id, action, timestamp, ...filteredLog } = log; + return ( +
+ + chevron-right + +

{`${log.timestamp}`}

+

{`[${log.action}]`}

+

{`${log.question}`}

+
+
+
+

+ {JSON.stringify(filteredLog, null, 2)} +

+
+ +
+
+
+ ); +} diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index bffe9f7a..47b5f155 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -15,6 +15,7 @@ import Analytics from './Analytics'; import APIKeys from './APIKeys'; import Documents from './Documents'; import General from './General'; +import Logs from './Logs'; import Widgets from './Widgets'; export default function Settings() { @@ -25,6 +26,7 @@ export default function Settings() { t('settings.documents.label'), t('settings.apiKeys.label'), t('settings.analytics.label'), + t('settings.logs.label'), ]; const [activeTab, setActiveTab] = React.useState(t('settings.general.label')); const [widgetScreenshot, setWidgetScreenshot] = React.useState( @@ -133,6 +135,8 @@ export default function Settings() { return ; case t('settings.analytics.label'): return ; + case t('settings.logs.label'): + return ; default: return null; } diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 7c04dab9..52a58f23 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -6,3 +6,15 @@ export type APIKeyData = { prompt_id: string; chunks: string; }; + +export type LogData = { + id: string; + action: string; + level: 'info' | 'error' | 'warning'; + user: string; + question: string; + response: string; + sources: Record[]; + retriever_params: Record; + timestamp: string; +};