@aptos-labs/ai-chatbot-client
Version:
Aptos AI Developer Assistant - A React-based chatbot interface for the Aptos Developer Assistant API
660 lines (656 loc) • 20 kB
JavaScript
import { createContext, useRef, useState, useCallback, useEffect, useContext } from 'react';
import { v4 } from 'uuid';
import { jsx } from 'react/jsx-runtime';
// src/types.ts
var RagProvider = /* @__PURE__ */ ((RagProvider2) => {
RagProvider2["DEVELOPER_DOCS"] = "developer-docs";
RagProvider2["APTOS_LEARN"] = "aptos-learn";
return RagProvider2;
})(RagProvider || {});
// src/client/index.ts
var ChatbotClient = class {
constructor(config) {
this.config = config;
}
// Chat Management
async createChat() {
const clientId = this.config.clientId;
if (!clientId) {
throw new Error("Client ID not found. Please ensure it is set in config.");
}
return {
id: null,
title: "New Chat",
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
messages: [],
metadata: {
client_id: clientId,
rag_provider: this.config.ragProvider
}
};
}
async getChat(chatId) {
const response = await this.fetch(`${this.config.apiUrl}/api/chat/history/${chatId}`);
return response.json();
}
async listChats() {
const clientId = this.config.clientId;
if (!clientId) {
throw new Error("Client ID not found. Please ensure it is set in config.");
}
const url = `${this.config.apiUrl}/api/chat/histories?client_id=${encodeURIComponent(clientId)}`;
const response = await this.fetch(url);
const data = await response.json();
if (!data || !data.histories) {
throw new Error("Invalid response format from server");
}
return data.histories;
}
async deleteChat(chatId) {
await this.fetch(`${this.config.apiUrl}/api/chat/history/${chatId}`, {
method: "DELETE"
});
}
async updateChatTitle(chatId, title) {
await this.fetch(`${this.config.apiUrl}/api/chat/history/${chatId}`, {
method: "PATCH",
body: JSON.stringify({ title })
});
}
// Message Operations
async sendMessage(chatId, content, options) {
const clientId = this.config.clientId;
if (!clientId) {
throw new Error("Client ID not found. Please ensure it is set in config.");
}
const request = {
content,
client_id: clientId,
role: "user",
temperature: 0.7,
rag_provider: this.config.ragProvider || "developer-docs",
use_multi_step: !this.config.fastMode,
...options?.messageId && { message_id: options.messageId },
...chatId && { chat_id: chatId },
...options?.sharedChatId && { shared_chat_id: options.sharedChatId }
};
const response = await this.fetch(`${this.config.apiUrl}/api/message/stream`, {
method: "POST",
body: JSON.stringify(request),
headers: {
Accept: "text/plain",
// Accept plain text for streaming
"Content-Type": "application/json"
},
signal: options?.signal
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(
`API request failed: ${response.status} ${response.statusText}
${errorText}`
);
}
return response;
}
async getMessages(chatId, before) {
const url = new URL(`${this.config.apiUrl}/api/chat/history/${chatId}`);
if (before) {
url.searchParams.set("before", before);
}
const response = await this.fetch(url.toString());
return response.json();
}
async provideFeedback(messageId, feedback) {
await this.fetch(`${this.config.apiUrl}/api/feedback`, {
method: "POST",
body: JSON.stringify({
message_id: messageId,
feedback,
client_id: this.config.clientId
})
});
}
async deleteFeedback(messageId) {
await this.fetch(
`${this.config.apiUrl}/api/feedback/${messageId}?client_id=${this.config.clientId}`,
{
method: "DELETE"
}
);
}
// Shared Chat Operations
async shareChat(chatId, options) {
const clientId = this.config.clientId;
if (!clientId) {
throw new Error("Client ID not found. Please ensure it is set in config.");
}
const request = {
client_id: clientId,
...options?.title && { title: options.title },
...options?.expiresInHours && { expires_in_hours: options.expiresInHours }
};
const response = await this.fetch(`${this.config.apiUrl}/api/chat/share/${chatId}`, {
method: "POST",
body: JSON.stringify(request)
});
return response.json();
}
async getSharedChat(shareId) {
const response = await this.fetch(`${this.config.apiUrl}/api/chat/shared/${shareId}`);
return response.json();
}
updateConfig(newConfig) {
this.config = {
...this.config,
...newConfig
};
}
getConfig() {
return { ...this.config };
}
// Basic fetch with API key
async fetch(url, options = {}) {
const headers = new Headers({
"Content-Type": "application/json",
...this.config.apiKey && { Authorization: `Bearer ${this.config.apiKey}` },
...options.headers
});
const response = await fetch(url, {
...options,
headers
});
if (!response.ok) {
if (response.status === 429) {
throw new Error("RATE_LIMIT_EXCEEDED");
}
throw new Error(`API request failed: ${response.statusText}`);
}
return response;
}
};
var DEFAULT_CONTEXT = {
isLoading: false,
error: null,
isLoadingChats: false,
isLoadingMoreMessages: false,
currentChatId: null,
chats: [],
messages: [],
isGenerating: false,
isTyping: false,
hasMoreMessages: false,
fastMode: false,
isOpen: false,
openChat: () => {
},
closeChat: () => {
},
createNewChat: async () => {
},
selectChat: () => {
},
deleteChat: async () => {
},
updateChatTitle: async () => {
},
sendMessage: async () => {
},
stopGenerating: () => {
},
retryLastMessage: async () => {
},
clearHistory: () => {
},
copyMessage: () => {
},
provideFeedback: async () => {
},
loadPreviousMessages: async () => {
},
loadChats: async () => {
},
config: { apiKey: "", apiUrl: "" },
updateConfig: () => {
},
setFastMode: () => {
},
shareChat: async () => ({ share_id: "", share_url: "" }),
getSharedChat: async () => ({ share_id: "", title: "", messages: [], created_at: "" }),
loadSharedChat: async () => {
},
isSharedChatMode: false,
sharedChatId: null
};
var ChatbotContext = createContext(DEFAULT_CONTEXT);
var updateChatMessages = (chats, chatId, messages) => {
return chats.map((chat) => {
if (chat.id === chatId) {
return { ...chat, messages, lastMessage: messages[messages.length - 1]?.content };
}
return chat;
});
};
var ChatbotProvider = ({
config: initialConfig,
children,
onError
}) => {
const clientRef = useRef(new ChatbotClient(initialConfig));
const [isLoading, setIsLoading] = useState(false);
const [isLoadingChats, setIsLoadingChats] = useState(false);
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
const [error, setError] = useState(null);
const [currentChatId, setCurrentChatId] = useState(null);
const [chats, setChats] = useState([]);
const [messages, setMessages] = useState([]);
const [isGenerating, setIsGenerating] = useState(false);
const [isTyping, setIsTyping] = useState(false);
const [hasMoreMessages, setHasMoreMessages] = useState(false);
const [fastMode, setFastMode] = useState(initialConfig.fastMode ?? false);
const [isOpen, setIsOpen] = useState(false);
const [config, setConfig] = useState(initialConfig);
const [isSharedChatMode, setIsSharedChatMode] = useState(false);
const [sharedChatId, setSharedChatId] = useState(null);
const abortControllerRef = useRef(null);
const handleError = useCallback(
(err) => {
console.error("Chatbot error:", err);
if (err.message === "RATE_LIMIT_EXCEEDED") {
config.onRateLimitExceeded?.();
window.dispatchEvent(new CustomEvent("rateLimitExceeded"));
}
setError(err);
onError?.(err);
},
[config, onError]
);
useEffect(() => {
const handleRateLimit = () => {
config.onRateLimitExceeded?.();
};
window.addEventListener("rateLimitExceeded", handleRateLimit);
return () => window.removeEventListener("rateLimitExceeded", handleRateLimit);
}, [config]);
const updateConfig = useCallback((newConfig) => {
setConfig((prev) => {
const updated = { ...prev, ...newConfig };
clientRef.current.updateConfig(updated);
return updated;
});
}, []);
const loadChats = useCallback(async () => {
try {
setIsLoadingChats(true);
const response = await clientRef.current.listChats();
setChats(response);
} catch (err) {
handleError(err);
setChats([]);
} finally {
setIsLoadingChats(false);
}
}, [handleError]);
useEffect(() => {
const storedClientId = localStorage.getItem("clientId");
if (storedClientId) {
updateConfig({ clientId: storedClientId });
} else {
const newClientId = v4();
localStorage.setItem("clientId", newClientId);
updateConfig({ clientId: newClientId });
}
}, [updateConfig]);
useEffect(() => {
if (config.clientId) {
loadChats();
}
}, [config.clientId, loadChats]);
const openChat = useCallback(() => setIsOpen(true), []);
const closeChat = useCallback(() => setIsOpen(false), []);
const sendMessage = useCallback(
async (content) => {
const clientId = config.clientId;
if (!clientId) {
console.error("No clientId available yet");
setError(new Error("Client ID not initialized. Please try again."));
onError?.(new Error("Client ID not initialized. Please try again."));
return;
}
try {
setIsGenerating(true);
setIsLoading(true);
const messageId = `msg-${v4()}`;
const message = {
id: messageId,
content,
role: "user",
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
setMessages((prev) => [...prev, message]);
if (currentChatId) {
setChats((prev) => updateChatMessages(prev, currentChatId, [...messages, message]));
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await clientRef.current.sendMessage(currentChatId, content, {
messageId,
signal: abortController.signal,
...isSharedChatMode && sharedChatId && !currentChatId && { sharedChatId }
});
const headerChatId = response.headers.get("X-Chat-ID");
if (headerChatId && !currentChatId) {
setCurrentChatId(headerChatId);
if (isSharedChatMode) {
setIsSharedChatMode(false);
setSharedChatId(null);
}
await loadChats();
}
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let responseText = "";
let assistantMessageId = `msg-${v4()}`;
let isFirstChunk = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
responseText += chunk;
if (isFirstChunk) {
setIsGenerating(false);
setIsTyping(true);
const assistantMessage = {
id: assistantMessageId,
content: responseText,
role: "assistant",
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
setMessages((prev) => [...prev, assistantMessage]);
if (currentChatId) {
setChats(
(prev) => updateChatMessages(prev, currentChatId, [...messages, message, assistantMessage])
);
}
isFirstChunk = false;
} else {
setMessages(
(prev) => prev.map(
(msg) => msg.id === assistantMessageId ? { ...msg, content: responseText } : msg
)
);
if (currentChatId) {
setChats(
(prev) => prev.map(
(chat) => chat.id === currentChatId ? {
...chat,
messages: chat.messages.map(
(msg) => msg.id === assistantMessageId ? { ...msg, content: responseText } : msg
),
lastMessage: responseText
} : chat
)
);
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
console.log("Request aborted");
} else {
console.error("Error sending message:", err);
const error2 = err;
setError(error2);
onError?.(error2);
}
} finally {
setIsGenerating(false);
setIsTyping(false);
setIsLoading(false);
abortControllerRef.current = null;
}
},
[config.clientId, currentChatId, messages, loadChats, onError]
);
const stopGenerating = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsGenerating(false);
setIsTyping(false);
}, []);
const retryLastMessage = useCallback(async () => {
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
if (lastUserMessage) {
await sendMessage(lastUserMessage.content);
}
}, [messages, sendMessage]);
const clearHistory = useCallback(() => {
setMessages([]);
setCurrentChatId(null);
}, []);
const copyMessage = useCallback(
(messageId) => {
const message = messages.find((m) => m.id === messageId);
if (message) {
navigator.clipboard.writeText(message.content);
}
},
[messages]
);
const provideFeedback = useCallback(
async (messageId, feedback) => {
try {
const existingMessage = messages.find((msg) => msg.id === messageId);
if (existingMessage?.feedback === feedback) {
await clientRef.current.deleteFeedback(messageId);
setMessages(
(prev) => prev.map((msg) => msg.id === messageId ? { ...msg, feedback: void 0 } : msg)
);
} else {
await clientRef.current.provideFeedback(messageId, feedback);
setMessages((prev) => prev.map((msg) => msg.id === messageId ? { ...msg, feedback } : msg));
}
} catch (err) {
const error2 = err;
setError(error2);
onError?.(error2);
}
},
[messages, onError]
);
const loadPreviousMessages = useCallback(async () => {
if (!currentChatId || isLoadingMoreMessages) return;
try {
setIsLoadingMoreMessages(true);
const oldestMessage = messages[0];
const response = await clientRef.current.getMessages(currentChatId, oldestMessage?.id);
setMessages((prev) => [...response.messages, ...prev]);
setHasMoreMessages(response.hasMore);
} catch (err) {
console.error("Error loading previous messages:", err);
const error2 = err;
setError(error2);
onError?.(error2);
} finally {
setIsLoadingMoreMessages(false);
}
}, [currentChatId, isLoadingMoreMessages, messages, onError]);
const createNewChat = useCallback(async () => {
try {
const newChat = await clientRef.current.createChat();
setChats((prev) => [newChat, ...prev]);
setCurrentChatId(newChat.id);
setMessages([]);
} catch (err) {
console.error("Error creating new chat:", err);
const error2 = err;
setError(error2);
onError?.(error2);
}
}, [onError]);
const selectChat = useCallback(
async (chatId) => {
try {
setIsLoading(true);
const chat = await clientRef.current.getChat(chatId);
setCurrentChatId(chatId);
setMessages(chat.messages || []);
} catch (err) {
console.error("Error selecting chat:", err);
const error2 = err;
setError(error2);
onError?.(error2);
} finally {
setIsLoading(false);
}
},
[onError]
);
const deleteChat = useCallback(
async (chatId) => {
try {
await clientRef.current.deleteChat(chatId);
setChats((prev) => prev.filter((chat) => chat.id !== chatId));
if (currentChatId === chatId) {
setCurrentChatId(null);
setMessages([]);
}
} catch (err) {
console.error("Error deleting chat:", err);
const error2 = err;
setError(error2);
onError?.(error2);
}
},
[currentChatId, onError]
);
const updateChatTitle = useCallback(
async (chatId, title) => {
try {
await clientRef.current.updateChatTitle(chatId, title);
setChats((prev) => prev.map((chat) => chat.id === chatId ? { ...chat, title } : chat));
} catch (err) {
console.error("Error updating chat title:", err);
const error2 = err;
setError(error2);
onError?.(error2);
}
},
[onError]
);
const shareChat = useCallback(
async (chatId, options) => {
try {
return await clientRef.current.shareChat(chatId, options);
} catch (err) {
const error2 = err;
setError(error2);
onError?.(error2);
throw error2;
}
},
[onError]
);
const getSharedChat = useCallback(
async (shareId) => {
try {
return await clientRef.current.getSharedChat(shareId);
} catch (err) {
const error2 = err;
setError(error2);
onError?.(error2);
throw error2;
}
},
[onError]
);
const loadSharedChat = useCallback(
async (shareId) => {
try {
setIsLoading(true);
const sharedChat = await clientRef.current.getSharedChat(shareId);
setIsSharedChatMode(true);
setSharedChatId(shareId);
setCurrentChatId(null);
setMessages(sharedChat.messages);
} catch (err) {
const error2 = err;
setError(error2);
onError?.(error2);
throw error2;
} finally {
setIsLoading(false);
}
},
[onError]
);
const setFastModeWithConfig = useCallback(
(enabled) => {
setFastMode(enabled);
updateConfig({ fastMode: enabled });
},
[updateConfig]
);
const contextValue = {
isLoading,
error,
isLoadingChats,
isLoadingMoreMessages,
currentChatId,
chats,
messages,
isGenerating,
isTyping,
hasMoreMessages,
fastMode,
isOpen,
openChat,
closeChat,
createNewChat,
selectChat,
deleteChat,
updateChatTitle,
sendMessage,
stopGenerating,
retryLastMessage,
clearHistory,
copyMessage,
provideFeedback,
loadPreviousMessages,
loadChats,
config,
updateConfig,
setFastMode: setFastModeWithConfig,
shareChat,
getSharedChat,
loadSharedChat,
isSharedChatMode,
sharedChatId
};
return /* @__PURE__ */ jsx(ChatbotContext.Provider, { value: contextValue, children });
};
function useChatbot() {
const context = useContext(ChatbotContext);
if (!context) {
throw new Error("useChatbot must be used within a ChatbotProvider");
}
return context;
}
function useChatHistory() {
const context = useContext(ChatbotContext);
if (!context) {
throw new Error("useChatHistory must be used within a ChatbotProvider");
}
return {
chats: context.chats,
createNewChat: context.createNewChat,
selectChat: context.selectChat,
deleteChat: context.deleteChat,
updateChatTitle: context.updateChatTitle
};
}
export { ChatbotClient, ChatbotContext, ChatbotProvider, RagProvider, useChatHistory, useChatbot };