feat(frontend): Enhance chat functionality and session management

- Added support for "example.com" in the Next.js configuration for external resources.
- Refactored `useChatPage` to improve session creation logic, ensuring sessions are only created when necessary.
- Introduced new helper functions for validating message structures and parsing tool responses in `ChatContainer`.
- Updated `useChatContainer` to utilize new helper functions for better message handling and response parsing.
- Implemented local storage management for session IDs, improving session persistence and validation.
- Added error handling for OAuth flows in `CredentialsInputs`, enhancing user experience during authentication.

These changes aim to streamline chat interactions and improve overall session management.
This commit is contained in:
Swifty
2025-11-04 09:24:50 +01:00
parent 465c7eebdf
commit 606c92f8d0
9 changed files with 725 additions and 497 deletions

View File

@@ -10,6 +10,7 @@ const nextConfig = {
"upload.wikimedia.org",
"storage.googleapis.com",
"example.com",
"ideogram.ai", // for generated images
"picsum.photos", // for placeholder images
],

View File

@@ -0,0 +1,357 @@
import type { ChatMessageData } from "@/components/molecules/ChatMessage/useChatMessage";
import type { ToolResult } from "@/types/chat";
/**
* Type guard to validate message structure from backend.
*
* @param msg - The message to validate
* @returns True if the message has valid structure
*/
export function isValidMessage(
msg: unknown
): msg is Record<string, unknown> {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
// Validate required fields
if (typeof m.role !== "string") {
return false;
}
// Content can be string or undefined
if (m.content !== undefined && typeof m.content !== "string") {
return false;
}
return true;
}
/**
* Type guard to validate tool_calls array structure.
*
* @param value - The value to validate
* @returns True if value is a valid tool_calls array
*/
export function isToolCallArray(
value: unknown
): value is Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}> {
if (!Array.isArray(value)) {
return false;
}
return value.every(
(item) =>
typeof item === "object" &&
item !== null &&
"id" in item &&
typeof item.id === "string" &&
"type" in item &&
typeof item.type === "string" &&
"function" in item &&
typeof item.function === "object" &&
item.function !== null &&
"name" in item.function &&
typeof item.function.name === "string" &&
"arguments" in item.function &&
typeof item.function.arguments === "string"
);
}
/**
* Type guard to validate agent data structure.
*
* @param value - The value to validate
* @returns True if value is a valid agents array
*/
export function isAgentArray(
value: unknown
): value is Array<{
id: string;
name: string;
description: string;
version?: number;
}> {
if (!Array.isArray(value)) {
return false;
}
return value.every(
(item) =>
typeof item === "object" &&
item !== null &&
"id" in item &&
typeof item.id === "string" &&
"name" in item &&
typeof item.name === "string" &&
"description" in item &&
typeof item.description === "string" &&
(!("version" in item) || typeof item.version === "number")
);
}
/**
* Extracts a JSON object embedded within an error message string.
*
* This handles the edge case where the backend returns error messages
* containing JSON objects with credential requirements or other structured data.
* Uses manual brace matching to extract the first balanced JSON object.
*
* @param message - The error message that may contain embedded JSON
* @returns The parsed JSON object, or null if no valid JSON found
*
* @example
* ```ts
* const msg = "Error: Missing credentials {\"missing_credentials\": {...}}";
* const result = extractJsonFromErrorMessage(msg);
* // Returns: { missing_credentials: {...} }
* ```
*/
export function extractJsonFromErrorMessage(
message: string,
): Record<string, unknown> | null {
try {
const start = message.indexOf("{");
if (start === -1) {
return null;
}
// Extract first balanced JSON object using brace matching
let depth = 0;
let end = -1;
for (let i = start; i < message.length; i++) {
const ch = message[i];
if (ch === "{") {
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
if (end === -1) {
return null;
}
const jsonStr = message.slice(start, end + 1);
return JSON.parse(jsonStr) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Parses a tool result and converts it to the appropriate ChatMessageData type.
*
* Handles specialized tool response types like:
* - no_results: Search returned no matches
* - agent_carousel: List of agents to display
* - execution_started: Agent execution began
* - Generic tool responses: Raw tool output
*
* @param result - The tool result to parse (may be string or object)
* @param toolId - The unique identifier for this tool call
* @param toolName - The name of the tool that was called
* @param timestamp - Optional timestamp for the response
* @returns The appropriate ChatMessageData object, or null for setup_requirements
*/
export function parseToolResponse(
result: ToolResult,
toolId: string,
toolName: string,
timestamp?: Date,
): ChatMessageData | null {
// Try to parse as JSON if it's a string
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof result === "string"
? JSON.parse(result)
: (result as Record<string, unknown>);
} catch {
// If parsing fails, we'll use the generic tool response
parsedResult = null;
}
// Handle structured response types
if (parsedResult && typeof parsedResult === "object") {
const responseType = parsedResult.type as string | undefined;
// Handle no_results response
if (responseType === "no_results") {
return {
type: "no_results",
message: (parsedResult.message as string) || "No results found",
suggestions: (parsedResult.suggestions as string[]) || [],
sessionId: parsedResult.session_id as string | undefined,
timestamp: timestamp || new Date(),
};
}
// Handle agent_carousel response
if (responseType === "agent_carousel") {
const agentsData = parsedResult.agents;
// Validate agents array structure before using it
if (isAgentArray(agentsData)) {
return {
type: "agent_carousel",
agents: agentsData,
totalCount: parsedResult.total_count as number | undefined,
timestamp: timestamp || new Date(),
};
} else {
console.warn("Invalid agents array in agent_carousel response");
}
}
// Handle execution_started response
if (responseType === "execution_started") {
return {
type: "execution_started",
executionId: (parsedResult.execution_id as string) || "",
agentName: parsedResult.agent_name as string | undefined,
message: parsedResult.message as string | undefined,
timestamp: timestamp || new Date(),
};
}
// Handle setup_requirements - return null so caller can handle it specially
if (responseType === "setup_requirements") {
return null;
}
}
// Generic tool response
return {
type: "tool_response",
toolId,
toolName,
result,
success: true,
timestamp: timestamp || new Date(),
};
}
/**
* Type guard to validate user readiness structure from backend.
*
* @param value - The value to validate
* @returns True if the value matches the UserReadiness structure
*/
export function isUserReadiness(
value: unknown,
): value is { missing_credentials?: Record<string, unknown> } {
return (
typeof value === "object" &&
value !== null &&
(!("missing_credentials" in value) ||
typeof (value as any).missing_credentials === "object")
);
}
/**
* Type guard to validate missing credentials structure.
*
* @param value - The value to validate
* @returns True if the value is a valid missing credentials record
*/
export function isMissingCredentials(
value: unknown,
): value is Record<string, Record<string, unknown>> {
if (typeof value !== "object" || value === null) {
return false;
}
// Check that all values are objects
return Object.values(value).every(
(v) => typeof v === "object" && v !== null,
);
}
/**
* Type guard to validate setup info structure.
*
* @param value - The value to validate
* @returns True if the value contains valid setup info
*/
export function isSetupInfo(
value: unknown,
): value is {
user_readiness?: Record<string, unknown>;
agent_name?: string;
} {
return (
typeof value === "object" &&
value !== null &&
(!("user_readiness" in value) ||
typeof (value as any).user_readiness === "object") &&
(!("agent_name" in value) ||
typeof (value as any).agent_name === "string")
);
}
/**
* Extract credentials requirements from setup info result.
*
* Used when a tool response indicates missing credentials are needed
* to execute an agent.
*
* @param parsedResult - The parsed tool response result
* @returns ChatMessageData for credentials_needed, or null if no credentials needed
*/
export function extractCredentialsNeeded(
parsedResult: Record<string, unknown>
): ChatMessageData | null {
try {
const setupInfo = parsedResult?.setup_info as
| Record<string, unknown>
| undefined;
const userReadiness = setupInfo?.user_readiness as
| Record<string, unknown>
| undefined;
const missingCreds = userReadiness?.missing_credentials as
| Record<string, Record<string, unknown>>
| undefined;
// If there are missing credentials, create the message
if (missingCreds && Object.keys(missingCreds).length > 0) {
// Get the first missing credential to show
const firstCredKey = Object.keys(missingCreds)[0];
const credInfo = missingCreds[firstCredKey];
return {
type: "credentials_needed",
provider: (credInfo.provider as string) || "unknown",
providerName:
(credInfo.provider_name as string) ||
(credInfo.provider as string) ||
"Unknown Provider",
credentialType: (credInfo.type as string) || "api_key",
title:
(credInfo.title as string) ||
(setupInfo?.agent_name as string) ||
"this agent",
message: `To run ${(setupInfo?.agent_name as string) || "this agent"}, you need to add ${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials.`,
scopes: credInfo.scopes as string[] | undefined,
timestamp: new Date(),
};
}
return null;
} catch (err) {
console.error("Failed to extract credentials from setup info:", err);
return null;
}
}

View File

@@ -1,8 +1,14 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useMemo } from "react";
import { toast } from "sonner";
import { useChatStream, type StreamChunk } from "@/hooks/useChatStream";
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import type { ChatMessageData } from "@/components/molecules/ChatMessage/useChatMessage";
import {
parseToolResponse,
extractCredentialsNeeded,
isValidMessage,
isToolCallArray,
} from "./helpers";
interface UseChatContainerArgs {
sessionId: string | null;
@@ -30,38 +36,39 @@ export function useChatContainer({
// Track streaming chunks in a ref so we can access the latest value in callbacks
const streamingChunksRef = useRef<string[]>([]);
// Track when tool calls were displayed to ensure minimum visibility time
const toolCallTimestamps = useRef<Map<string, number>>(new Map());
const { error, sendMessage: sendStreamMessage } = useChatStream();
// Show streaming UI when we have text chunks, independent of connection state
// This keeps the StreamingMessage visible during the transition to persisted message
const isStreaming = hasTextChunks;
// Convert initial messages to our format, filtering out empty messages
const allMessages: ChatMessageData[] = [
...initialMessages
/**
* Convert initial messages to our format, filtering out empty messages.
* Memoized to prevent expensive re-computation on every render.
*/
const allMessages = useMemo((): ChatMessageData[] => {
const processedInitialMessages = initialMessages
.filter((msg: Record<string, unknown>) => {
// Validate message structure first
if (!isValidMessage(msg)) {
console.warn("Invalid message structure from backend:", msg);
return false;
}
// Include messages with content OR tool_calls (tool_call messages have empty content)
const content = String(msg.content || "").trim();
const toolCalls = msg.tool_calls as unknown[] | undefined;
return content.length > 0 || (toolCalls && toolCalls.length > 0);
const toolCalls = msg.tool_calls;
return content.length > 0 || (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0);
})
.map((msg: Record<string, unknown>): ChatMessageData | null => {
const content = String(msg.content || "");
const role = String(msg.role || "assistant").toLowerCase();
// Check if this is a tool_call message (assistant message with tool_calls)
const toolCalls = msg.tool_calls as
| Array<{
id: string;
type: string;
function: { name: string; arguments: string };
}>
| undefined;
const toolCalls = msg.tool_calls;
if (role === "assistant" && toolCalls && toolCalls.length > 0) {
// Validate tool_calls structure if present
if (role === "assistant" && toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
// Skip tool_call messages from persisted history
// We only show tool_calls during live streaming, not from history
// The tool_response that follows it is what we want to display
@@ -70,76 +77,25 @@ export function useChatContainer({
// Check if this is a tool response message (role="tool")
if (role === "tool") {
// Try to parse the content as JSON to detect structured responses
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object" && parsed.type) {
// Handle no_results
if (parsed.type === "no_results") {
return {
type: "no_results",
message: parsed.message || "No results found",
suggestions: parsed.suggestions || [],
sessionId: parsed.session_id,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
}
const timestamp = msg.timestamp
? new Date(msg.timestamp as string)
: undefined;
// Handle agent_carousel
if (
parsed.type === "agent_carousel" &&
Array.isArray(parsed.agents)
) {
return {
type: "agent_carousel",
agents: parsed.agents,
totalCount: parsed.total_count,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
}
// Use helper function to parse tool response
const toolResponse = parseToolResponse(
content,
(msg.tool_call_id as string) || "",
"unknown",
timestamp
);
// Handle execution_started
if (parsed.type === "execution_started") {
return {
type: "execution_started",
executionId: parsed.execution_id || "",
agentName: parsed.agent_name,
message: parsed.message,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
}
}
// Generic tool response - not a specialized type
return {
type: "tool_response",
toolId: (msg.tool_call_id as string) || "",
toolName: "unknown",
result: parsed,
success: true,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
} catch {
// Not valid JSON, treat as string result
return {
type: "tool_response",
toolId: (msg.tool_call_id as string) || "",
toolName: "unknown",
result: content,
success: true,
timestamp: msg.timestamp
? new Date(msg.timestamp as string)
: undefined,
};
// parseToolResponse returns null for setup_requirements
// In that case, skip this message (it should be handled during streaming)
if (!toolResponse) {
return null;
}
return toolResponse;
}
// Return as regular message
@@ -152,10 +108,28 @@ export function useChatContainer({
: undefined,
};
})
.filter((msg): msg is ChatMessageData => msg !== null), // Remove null entries
...messages,
];
.filter((msg): msg is ChatMessageData => msg !== null); // Remove null entries
return [...processedInitialMessages, ...messages];
}, [initialMessages, messages]);
/**
* Send a message and handle the streaming response.
*
* Message Flow:
* 1. User message added immediately to local state
* 2. text_chunk events accumulate in streaming box
* 3. text_ended closes streaming box
* 4. tool_call_start shows ToolCallMessage (spinning gear)
* 5. tool_response replaces ToolCallMessage with ToolResponseMessage (result)
* 6. stream_end finalizes, saves to backend, triggers refresh
*
* State Management:
* - Local `messages` state tracks only new messages during streaming
* - `streamingChunks` accumulates text as it arrives
* - `streamingChunksRef` prevents stale closures in async handlers
* - On stream_end, local messages cleared and replaced by refreshed initialMessages
*/
const sendMessage = useCallback(
async function sendMessage(content: string) {
if (!sessionId) {
@@ -176,8 +150,6 @@ export function useChatContainer({
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
// Clear any pending tool call timestamps
toolCallTimestamps.current.clear();
try {
// Stream the response
@@ -192,206 +164,89 @@ export function useChatContainer({
streamingChunksRef.current = updated;
return updated;
});
} else if (chunk.type === "tool_call") {
// Record the timestamp when this tool call was displayed
toolCallTimestamps.current.set(chunk.tool_id!, Date.now());
// Add tool call message
} else if (chunk.type === "text_ended") {
// Close the streaming text box
console.log("[Text Ended] Closing streaming text box");
setStreamingChunks([]);
streamingChunksRef.current = [];
setHasTextChunks(false);
} else if (chunk.type === "tool_call_start") {
// Show ToolCallMessage immediately when tool execution starts
const toolCallMessage: ChatMessageData = {
type: "tool_call",
toolId: chunk.tool_id!,
toolName: chunk.tool_name!,
arguments: chunk.arguments,
toolId: `tool-${Date.now()}-${chunk.idx || 0}`,
toolName: "Executing...",
arguments: {},
timestamp: new Date(),
};
setMessages((prev) => [...prev, toolCallMessage]);
} else if (chunk.type === "tool_response") {
// Parse the tool response first so it's available for all checks
let parsedResult: Record<string, unknown> | null = null;
try {
parsedResult =
typeof chunk.result === "string"
? JSON.parse(chunk.result)
: (chunk.result as Record<string, unknown>);
} catch {
// If parsing fails, we'll use the generic tool response
parsedResult = null;
}
// Ensure tool call was visible for at least 500ms
const MIN_DISPLAY_TIME = 500; // milliseconds
const toolCallTime = toolCallTimestamps.current.get(
console.log("[Tool Call Start]", {
toolId: toolCallMessage.toolId,
timestamp: new Date().toISOString(),
});
} else if (chunk.type === "tool_response") {
console.log("[Tool Response] Received:", {
toolId: chunk.tool_id,
toolName: chunk.tool_name,
timestamp: new Date().toISOString(),
});
// Use helper function to parse tool response
const responseMessage = parseToolResponse(
chunk.result!,
chunk.tool_id!,
chunk.tool_name!,
new Date()
);
const processToolResponse = async () => {
if (toolCallTime) {
const elapsed = Date.now() - toolCallTime;
const remainingTime = MIN_DISPLAY_TIME - elapsed;
if (remainingTime > 0) {
await new Promise((resolve) =>
setTimeout(resolve, remainingTime),
);
}
// Clean up the timestamp
toolCallTimestamps.current.delete(chunk.tool_id!);
}
// Create the appropriate message based on response type
let responseMessage: ChatMessageData;
if (parsedResult && typeof parsedResult === "object") {
const responseType = parsedResult.type as string | undefined;
// Handle no_results response
if (responseType === "no_results") {
responseMessage = {
type: "no_results",
message:
(parsedResult.message as string) || "No results found",
suggestions: (parsedResult.suggestions as string[]) || [],
sessionId: parsedResult.session_id as string | undefined,
timestamp: new Date(),
};
} else if (responseType === "agent_carousel") {
// Handle agent_carousel response
const agentsData = parsedResult.agents as Array<{
id: string;
name: string;
description: string;
version?: number;
}>;
if (agentsData && Array.isArray(agentsData)) {
responseMessage = {
type: "agent_carousel",
agents: agentsData,
totalCount: parsedResult.total_count as
| number
| undefined,
timestamp: new Date(),
};
} else {
// Fallback to generic if agents array is invalid
responseMessage = {
type: "tool_response",
toolId: chunk.tool_id!,
toolName: chunk.tool_name!,
result: chunk.result!,
success: chunk.success,
timestamp: new Date(),
};
}
} else if (responseType === "execution_started") {
// Handle execution_started response
responseMessage = {
type: "execution_started",
executionId: (parsedResult.execution_id as string) || "",
agentName: parsedResult.agent_name as string | undefined,
message: parsedResult.message as string | undefined,
timestamp: new Date(),
};
} else {
// Generic tool response
responseMessage = {
type: "tool_response",
toolId: chunk.tool_id!,
toolName: chunk.tool_name!,
result: chunk.result!,
success: chunk.success,
timestamp: new Date(),
};
}
} else {
// Generic tool response if parsing failed
responseMessage = {
type: "tool_response",
toolId: chunk.tool_id!,
toolName: chunk.tool_name!,
result: chunk.result!,
success: chunk.success,
timestamp: new Date(),
};
}
// Replace the tool_call message with the response message
setMessages((prev) => {
// Find and replace the tool_call message with the same tool_id
const toolCallIndex = prev.findIndex(
(msg) =>
msg.type === "tool_call" && msg.toolId === chunk.tool_id,
);
if (toolCallIndex !== -1) {
// Replace the tool_call with the response
const newMessages = [...prev];
newMessages[toolCallIndex] = responseMessage;
return newMessages;
} else {
// Tool call not found (shouldn't happen), just append
return [...prev, responseMessage];
}
});
};
// Process the tool response with potential delay
processToolResponse();
// Check if this is get_required_setup_info and has missing credentials
if (
chunk.tool_name === "get_required_setup_info" &&
chunk.success &&
parsedResult
) {
// If helper returns null (setup_requirements), handle credentials
if (!responseMessage) {
// Parse for credentials check
let parsedResult: Record<string, unknown> | null = null;
try {
const setupInfo = parsedResult?.setup_info as
| Record<string, unknown>
| undefined;
const userReadiness = setupInfo?.user_readiness as
| Record<string, unknown>
| undefined;
const missingCreds = userReadiness?.missing_credentials as
| Record<string, Record<string, unknown>>
| undefined;
parsedResult =
typeof chunk.result === "string"
? JSON.parse(chunk.result)
: (chunk.result as Record<string, unknown>);
} catch {
parsedResult = null;
}
// If there are missing credentials, show a prompt
if (missingCreds && Object.keys(missingCreds).length > 0) {
// Get the first missing credential to show
const firstCredKey = Object.keys(missingCreds)[0];
const credInfo = missingCreds[firstCredKey];
const credentialsMessage: ChatMessageData = {
type: "credentials_needed",
provider: (credInfo.provider as string) || "unknown",
providerName:
(credInfo.provider_name as string) ||
(credInfo.provider as string) ||
"Unknown Provider",
credentialType: (credInfo.type as string) || "api_key",
title:
(credInfo.title as string) ||
(setupInfo?.agent_name as string) ||
"this agent",
message: `To run ${(setupInfo?.agent_name as string) || "this agent"}, you need to add ${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials.`,
scopes: credInfo.scopes as string[] | undefined,
timestamp: new Date(),
};
// Check if this is get_required_setup_info with missing credentials
if (
chunk.tool_name === "get_required_setup_info" &&
chunk.success &&
parsedResult
) {
const credentialsMessage = extractCredentialsNeeded(parsedResult);
if (credentialsMessage) {
setMessages((prev) => [...prev, credentialsMessage]);
}
} catch (err) {
console.error(
"Failed to parse setup info for credentials check:",
err,
);
toast.error("Failed to parse credential requirements", {
description:
err instanceof Error
? err.message
: "Could not process setup information",
});
}
// Don't add message if setup_requirements
return;
}
// Replace the most recent tool_call message with the response
setMessages((prev) => {
const toolCallIndex = [...prev]
.reverse()
.findIndex((msg) => msg.type === "tool_call");
if (toolCallIndex !== -1) {
const actualIndex = prev.length - 1 - toolCallIndex;
const newMessages = [...prev];
newMessages[actualIndex] = responseMessage;
console.log("[Tool Response] Replaced tool_call at index:", actualIndex);
return newMessages;
}
console.warn("[Tool Response] No tool_call found to replace, appending");
return [...prev, responseMessage];
});
} else if (chunk.type === "login_needed") {
// Add login needed message
const loginNeededMessage: ChatMessageData = {
@@ -402,6 +257,27 @@ export function useChatContainer({
};
setMessages((prev) => [...prev, loginNeededMessage]);
} else if (chunk.type === "stream_end") {
console.log("[Stream End] Final state:", {
localMessages: messages.map((m) => ({
type: m.type,
...(m.type === "message" && {
role: m.role,
contentLength: m.content.length,
}),
...(m.type === "tool_call" && {
toolId: m.toolId,
toolName: m.toolName,
}),
...(m.type === "tool_response" && {
toolId: m.toolId,
toolName: m.toolName,
success: m.success,
}),
})),
streamingChunks: streamingChunksRef.current,
timestamp: new Date().toISOString(),
});
// Convert streaming chunks into a completed assistant message
// This provides seamless transition without flash or resize
// Use ref to get the latest chunks value (not stale closure value)
@@ -415,12 +291,9 @@ export function useChatContainer({
timestamp: new Date(),
};
// Replace ALL local messages with just the completed message
// This prevents duplicates when initialMessages updates from backend
setMessages([assistantMessage]);
} else {
// No text content, just clear all local messages
setMessages([]);
// Add the completed assistant message to local state
// It will be visible immediately while backend updates
setMessages((prev) => [...prev, assistantMessage]);
}
// Clear streaming state immediately now that we have the message
@@ -428,12 +301,24 @@ export function useChatContainer({
streamingChunksRef.current = [];
setHasTextChunks(false);
// Refresh session data in background, then clear local messages
// The completed message from initialMessages will replace our local one
onRefreshSession().then(() => {
setMessages([]);
toolCallTimestamps.current.clear();
});
// Refresh session data from backend, then clear local messages
// The backend-persisted messages in initialMessages will replace our local ones
onRefreshSession()
.then(() => {
// Clear local messages, but keep UI-only message types that aren't persisted by backend
setMessages((prev) =>
prev.filter((msg) =>
// Keep UI-only message types
msg.type === "credentials_needed" ||
msg.type === "login_needed" ||
msg.type === "no_results" ||
msg.type === "agent_carousel"
)
);
})
.catch((err) => {
console.error("[Stream End] Failed to refresh session:", err);
});
} else if (chunk.type === "error") {
const errorMessage =
chunk.message || chunk.content || "An error occurred";

View File

@@ -2,7 +2,6 @@ import { useEffect, useState, useRef } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import { useChatSession } from "@/hooks/useChatSession";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
interface UseChatPageResult {
session: ReturnType<typeof useChatSession>["session"];
@@ -20,14 +19,12 @@ export function useChatPage(): UseChatPageResult {
const router = useRouter();
const searchParams = useSearchParams();
const urlSessionId = searchParams.get("session");
const { user, isUserLoading } = useSupabase();
const [_isOnline, setIsOnline] = useState(true);
const claimingSessionRef = useRef<string | null>(null);
const claimedSessionsRef = useRef<Set<string>>(new Set());
const recoveringFromErrorRef = useRef<boolean>(false);
const [isOnline, setIsOnline] = useState(true);
const hasCreatedSessionRef = useRef(false);
const {
session,
sessionId: sessionIdFromHook,
messages,
isLoading,
isCreating,
@@ -35,155 +32,37 @@ export function useChatPage(): UseChatPageResult {
createSession,
refreshSession,
clearSession: clearSessionBase,
claimSession,
} = useChatSession({
urlSessionId,
autoCreate: false, // We'll manually create when needed
});
// Auto-create session if none exists or if the existing one failed to load
// Auto-create session ONLY if there's no URL session
// If URL session exists, GET query will fetch it automatically
useEffect(
function autoCreateSession() {
// Skip if we're recovering from an error
if (recoveringFromErrorRef.current) {
return;
}
// Simple case: no URL session, no current session, not loading
if (!urlSessionId && !session && !isLoading && !isCreating && !error) {
// Only create if:
// 1. No URL session (not loading someone else's session)
// 2. Haven't already created one this mount
// 3. Not currently creating
// 4. We don't already have a sessionId
if (!urlSessionId && !hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
console.log("[autoCreateSession] Creating new session");
hasCreatedSessionRef.current = true;
createSession().catch((err) => {
console.error("Failed to auto-create session:", err);
console.error("[autoCreateSession] Failed to create session:", err);
hasCreatedSessionRef.current = false; // Reset on error to allow retry
});
return;
}
// Error case: session failed to load (404), create a new one
// Only do this once - don't create multiple times
if (error && !isCreating && !session && !claimingSessionRef.current) {
claimingSessionRef.current = "creating"; // Use ref as a lock
recoveringFromErrorRef.current = true; // Set recovery flag
createSession()
.then((_newSessionId) => {
claimingSessionRef.current = null;
// Remove old session from URL if present, new session is in localStorage
if (urlSessionId) {
router.replace("/chat");
}
// Clear recovery flag after a short delay to allow state to settle
setTimeout(() => {
recoveringFromErrorRef.current = false;
}, 500);
})
.catch((err) => {
console.error("Failed to create new session after error:", err);
claimingSessionRef.current = null;
recoveringFromErrorRef.current = false;
});
} else if (sessionIdFromHook) {
console.log("[autoCreateSession] Skipping - already have sessionId:", sessionIdFromHook);
}
},
[
urlSessionId,
session,
isLoading,
isCreating,
error,
createSession,
router,
],
[urlSessionId, isCreating, sessionIdFromHook, createSession],
);
// Auto-claim anonymous sessions when user logs in
useEffect(
function autoClaimSession() {
// Skip if no user or still loading
if (!user || isUserLoading || !session?.id) {
return;
}
// Skip if we're recovering from an error
if (recoveringFromErrorRef.current) {
return;
}
// Anonymous session that needs claiming
if (!session.user_id) {
// Prevent duplicate claims
if (
claimingSessionRef.current === session.id ||
claimedSessionsRef.current.has(session.id)
) {
return;
}
claimingSessionRef.current = session.id;
claimSession(session.id)
.then(() => {
claimedSessionsRef.current.add(session.id);
claimingSessionRef.current = null;
})
.catch((err) => {
console.error("Failed to auto-claim session:", err);
claimingSessionRef.current = null;
// If session doesn't exist (404) or belongs to another user, create a new one
if (err?.status === 404 || err?.response?.status === 404) {
// Set recovery flag to prevent effect from running again during recovery
recoveringFromErrorRef.current = true;
clearSessionBase();
createSession()
.then((_newSessionId) => {
// Remove old session from URL, new session is in localStorage
router.replace("/chat");
// Clear recovery flag after a short delay to allow state to settle
setTimeout(() => {
recoveringFromErrorRef.current = false;
}, 500);
})
.catch((createErr) => {
console.error("Failed to create new session:", createErr);
recoveringFromErrorRef.current = false;
});
}
});
}
// Session belongs to a different user
else if (session.user_id !== user.id) {
// Set recovery flag to prevent effect from running again during recovery
recoveringFromErrorRef.current = true;
clearSessionBase();
createSession()
.then((_newSessionId) => {
// Remove old session from URL, new session is in localStorage
router.replace("/chat");
// Clear recovery flag after a short delay to allow state to settle
setTimeout(() => {
recoveringFromErrorRef.current = false;
}, 500);
})
.catch((createErr) => {
console.error("Failed to create new session:", createErr);
recoveringFromErrorRef.current = false;
});
}
},
[
user,
isUserLoading,
session?.id,
session?.user_id,
claimSession,
clearSessionBase,
createSession,
router,
],
);
// Note: Session claiming is handled explicitly by UI components when needed
// - Locally created sessions: backend sets user_id from JWT automatically
// - URL sessions: claiming happens in specific user flows, not automatically
// Monitor online/offline status
useEffect(function monitorNetworkStatus() {
@@ -213,11 +92,11 @@ export function useChatPage(): UseChatPageResult {
};
}, []);
const clearSession = () => {
function clearSession() {
clearSessionBase();
// Remove session from URL
router.push("/chat");
};
}
return {
session,
@@ -228,6 +107,6 @@ export function useChatPage(): UseChatPageResult {
createSession,
refreshSession,
clearSession,
sessionId: session?.id || null,
sessionId: sessionIdFromHook, // Use direct sessionId from hook, not derived from session.id
};
}

View File

@@ -1,9 +1,5 @@
import { Button } from "@/components/atoms/Button/Button";
import {
IconKey,
IconKeyPlus,
IconUserPlus,
} from "@/components/__legacy__/ui/icons";
import { Key, Plus, UserPlus } from "@phosphor-icons/react";
import {
Select,
SelectContent,
@@ -13,7 +9,11 @@ import {
SelectValue,
} from "@/components/__legacy__/ui/select";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
useGetV1InitiateOauthFlow,
usePostV1ExchangeOauthCodeForTokens,
} from "@/app/api/__generated__/endpoints/integrations/integrations";
import { LoginResponse } from "@/app/api/__generated__/models/loginResponse";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -21,7 +21,7 @@ import {
import { cn } from "@/lib/utils";
import { getHostFromUrl } from "@/lib/utils/url";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FC, useEffect, useMemo, useState } from "react";
import { FC, useEffect, useMemo, useState, useCallback, useRef } from "react";
import {
FaDiscord,
FaGithub,
@@ -128,9 +128,46 @@ export const CredentialsInput: FC<{
useState<AbortController | null>(null);
const [oAuthError, setOAuthError] = useState<string | null>(null);
const api = useBackendAPI();
const credentials = useCredentials(schema, siblingInputs);
// Use refs to track previous values and only recompute when they actually change
const providerRef = useRef("");
const scopesRef = useRef<string | undefined>(undefined);
// Compute current values
const currentProvider = credentials && "provider" in credentials ? credentials.provider : "";
const currentScopes = schema.credentials_scopes?.join(",");
// Only update refs when values actually change
if (currentProvider !== providerRef.current) {
providerRef.current = currentProvider;
}
if (currentScopes !== scopesRef.current) {
scopesRef.current = currentScopes;
}
// Use stable ref values for hooks
const stableProvider = providerRef.current;
const stableScopes = scopesRef.current;
// Setup OAuth hooks with generated API endpoints (only when provider is stable)
const {
refetch: initiateOauthFlow,
} = useGetV1InitiateOauthFlow(
stableProvider,
{
scopes: stableScopes,
},
{
query: {
enabled: false,
select: (res) => res.data as LoginResponse,
},
},
);
const { mutateAsync: oAuthCallbackMutation } = usePostV1ExchangeOauthCodeForTokens();
// Report loaded state to parent
useEffect(() => {
if (onLoaded) {
@@ -198,12 +235,17 @@ export const CredentialsInput: FC<{
oAuthCallback,
} = credentials;
async function handleOAuthLogin() {
const handleOAuthLogin = useCallback(async () => {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
provider,
schema.credentials_scopes,
);
// Use the generated API hook to initiate OAuth flow
const { data } = await initiateOauthFlow();
if (!data || !data.login_url || !data.state_token) {
setOAuthError("Failed to initiate OAuth flow");
return;
}
const { login_url, state_token } = data;
setOAuth2FlowInProgress(true);
const popup = window.open(login_url, "_blank", "popup=true");
@@ -248,14 +290,26 @@ export const CredentialsInput: FC<{
try {
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
id: credentials.id,
type: "oauth2",
title: credentials.title,
// Use the generated API hook for OAuth callback
const result = await oAuthCallbackMutation({
provider,
data: {
code: e.data.code,
state_token: e.data.state,
},
});
console.debug("OAuth callback processed successfully");
// Extract credential data from response
const credData = result.status === 200 ? result.data : null;
if (credData && "id" in credData) {
onSelectCredentials({
id: credData.id,
type: "oauth2",
title: ("title" in credData ? credData.title : undefined) || `${providerName} account`,
provider,
});
}
} catch (error) {
console.error("Error in OAuth callback:", error);
setOAuthError(
@@ -285,7 +339,7 @@ export const CredentialsInput: FC<{
},
5 * 60 * 1000,
);
}
}, [initiateOauthFlow, oAuthCallbackMutation, stableProvider, providerName, onSelectCredentials]);
const ProviderIcon = providerIcons[provider] || fallbackIcon;
const modals = (
@@ -444,7 +498,7 @@ export const CredentialsInput: FC<{
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
<Key className="mr-1.5 inline" size={16} />
{credentials.title}
</SelectItem>
))}
@@ -453,7 +507,7 @@ export const CredentialsInput: FC<{
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconUserPlus className="mr-1.5 inline" />
<UserPlus className="mr-1.5 inline" size={16} />
{credentials.title}
</SelectItem>
))}
@@ -462,32 +516,32 @@ export const CredentialsInput: FC<{
.map((credentials, index) => (
<SelectItem key={index} value={credentials.id}>
<ProviderIcon className="mr-2 inline h-4 w-4" />
<IconKey className="mr-1.5 inline" />
<Key className="mr-1.5 inline" size={16} />
{credentials.title}
</SelectItem>
))}
<SelectSeparator />
{supportsOAuth2 && (
<SelectItem value="sign-in">
<IconUserPlus className="mr-1.5 inline" />
<UserPlus className="mr-1.5 inline" size={16} />
Sign in with {providerName}
</SelectItem>
)}
{supportsApiKey && (
<SelectItem value="add-api-key">
<IconKeyPlus className="mr-1.5 inline" />
<Plus className="mr-1.5 inline" size={16} weight="bold" />
Add new API key
</SelectItem>
)}
{supportsUserPassword && (
<SelectItem value="add-user-password">
<IconUserPlus className="mr-1.5 inline" />
<UserPlus className="mr-1.5 inline" size={16} />
Add new user password
</SelectItem>
)}
{supportsHostScoped && (
<SelectItem value="add-host-scoped">
<IconKey className="mr-1.5 inline" />
<Key className="mr-1.5 inline" size={16} />
Add host-scoped headers
</SelectItem>
)}

View File

@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState, useRef } from "react";
import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
usePostV2CreateSession,
postV2CreateSession,
useGetV2GetSession,
usePatchV2SessionAssignUser,
getGetV2GetSessionQueryKey,
} from "@/app/api/__generated__/endpoints/chat/chat";
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
import { storage, Key } from "@/services/storage/local-storage";
import { isValidUUID } from "@/lib/utils";
interface UseChatSessionArgs {
urlSessionId?: string | null;
@@ -16,6 +19,7 @@ interface UseChatSessionArgs {
interface UseChatSessionResult {
session: SessionDetailResponse | null;
sessionId: string | null; // Direct access to session ID state
messages: SessionDetailResponse["messages"];
isLoading: boolean;
isCreating: boolean;
@@ -27,8 +31,6 @@ interface UseChatSessionResult {
clearSession: () => void;
}
const SESSION_STORAGE_KEY = "chat_session_id";
export function useChatSession({
urlSessionId,
autoCreate = false,
@@ -42,31 +44,27 @@ export function useChatSession({
useEffect(
function initializeSessionId() {
if (urlSessionId) {
// Validate UUID format (basic validation)
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(urlSessionId)) {
// Validate UUID format
if (!isValidUUID(urlSessionId)) {
console.error("Invalid session ID format:", urlSessionId);
toast.error("Invalid session ID", {
description:
"The session ID in the URL is not valid. Starting a new session...",
});
setSessionId(null);
localStorage.removeItem(SESSION_STORAGE_KEY);
storage.clean(Key.CHAT_SESSION_ID);
return;
}
setSessionId(urlSessionId);
localStorage.setItem(SESSION_STORAGE_KEY, urlSessionId);
storage.set(Key.CHAT_SESSION_ID, urlSessionId);
} else {
const storedSessionId = localStorage.getItem(SESSION_STORAGE_KEY);
const storedSessionId = storage.get(Key.CHAT_SESSION_ID);
if (storedSessionId) {
// Validate stored session ID as well
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(storedSessionId)) {
if (!isValidUUID(storedSessionId)) {
console.error("Invalid stored session ID:", storedSessionId);
localStorage.removeItem(SESSION_STORAGE_KEY);
storage.clean(Key.CHAT_SESSION_ID);
setSessionId(null);
} else {
setSessionId(storedSessionId);
@@ -87,7 +85,7 @@ export function useChatSession({
error: createError,
} = usePostV2CreateSession();
// Get session query (only runs when sessionId is set AND we didn't just create it)
// Get session query - runs for any valid session (URL or locally created)
const {
data: sessionData,
isLoading: isLoadingSession,
@@ -95,7 +93,7 @@ export function useChatSession({
refetch,
} = useGetV2GetSession(sessionId || "", {
query: {
enabled: !!sessionId && !!urlSessionId, // Only fetch if session ID came from URL
enabled: !!sessionId, // Fetch whenever we have a session ID
staleTime: 30000, // Consider data fresh for 30 seconds
retry: 1,
// Error handling is done in useChatPage via the error state
@@ -106,22 +104,28 @@ export function useChatSession({
const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
// Extract session from response with type guard
// For new sessions that we just created, create a minimal session object
// Only do this for sessions we JUST created, not for stale localStorage sessions
const session: SessionDetailResponse | null =
sessionData?.status === 200
? sessionData.data
: sessionId &&
!urlSessionId &&
sessionId === justCreatedSessionIdRef.current
? {
id: sessionId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
user_id: null,
messages: [],
}
: null;
// Once we have session data from the backend, use it
// While waiting for the first fetch, create a minimal object for just-created sessions
const session: SessionDetailResponse | null = useMemo(() => {
// If we have real session data from GET query, use it
if (sessionData?.status === 200) {
return sessionData.data;
}
// If we just created a session and are waiting for the first fetch, create a minimal object
// This prevents a blank page while the GET query loads
if (sessionId && justCreatedSessionIdRef.current === sessionId) {
return {
id: sessionId,
user_id: null, // Placeholder - actual value set by backend during creation
messages: [],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as SessionDetailResponse;
}
return null;
}, [sessionData, sessionId]);
const messages = session?.messages || [];
@@ -154,7 +158,10 @@ export function useChatSession({
async function createSession(): Promise<string> {
try {
setError(null);
const response = await createSessionMutation();
// Call the API function directly with empty body to satisfy Content-Type: application/json
const response = await postV2CreateSession({
body: JSON.stringify({}),
});
// Type guard to ensure we have a successful response
if (response.status !== 200) {
@@ -164,7 +171,7 @@ export function useChatSession({
const newSessionId = response.data.id;
setSessionId(newSessionId);
localStorage.setItem(SESSION_STORAGE_KEY, newSessionId);
storage.set(Key.CHAT_SESSION_ID, newSessionId);
// Mark this session as "just created" so we can create a minimal object for it
justCreatedSessionIdRef.current = newSessionId;
@@ -196,7 +203,7 @@ export function useChatSession({
try {
setError(null);
setSessionId(id);
localStorage.setItem(SESSION_STORAGE_KEY, id);
storage.set(Key.CHAT_SESSION_ID, id);
// Attempt to fetch the session to verify it exists
const result = await refetch();
@@ -204,7 +211,7 @@ export function useChatSession({
// If session doesn't exist (404), clear it and throw error
if (!result.data || result.isError) {
console.warn("Session not found on server, clearing local state");
localStorage.removeItem(SESSION_STORAGE_KEY);
storage.clean(Key.CHAT_SESSION_ID);
setSessionId(null);
throw new Error("Session not found");
}
@@ -220,7 +227,11 @@ export function useChatSession({
const refreshSession = useCallback(
async function refreshSession() {
if (!sessionId) return;
// Refresh session data from backend (works for all sessions now)
if (!sessionId) {
console.log("[refreshSession] Skipping - no session ID");
return;
}
try {
setError(null);
@@ -293,12 +304,13 @@ export function useChatSession({
const clearSession = useCallback(function clearSession() {
setSessionId(null);
setError(null);
localStorage.removeItem(SESSION_STORAGE_KEY);
storage.clean(Key.CHAT_SESSION_ID);
justCreatedSessionIdRef.current = null;
}, []);
return {
session,
sessionId, // Return direct access to sessionId state
messages,
isLoading,
isCreating,

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { toast } from "sonner";
import type { ToolArguments, ToolResult } from "@/types/chat";
@@ -8,7 +8,9 @@ const INITIAL_RETRY_DELAY = 1000; // 1 second
export interface StreamChunk {
type:
| "text_chunk"
| "text_ended"
| "tool_call"
| "tool_call_start"
| "tool_response"
| "login_needed"
| "credentials_needed"
@@ -24,6 +26,7 @@ export interface StreamChunk {
arguments?: ToolArguments;
result?: ToolResult;
success?: boolean;
idx?: number; // Index for tool_call_start
// Login needed fields
session_id?: string;
// Credentials needed fields
@@ -52,8 +55,15 @@ export function useChatStream(): UseChatStreamResult {
const eventSourceRef = useRef<EventSource | null>(null);
const retryCountRef = useRef<number>(0);
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const stopStreaming = useCallback(function stopStreaming() {
// Abort any ongoing operations
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -66,6 +76,13 @@ export function useChatStream(): UseChatStreamResult {
setIsStreaming(false);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
stopStreaming();
};
}, [stopStreaming]);
const sendMessage = useCallback(
async function sendMessage(
sessionId: string,
@@ -75,6 +92,15 @@ export function useChatStream(): UseChatStreamResult {
// Stop any existing stream
stopStreaming();
// Create abort controller for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Check if already aborted
if (abortController.signal.aborted) {
return Promise.reject(new Error("Request aborted"));
}
// Reset retry count for new message
retryCountRef.current = 0;
@@ -91,6 +117,12 @@ export function useChatStream(): UseChatStreamResult {
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
// Listen for abort signal
abortController.signal.addEventListener('abort', () => {
eventSource.close();
eventSourceRef.current = null;
});
// Handle incoming messages
eventSource.onmessage = function handleMessage(event) {
try {

View File

@@ -420,3 +420,10 @@ export function isEmpty(value: any): boolean {
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Validate UUID v4 format */
export function isValidUUID(value: string): boolean {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(value);
}

View File

@@ -8,6 +8,7 @@ export enum Key {
SHEPHERD_TOUR = "shepherd-tour",
WALLET_LAST_SEEN_CREDITS = "wallet-last-seen-credits",
LIBRARY_AGENTS_CACHE = "library-agents-cache",
CHAT_SESSION_ID = "chat_session_id",
}
function get(key: Key) {