mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -10,6 +10,7 @@ const nextConfig = {
|
||||
"upload.wikimedia.org",
|
||||
"storage.googleapis.com",
|
||||
|
||||
"example.com",
|
||||
"ideogram.ai", // for generated images
|
||||
"picsum.photos", // for placeholder images
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user