Login Required
Please log in to chat with the AI assistant about C# programming.
@@ -121,11 +133,7 @@ const {
-
Rate Limit Reached
-
Authentication Required
-
Verification Required
-
Invalid Input
-
Error
+
{{ getErrorHeading(message.errorType) }}
Please wait before sending another message
@@ -158,7 +166,7 @@ const {
-
diff --git a/EssentialCSharp.Web/src/composables/useSiteShell.js b/EssentialCSharp.Web/src/composables/useSiteShell.js
index bcc87ca7..bf00172b 100644
--- a/EssentialCSharp.Web/src/composables/useSiteShell.js
+++ b/EssentialCSharp.Web/src/composables/useSiteShell.js
@@ -62,7 +62,10 @@ export function useSiteShell() {
const expandedTocs = reactive(new Set());
const percentComplete = ref(window.PERCENT_COMPLETE);
const buildLabel = window.BUILD_LABEL ?? null;
- const enableChatWidget = Boolean(window.ENABLE_CHAT_WIDGET);
+ const chatWidget = window.CHAT_WIDGET ?? {};
+ const enableChatWidget = Boolean(chatWidget.enabled);
+ const chatWidgetAvailable = Boolean(chatWidget.available);
+ const chatWidgetUnavailableMessage = chatWidget.unavailableMessage ?? null;
const { width: windowWidth } = useWindowSize();
let snackbarTimeoutId = null;
@@ -270,6 +273,8 @@ export function useSiteShell() {
percentComplete,
buildLabel,
enableChatWidget,
+ chatWidgetAvailable,
+ chatWidgetUnavailableMessage,
smallScreen,
currentPage,
chapterParentPage,
diff --git a/EssentialCSharp.Web/wwwroot/css/chat-widget.css b/EssentialCSharp.Web/wwwroot/css/chat-widget.css
index 9712c397..a13d4491 100644
--- a/EssentialCSharp.Web/wwwroot/css/chat-widget.css
+++ b/EssentialCSharp.Web/wwwroot/css/chat-widget.css
@@ -127,6 +127,19 @@
animation: subtlePulse 2s ease-in-out infinite;
}
+.chat-button--unavailable {
+ background: linear-gradient(135deg, #757575 0%, #616161 50%, #424242 100%);
+}
+
+.chat-button--unavailable:hover {
+ background: linear-gradient(135deg, #6d6d6d 0%, #575757 50%, #383838 100%);
+ box-shadow:
+ 0 5px 5px -3px rgba(0,0,0,.2),
+ 0 8px 10px 1px rgba(0,0,0,.14),
+ 0 3px 14px 2px rgba(0,0,0,.12),
+ 0 0 20px rgba(97, 97, 97, 0.2);
+}
+
@keyframes subtlePulse {
0%, 100% {
box-shadow:
diff --git a/EssentialCSharp.Web/wwwroot/js/chat-module.js b/EssentialCSharp.Web/wwwroot/js/chat-module.js
index 1305b354..86754e02 100644
--- a/EssentialCSharp.Web/wwwroot/js/chat-module.js
+++ b/EssentialCSharp.Web/wwwroot/js/chat-module.js
@@ -1,94 +1,211 @@
// Chat Module - Vue.js composable for AI chat functionality
import DOMPurify from "dompurify";
import { marked } from "marked";
-import { ref, nextTick, watch, onMounted, onUnmounted } from "vue";
+import { ref, nextTick, watch } from "vue";
+
+const CHAT_HISTORY_KEY = "aiChatHistory";
+const CHAT_HISTORY_RETENTION_DAYS = 7;
+const MAX_MESSAGE_LENGTH = 500;
+const MAX_SAVED_MESSAGES = 100;
+const MINIMAL_SAVED_MESSAGES = 20;
+const CAPTCHA_SCRIPT_TIMEOUT_MS = 10000;
+const CAPTCHA_TIMEOUT_MS = 15000;
+const CHAT_WIDGET = window.CHAT_WIDGET ?? {};
+
+const DEFAULT_ERROR_DISPLAY = {
+ heading: "Error",
+ className: "error-message",
+ iconClass: "fas fa-exclamation-triangle"
+};
+
+const ERROR_DISPLAY = {
+ "auth-error": {
+ ...DEFAULT_ERROR_DISPLAY,
+ heading: "Authentication Required",
+ iconClass: "fas fa-lock"
+ },
+ "captcha-error": {
+ ...DEFAULT_ERROR_DISPLAY,
+ heading: "Verification Required"
+ },
+ "validation-error": {
+ ...DEFAULT_ERROR_DISPLAY,
+ heading: "Invalid Input",
+ iconClass: "fas fa-exclamation-circle"
+ },
+ "chat-unavailable": {
+ ...DEFAULT_ERROR_DISPLAY,
+ heading: "AI Chat Unavailable",
+ iconClass: "fas fa-robot"
+ },
+ "rate-limit": {
+ ...DEFAULT_ERROR_DISPLAY,
+ heading: "Rate Limit Reached",
+ className: "rate-limit-error",
+ iconClass: "fas fa-clock"
+ },
+ "network-error": {
+ ...DEFAULT_ERROR_DISPLAY,
+ iconClass: "fas fa-wifi"
+ },
+ "connection-error": {
+ ...DEFAULT_ERROR_DISPLAY,
+ iconClass: "fas fa-plug"
+ }
+};
+
+function nowIso() {
+ return new Date().toISOString();
+}
+
+function createChatError(errorType, message) {
+ const error = new Error(message);
+ error.chatErrorType = errorType;
+ return error;
+}
export function useChatWidget() {
// Authentication state
const isAuthenticated = ref(window.IS_AUTHENTICATED || false);
-
+
// Chat state with persistence
const showChatDialog = ref(false);
const chatMessages = ref([]);
- const chatInput = ref('');
+ const chatInput = ref("");
const isTyping = ref(false);
+ const isSubmitting = ref(false);
const chatMessagesEl = ref(null);
const chatInputField = ref(null);
const lastResponseId = ref(null);
+ // hCaptcha invisible widget state
+ const captchaContainerEl = ref(null);
+ let captchaWidgetId = null;
+ let captchaResolve = null;
+ let captchaReject = null;
+
+ function resetConversationState() {
+ chatMessages.value = [];
+ lastResponseId.value = null;
+ }
+
+ function buildChatHistoryData(messages) {
+ return {
+ messages,
+ lastResponseId: lastResponseId.value,
+ timestamp: Date.now()
+ };
+ }
+
+ function readSavedChatHistory() {
+ const saved = localStorage.getItem(CHAT_HISTORY_KEY);
+ return saved ? JSON.parse(saved) : null;
+ }
+
+ function saveChatHistorySnapshot(messages) {
+ localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(buildChatHistoryData(messages)));
+ }
+
// Load chat history from localStorage on initialization
function loadChatHistory() {
try {
- const saved = localStorage.getItem('aiChatHistory');
- if (saved) {
- const data = JSON.parse(saved);
+ const data = readSavedChatHistory();
+ if (data) {
chatMessages.value = data.messages || [];
lastResponseId.value = data.lastResponseId || null;
}
} catch (error) {
- console.warn('Failed to load chat history:', error);
+ console.warn("Failed to load chat history:", error);
}
}
// Save chat history to localStorage with message limits
function saveChatHistory() {
try {
- // Limit messages to prevent memory issues (keep last 100 messages)
- const maxMessages = 100;
- const messagesToSave = chatMessages.value.slice(-maxMessages);
-
- const data = {
- messages: messagesToSave,
- lastResponseId: lastResponseId.value,
- timestamp: Date.now()
- };
- localStorage.setItem('aiChatHistory', JSON.stringify(data));
+ saveChatHistorySnapshot(chatMessages.value.slice(-MAX_SAVED_MESSAGES));
} catch (error) {
- console.warn('Failed to save chat history:', error);
- // If localStorage is full, try clearing and saving only recent messages
+ console.warn("Failed to save chat history:", error);
+
try {
- const recentMessages = chatMessages.value.slice(-20);
- const fallbackData = {
- messages: recentMessages,
- lastResponseId: lastResponseId.value,
- timestamp: Date.now()
- };
- localStorage.setItem('aiChatHistory', JSON.stringify(fallbackData));
+ saveChatHistorySnapshot(chatMessages.value.slice(-MINIMAL_SAVED_MESSAGES));
} catch (fallbackError) {
- console.error('Failed to save even minimal chat history:', fallbackError);
+ console.error("Failed to save even minimal chat history:", fallbackError);
}
}
}
+ function focusChatInput() {
+ nextTick(() => {
+ if (chatInputField.value) {
+ chatInputField.value.focus();
+ }
+ });
+ }
+
+ function scrollToBottom() {
+ nextTick(() => {
+ if (chatMessagesEl.value) {
+ chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight;
+ }
+ });
+ }
+
+ function createMessage(role, content, extra = {}) {
+ return {
+ role,
+ content,
+ timestamp: nowIso(),
+ ...extra
+ };
+ }
+
+ function pushMessage(role, content, extra = {}) {
+ chatMessages.value.push(createMessage(role, content, extra));
+ return chatMessages.value.length - 1;
+ }
+
+ function pushError(errorType, content) {
+ pushMessage("error", content, { errorType });
+ saveChatHistory();
+ }
+
+ function restorePendingUserMessage(userMessageIndex, userMessage) {
+ if (userMessageIndex >= 0 && userMessageIndex < chatMessages.value.length) {
+ chatMessages.value.splice(userMessageIndex, 1);
+ }
+
+ chatInput.value = userMessage;
+ }
+
+ function getErrorDisplay(errorType) {
+ return ERROR_DISPLAY[errorType] || DEFAULT_ERROR_DISPLAY;
+ }
+
// Initialize chat history on load
loadChatHistory();
// Clear chat if user is not authenticated
if (!isAuthenticated.value) {
- chatMessages.value = [];
- lastResponseId.value = null;
+ resetConversationState();
}
// Watch for authentication changes and clear chat when user logs out
watch(isAuthenticated, (newAuth, oldAuth) => {
if (oldAuth === true && newAuth === false) {
- // User logged out, clear chat
clearChatHistory();
}
});
- // Chat functions
+ // Chat functions
function openChatDialog() {
- // Update authentication status in case it changed without page refresh
isAuthenticated.value = window.IS_AUTHENTICATED || false;
-
showChatDialog.value = true;
- nextTick(() => {
- if (chatInputField.value && isAuthenticated.value) {
- chatInputField.value.focus();
- }
- scrollToBottom();
- });
+
+ if (isAuthenticated.value) {
+ focusChatInput();
+ }
+
+ scrollToBottom();
}
function closeChatDialog() {
@@ -96,11 +213,9 @@ export function useChatWidget() {
}
function clearChatHistory() {
- chatMessages.value = [];
- lastResponseId.value = null;
+ resetConversationState();
saveChatHistory();
-
- // Force a scroll to top to make it obvious the messages are gone
+
nextTick(() => {
if (chatMessagesEl.value) {
chatMessagesEl.value.scrollTop = 0;
@@ -108,279 +223,438 @@ export function useChatWidget() {
});
}
- // Remove captcha callback functions as they're no longer needed for chat
- // The captcha service can still be used elsewhere in the application
+ function resetCaptchaCallbacks() {
+ captchaResolve = null;
+ captchaReject = null;
+ }
- function scrollToBottom() {
- if (chatMessagesEl.value) {
- nextTick(() => {
- chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight;
- });
+ // Captcha callbacks used by the hCaptcha invisible widget during chat requests.
+ function onCaptchaSuccess(token) {
+ if (!captchaResolve) {
+ return;
}
+
+ const resolve = captchaResolve;
+ resetCaptchaCallbacks();
+ resolve(token);
+ }
+
+ function onCaptchaExpired() {
+ if (!captchaReject) {
+ return;
+ }
+
+ const reject = captchaReject;
+ resetCaptchaCallbacks();
+ reject(new Error("Captcha expired"));
+ }
+
+ function onCaptchaError() {
+ if (!captchaReject) {
+ return;
+ }
+
+ const reject = captchaReject;
+ resetCaptchaCallbacks();
+ reject(new Error("Captcha error"));
+ }
+
+ async function ensureCaptchaWidget() {
+ const siteKey = window.HCAPTCHA_SITE_KEY?.trim();
+ if (!siteKey) {
+ throw new Error("Captcha is not configured.");
+ }
+
+ await nextTick();
+
+ if (captchaWidgetId !== null) {
+ return;
+ }
+
+ if (!captchaContainerEl.value) {
+ throw new Error("Captcha container is missing.");
+ }
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => reject(new Error("Captcha script is not ready.")), CAPTCHA_SCRIPT_TIMEOUT_MS);
+ window.EssentialCSharp.HCaptcha.whenHcaptchaReady(() => {
+ clearTimeout(timeout);
+ resolve();
+ });
+ });
+
+ captchaWidgetId = window.hcaptcha.render(captchaContainerEl.value, {
+ sitekey: siteKey,
+ size: "invisible",
+ callback: onCaptchaSuccess,
+ "expired-callback": onCaptchaExpired,
+ "error-callback": onCaptchaError
+ });
}
+ async function getFreshCaptchaToken() {
+ await ensureCaptchaWidget();
+
+ return await new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ if (!captchaReject) {
+ return;
+ }
+
+ const rejectCaptcha = captchaReject;
+ resetCaptchaCallbacks();
+ rejectCaptcha(new Error("Captcha timed out"));
+ }, CAPTCHA_TIMEOUT_MS);
+
+ captchaResolve = (token) => {
+ clearTimeout(timeoutId);
+ resolve(token);
+ };
+ captchaReject = (error) => {
+ clearTimeout(timeoutId);
+ reject(error);
+ };
+
+ window.hcaptcha.reset(captchaWidgetId);
+ window.hcaptcha.execute(captchaWidgetId);
+ });
+ }
+ // The captcha service can still be used elsewhere in the application
+
function formatMessage(content) {
- if (!content) return '';
+ if (!content) {
+ return "";
+ }
const rawHtml = marked.parse(content);
return DOMPurify.sanitize(rawHtml);
}
function getErrorMessageClass(errorType) {
- if (errorType === 'rate-limit') {
- return 'rate-limit-error';
- } else if (errorType === 'auth-error') {
- return 'error-message';
- } else if (errorType === 'validation-error') {
- return 'error-message';
- } else {
- return 'error-message';
- }
+ return getErrorDisplay(errorType).className;
}
function getErrorIconClass(errorType) {
- if (errorType === 'rate-limit') {
- return 'fas fa-clock';
- } else if (errorType === 'auth-error') {
- return 'fas fa-lock';
- } else if (errorType === 'validation-error') {
- return 'fas fa-exclamation-circle';
- } else if (errorType === 'network-error') {
- return 'fas fa-wifi';
- } else if (errorType === 'connection-error') {
- return 'fas fa-plug';
- } else {
- return 'fas fa-exclamation-triangle';
+ return getErrorDisplay(errorType).iconClass;
+ }
+
+ function getErrorHeading(errorType) {
+ return getErrorDisplay(errorType).heading;
+ }
+
+ function normalizeUnexpectedChatError(error) {
+ if (error?.chatErrorType && error?.message) {
+ return {
+ errorType: error.chatErrorType,
+ errorMessage: error.message
+ };
+ }
+
+ if (error?.name === "AbortError") {
+ return {
+ errorType: "error",
+ errorMessage: "Request was cancelled. Please try again."
+ };
+ }
+
+ if (error?.message?.includes("Failed to fetch")) {
+ return {
+ errorType: "network-error",
+ errorMessage: "Network error. Please check your internet connection and try again."
+ };
}
+
+ return {
+ errorType: "error",
+ errorMessage: "Sorry, I encountered an error while processing your request. Please try again."
+ };
}
- async function sendChatMessage() {
- if (!chatInput.value.trim() || isTyping.value) return;
+ async function tryReadJson(response, fallback = {}) {
+ try {
+ return await response.json();
+ } catch {
+ return fallback;
+ }
+ }
- // Check authentication first
- if (!isAuthenticated.value) {
- chatMessages.value.push({
- role: 'error',
- errorType: 'auth-error',
- content: 'You must be logged in to use the chat feature. Please log in and try again.',
- timestamp: new Date().toISOString()
- });
- saveChatHistory();
+ function extractSseLines(buffer, flushRemainder = false) {
+ const lines = buffer.split("\n");
+
+ if (flushRemainder) {
+ return {
+ lines,
+ remainder: ""
+ };
+ }
+
+ return {
+ lines: lines.slice(0, -1),
+ remainder: lines[lines.length - 1] ?? ""
+ };
+ }
+
+ function handleStreamLine(line, streamState) {
+ const trimmedLine = line.trimEnd();
+ if (!trimmedLine.startsWith("data: ")) {
return;
}
- const userMessage = chatInput.value.trim();
-
- // Client-side validation
- if (userMessage.length > 500) {
- chatMessages.value.push({
- role: 'error',
- errorType: 'validation-error',
- content: 'Your message is too long. Please keep it under 500 characters.',
- timestamp: new Date().toISOString()
- });
+ const data = trimmedLine.slice(6);
+ if (data === "[DONE]") {
+ isTyping.value = false;
saveChatHistory();
return;
}
-
- chatInput.value = '';
- // Add user message
- chatMessages.value.push({
- role: 'user',
- content: userMessage,
- timestamp: new Date().toISOString()
- });
+ let parsed;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ console.warn("Failed to parse SSE data:", data);
+ return;
+ }
- // Save immediately after adding user message
- saveChatHistory();
+ if (parsed.type === "text" && parsed.data) {
+ if (!streamState.hasStartedStreaming) {
+ isTyping.value = false;
+ streamState.assistantMessageIndex = pushMessage("assistant", "");
+ streamState.hasStartedStreaming = true;
+ }
+
+ streamState.assistantMessage += parsed.data;
+ chatMessages.value[streamState.assistantMessageIndex].content = streamState.assistantMessage;
+ scrollToBottom();
+ return;
+ }
- // Show typing indicator
- isTyping.value = true;
+ if (parsed.type === "responseId" && parsed.data) {
+ lastResponseId.value = parsed.data;
+ return;
+ }
- // Scroll to bottom
- nextTick(() => {
- if (chatMessagesEl.value) {
- chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight;
+ if (parsed.type === "error") {
+ throw createChatError("connection-error", parsed.message || parsed.data || "Stream interrupted. Please try again.");
+ }
+ }
+
+ async function consumeChatStream(reader) {
+ const decoder = new TextDecoder();
+ let bufferedChunk = "";
+ const streamState = {
+ assistantMessage: "",
+ assistantMessageIndex: -1,
+ hasStartedStreaming: false
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ bufferedChunk += decoder.decode();
+ const { lines } = extractSseLines(bufferedChunk, true);
+ for (const line of lines) {
+ handleStreamLine(line, streamState);
+ }
+ break;
}
- });
- let reader = null;
+ bufferedChunk += decoder.decode(value, { stream: true });
+
+ const { lines, remainder } = extractSseLines(bufferedChunk);
+ for (const line of lines) {
+ handleStreamLine(line, streamState);
+ }
+
+ bufferedChunk = remainder;
+ }
+
+ if (streamState.hasStartedStreaming) {
+ saveChatHistory();
+ }
+ }
+
+ async function retryCaptchaChallenge(streamResponse, userMessage, userMessageIndex) {
+ const errorData = await tryReadJson(streamResponse);
+ if (!errorData.retryable) {
+ throw createChatError("captcha-error", "Security verification failed. Please try again.");
+ }
+
+ let retryToken;
try {
- const requestBody = {
- message: userMessage,
- enableContextualSearch: true,
- previousResponseId: lastResponseId.value
- };
+ retryToken = await getFreshCaptchaToken();
+ } catch {
+ throw createChatError("captcha-error", "Security verification failed. Please try again.");
+ }
+
+ const retryResponse = await fetchChatStream(userMessage, retryToken);
+ if (!retryResponse.ok) {
+ restorePendingUserMessage(userMessageIndex, userMessage);
+ if (retryResponse.status === 403) {
+ throw createChatError("captcha-error", "Security verification failed. Please try again.");
+ }
+
+ await throwForErrorResponse(retryResponse);
+ }
+
+ return retryResponse;
+ }
+
+ async function throwForErrorResponse(streamResponse) {
+ if (streamResponse.status === 401) {
+ isAuthenticated.value = false;
+ throw createChatError("auth-error", "You must be logged in to use the chat feature. Please log in and try again.");
+ }
- const response = await fetch('/api/chat/stream', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody)
+ if (streamResponse.status === 429) {
+ const errorData = await tryReadJson(streamResponse, {
+ error: "Rate limit exceeded. Please wait before sending another message.",
+ retryAfter: 60
});
+ const retryAfter = errorData.retryAfter || 60;
- if (!response.ok) {
- if (response.status === 401) {
- throw new Error('Authentication required');
- } else if (response.status === 429) {
- // Handle rate limiting - simple error message without captcha
- let errorData;
- try {
- errorData = await response.json();
- } catch (e) {
- errorData = {
- error: 'Rate limit exceeded. Please wait before sending another message.',
- retryAfter: 60
- };
- }
-
- const retryAfter = errorData.retryAfter || 60;
- const errorMessage = `Rate limit exceeded. Please wait ${Math.ceil(retryAfter)} seconds before sending another message.`;
-
- throw new Error(errorMessage);
- } else if (response.status === 400) {
- // Handle validation errors
- const errorData = await response.json();
- throw new Error(errorData.error || 'Bad request');
- }
- throw new Error(`HTTP error! status: ${response.status}`);
+ throw createChatError("rate-limit", `Rate limit exceeded. Please wait ${Math.ceil(retryAfter)} seconds before sending another message.`);
+ }
+
+ if (streamResponse.status === 400) {
+ const errorData = await tryReadJson(streamResponse, { error: "Bad request" });
+ throw createChatError("validation-error", errorData.error || "Bad request");
+ }
+
+ if (streamResponse.status === 503) {
+ const errorData = await tryReadJson(streamResponse);
+ if (errorData.errorCode === "ai_unavailable") {
+ throw createChatError(
+ "chat-unavailable",
+ errorData.error || CHAT_WIDGET.unavailableMessage || "AI chat is unavailable for this local run."
+ );
}
- // Handle streaming response
- reader = response.body.getReader();
- const decoder = new TextDecoder();
- let assistantMessage = '';
- let assistantMessageIndex = -1;
- let hasStartedStreaming = false;
+ if (errorData.errorCode === "captcha_unavailable") {
+ throw createChatError("captcha-error", "Security verification is temporarily unavailable. Please try again later.");
+ }
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
+ throw createChatError("connection-error", errorData.error || "Service unavailable");
+ }
- const chunk = decoder.decode(value);
- const lines = chunk.split('\n');
+ throw createChatError("connection-error", "Unable to connect to the chat service. Please check your connection and try again.");
+ }
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- const data = line.slice(6);
- if (data === '[DONE]') {
- isTyping.value = false;
- // Save final state
- saveChatHistory();
- continue;
- }
-
- try {
- const parsed = JSON.parse(data);
- if (parsed.type === 'text' && parsed.data) {
- // If this is the first chunk, hide typing indicator and add assistant message
- if (!hasStartedStreaming) {
- isTyping.value = false;
- chatMessages.value.push({
- role: 'assistant',
- content: '',
- timestamp: new Date().toISOString()
- });
- assistantMessageIndex = chatMessages.value.length - 1;
- hasStartedStreaming = true;
- }
-
- assistantMessage += parsed.data;
- chatMessages.value[assistantMessageIndex].content = assistantMessage;
-
- // Scroll to bottom
- nextTick(() => {
- if (chatMessagesEl.value) {
- chatMessagesEl.value.scrollTop = chatMessagesEl.value.scrollHeight;
- }
- });
- }
- // Store responseId for conversation continuity
- else if (parsed.type === 'responseId' && parsed.data) {
- lastResponseId.value = parsed.data;
- }
- } catch (e) {
- console.warn('Failed to parse SSE data:', data);
- }
- }
- }
+ async function ensureSuccessfulStreamResponse(streamResponse, userMessage, userMessageIndex) {
+ if (streamResponse.ok) {
+ return streamResponse;
+ }
+
+ if (streamResponse.status === 403) {
+ return await retryCaptchaChallenge(streamResponse, userMessage, userMessageIndex);
+ }
+
+ await throwForErrorResponse(streamResponse);
+ }
+
+ async function startChatStream(userMessage, captchaToken, userMessageIndex) {
+ const streamResponse = await fetchChatStream(userMessage, captchaToken);
+ return await ensureSuccessfulStreamResponse(streamResponse, userMessage, userMessageIndex);
+ }
+
+ async function sendChatMessage() {
+ if (!chatInput.value.trim() || isTyping.value || isSubmitting.value) {
+ return;
+ }
+
+ if (CHAT_WIDGET.available === false) {
+ pushError("chat-unavailable", CHAT_WIDGET.unavailableMessage || "AI chat is unavailable for this local run.");
+ return;
+ }
+
+ if (!isAuthenticated.value) {
+ pushError("auth-error", "You must be logged in to use the chat feature. Please log in and try again.");
+ return;
+ }
+
+ const userMessage = chatInput.value.trim();
+ if (userMessage.length > MAX_MESSAGE_LENGTH) {
+ pushError("validation-error", `Your message is too long. Please keep it under ${MAX_MESSAGE_LENGTH} characters.`);
+ return;
+ }
+
+ isSubmitting.value = true;
+ let reader = null;
+ try {
+ let captchaToken;
+ try {
+ captchaToken = await getFreshCaptchaToken();
+ } catch (captchaErr) {
+ console.warn("Captcha acquisition failed:", captchaErr);
+ pushError("captcha-error", "Security verification failed. Please refresh the page and try again.");
+ return;
}
+ chatInput.value = "";
+
+ const userMessageIndex = pushMessage("user", userMessage);
+ saveChatHistory();
+ isTyping.value = true;
+ scrollToBottom();
+
+ const streamResponse = await startChatStream(userMessage, captchaToken, userMessageIndex);
+ reader = streamResponse.body?.getReader() ?? null;
+
+ if (!reader) {
+ throw createChatError("connection-error", "Unable to connect to the chat service. Please check your connection and try again.");
+ }
+
+ await consumeChatStream(reader);
} catch (error) {
- console.error('Chat error:', error);
-
- // Hide typing indicator if still showing
+ console.error("Chat error:", error);
isTyping.value = false;
-
- // Provide more specific error messages with types
- let errorMessage = 'Sorry, I encountered an error while processing your request. Please try again.';
- let errorType = 'error';
-
- if (error.name === 'AbortError') {
- errorMessage = 'Request was cancelled. Please try again.';
- errorType = 'error';
- } else if (error.message?.includes('Authentication required')) {
- errorMessage = 'You must be logged in to use the chat feature. Please log in and try again.';
- errorType = 'auth-error';
- isAuthenticated.value = false; // Update auth state
- } else if (error.message?.includes('Rate limit exceeded')) {
- errorMessage = error.message; // Use the specific rate limit message with timing
- errorType = 'rate-limit';
- } else if (error.message?.includes('HTTP error')) {
- errorMessage = 'Unable to connect to the chat service. Please check your connection and try again.';
- errorType = 'connection-error';
- } else if (error.message?.includes('Failed to fetch')) {
- errorMessage = 'Network error. Please check your internet connection and try again.';
- errorType = 'network-error';
- }
-
- chatMessages.value.push({
- role: 'error',
- errorType: errorType,
- content: errorMessage,
- timestamp: new Date().toISOString()
- });
- saveChatHistory();
+
+ const { errorType, errorMessage } = normalizeUnexpectedChatError(error);
+ pushError(errorType, errorMessage);
} finally {
- // Ensure reader is properly closed
if (reader) {
try {
await reader.cancel();
- } catch (e) {
- console.warn('Failed to cancel reader:', e);
+ } catch (error) {
+ console.warn("Failed to cancel reader:", error);
}
}
-
- // Ensure typing indicator is hidden
+
isTyping.value = false;
-
- // Focus back on input
- nextTick(() => {
- if (chatInputField.value) {
- chatInputField.value.focus();
- }
- });
+ isSubmitting.value = false;
+ focusChatInput();
}
}
+ function fetchChatStream(message, captchaToken) {
+ return fetch("/api/chat/stream", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message,
+ enableContextualSearch: true,
+ previousResponseId: lastResponseId.value,
+ captchaToken
+ })
+ });
+ }
+
// Clean up old chat sessions (keep only last 7 days)
function cleanupOldSessions() {
try {
- const saved = localStorage.getItem('aiChatHistory');
- if (saved) {
- const data = JSON.parse(saved);
- const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
-
- if (data.timestamp && data.timestamp < sevenDaysAgo) {
- localStorage.removeItem('aiChatHistory');
- chatMessages.value = [];
- lastResponseId.value = null;
- }
+ const data = readSavedChatHistory();
+ if (!data) {
+ return;
+ }
+
+ const maxAge = CHAT_HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
+ const retentionCutoff = Date.now() - maxAge;
+
+ if (data.timestamp && data.timestamp < retentionCutoff) {
+ localStorage.removeItem(CHAT_HISTORY_KEY);
+ resetConversationState();
}
} catch (error) {
- console.warn('Failed to cleanup old sessions:', error);
+ console.warn("Failed to cleanup old sessions:", error);
}
}
@@ -394,14 +668,17 @@ export function useChatWidget() {
chatMessages,
chatInput,
isTyping,
+ isSubmitting,
chatMessagesEl,
chatInputField,
+ captchaContainerEl,
// Methods
openChatDialog,
closeChatDialog,
clearChatHistory,
formatMessage,
+ getErrorHeading,
getErrorMessageClass,
getErrorIconClass,
sendChatMessage