diff --git a/README.md b/README.md index 038932e0..87f9cc84 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This project contains the Agentuity documentation website, created using Fumadocs and running on NextJS 15. + To make the search feature work, you must set up `.env.local` with the following steps. ## Quick Start Guide diff --git a/app/(docs)/[[...slug]]/page.tsx b/app/(docs)/[[...slug]]/page.tsx index 1e04170f..16e4b763 100644 --- a/app/(docs)/[[...slug]]/page.tsx +++ b/app/(docs)/[[...slug]]/page.tsx @@ -19,6 +19,8 @@ import { source } from '@/lib/source'; import { CommunityButton } from '../../../components/Community'; import CopyPageDropdown from '../../../components/CopyPageDropdown'; import { NavButton } from '../../../components/NavButton'; +import CodeFromFiles from '../../../components/CodeFromFiles'; + export default async function Page(props: { params: Promise<{ slug?: string[] }>; @@ -56,6 +58,7 @@ export default async function Page(props: { PopupContent, PopupTrigger, CodeExample, + CodeFromFiles, CLICommand, CommunityButton, Mermaid, diff --git a/app/api/rag-search/route.ts b/app/api/rag-search/route.ts index 665a892f..1e8c40ed 100644 --- a/app/api/rag-search/route.ts +++ b/app/api/rag-search/route.ts @@ -1,5 +1,5 @@ import type { NextRequest } from 'next/server'; -import { getAgentConfig } from '@/lib/env'; +import { getAgentQaConfig } from '@/lib/env'; import { source } from '@/lib/source'; function documentPathToUrl(docPath: string): string { @@ -93,7 +93,7 @@ export async function GET(request: NextRequest) { } try { - const agentConfig = getAgentConfig(); + const agentConfig = getAgentQaConfig(); // Prepare headers const headers: Record = { diff --git a/app/api/sessions/[sessionId]/messages/route.ts b/app/api/sessions/[sessionId]/messages/route.ts new file mode 100644 index 00000000..523b43d1 --- /dev/null +++ b/app/api/sessions/[sessionId]/messages/route.ts @@ -0,0 +1,374 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getKVValue, setKVValue } from "@/lib/kv-store"; +import { + Session, + Message, + StreamingChunk, + TutorialData, +} from "@/app/chat/types"; +import { toISOString, getCurrentTimestamp } from "@/app/chat/utils/dateUtils"; +import { getAgentPulseConfig } from "@/lib/env"; +import { config } from "@/lib/config"; +import { parseAndValidateJSON, SessionMessageRequestSchema } from "@/lib/validation/middleware"; + +// Constants +const DEFAULT_CONVERSATION_HISTORY_LIMIT = 10; +const AGENT_REQUEST_TIMEOUT = 30000; // 30 seconds + + +function sanitizeTitle(input: string): string { + if (!input) return ''; + let s = input.trim(); + // Strip wrapping quotes/backticks + if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith('\'') && s.endsWith('\'')) || (s.startsWith('`') && s.endsWith('`'))) { + s = s.slice(1, -1).trim(); + } + // Remove markdown emphasis + s = s.replace(/\*\*([^*]+)\*\*|\*([^*]+)\*|__([^_]+)__|_([^_]+)_/g, (_m, a, b, c, d) => a || b || c || d || ''); + // Remove emojis (basic unicode emoji ranges) + s = s.replace(/[\u{1F300}-\u{1FAFF}\u{1F900}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, ''); + // Collapse whitespace + s = s.replace(/\s+/g, ' ').trim(); + // Sentence case + s = sentenceCase(s); + // Trim trailing punctuation noise + s = s.replace(/[\s\-–—:;,\.]+$/g, '').trim(); + // Enforce 60 chars + if (s.length > 60) s = s.slice(0, 60).trim(); + return s; +} + +function sentenceCase(str: string): string { + if (!str) return ''; + const lower = str.toLowerCase(); + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * POST /api/sessions/[sessionId]/messages - Add a message to a session and process with streaming + * + * This endpoint now handles: + * 1. Adding a user message to a session + * 2. Processing the message with the agent + * 3. Streaming the response back to the client + * 4. Saving the assistant's response when complete + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get("chat_user_id")?.value; + if (!userId) { + return NextResponse.json({ error: "User ID not found" }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + + const validation = await parseAndValidateJSON(request, SessionMessageRequestSchema); + + if (!validation.success) { + return validation.response; + } + + const { message, processWithAgent } = validation.data; + + // Ensure timestamp is in ISO string format + if (message.timestamp) { + message.timestamp = toISOString(message.timestamp); + } + const sessionKey = `${userId}_${sessionId}`; + const sessionResponse = await getKVValue(sessionKey, { + storeName: config.defaultStoreName, + }); + + // Helper: background title generation and persistence + async function generateAndPersistTitle(sessionId: string, sessionKey: string, finalSession: Session) { + try { + if ((finalSession as any).title) { + return; // Title already set + } + // Build compact conversation history (last 10 messages, truncate content) + const HISTORY_LIMIT = 10; + const MAX_CONTENT_LEN = 400; + const history = finalSession.messages + .slice(-HISTORY_LIMIT) + .map(m => ({ + author: m.author, + content: (m.content || '').slice(0, MAX_CONTENT_LEN), + })); + + const prompt = `Generate a very short session title summarizing the conversation topic.\n\nRequirements:\n- sentence case\n- no emojis\n- <= 60 characters\n- no quotes or markdown\n- output the title only, no extra text`; + + const agentConfig = getAgentPulseConfig(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (agentConfig.bearerToken) headers['Authorization'] = `Bearer ${agentConfig.bearerToken}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + let agentResponse: Response | null = null; + try { + agentResponse = await fetch(agentConfig.url, { + method: 'POST', + headers, + body: JSON.stringify({ + message: prompt, + conversationHistory: history, + use_direct_llm: true, + }), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (!agentResponse || !agentResponse.ok) { + console.error(`[title-gen] failed: bad response ${agentResponse ? agentResponse.status : 'no-response'}`); + return; + } + + const reader = agentResponse.body?.getReader(); + if (!reader) { + console.error('[title-gen] failed: no response body'); + return; + } + + let accumulated = ''; + const textDecoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) { + const text = textDecoder.decode(value); + for (const line of text.split('\n')) { + if (line.startsWith('data: ')) { + try { + const ev = JSON.parse(line.slice(6)); + if (ev.type === 'text-delta' && ev.textDelta) accumulated += ev.textDelta; + if (ev.type === 'finish') { + try { await reader.cancel(); } catch { } + break; + } + } catch { } + } + } + } + } + + const candidate = sanitizeTitle(accumulated); + const title = candidate || 'New chat'; + + // Re-fetch and set title only if still empty + const latest = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + if (!latest.success || !latest.data) return; + const current = latest.data as any; + if (current.title) return; + current.title = title; + await setKVValue(sessionKey, current, { storeName: config.defaultStoreName }); + + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('The operation was aborted') || msg.includes('aborted')) { + console.error('[title-gen] timeout after 3000ms'); + } else { + console.error(`[title-gen] failed: ${msg}`); + } + } + } + if (!sessionResponse.success || !sessionResponse.data) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const session = sessionResponse.data; + + const updatedSession: Session = { + ...session, + messages: [...session.messages, message], + }; + + try { + await setKVValue(sessionKey, updatedSession, { + storeName: config.defaultStoreName, + }); + } catch (error) { + console.error( + `Failed to save session after adding message. SessionId: ${sessionId}, Error details:`, + error instanceof Error ? error.message : String(error), + error instanceof Error && error.stack ? `Stack: ${error.stack}` : '' + ); + return NextResponse.json( + { + error: "Failed to save message to session", + details: "Unable to persist the message. Please try again." + }, + { status: 500 } + ); + } + + if (!processWithAgent || message.author !== "USER") { + return NextResponse.json( + { success: true, session: updatedSession }, + { status: 200 } + ); + } + + // Create assistant message placeholder for tracking + const assistantMessageId = crypto.randomUUID(); + + // Process with agent and stream response + const agentConfig = getAgentPulseConfig(); + const agentUrl = agentConfig.url; + + // Get current tutorial state for the user + const { TutorialStateManager } = await import('@/lib/tutorial/state-manager'); + const currentTutorialState = await TutorialStateManager.getCurrentTutorialState(userId); + + const agentPayload = { + message: message.content, + conversationHistory: updatedSession.messages.slice( + -DEFAULT_CONVERSATION_HISTORY_LIMIT + ), + tutorialData: currentTutorialState, + }; + + // Prepare headers with optional bearer token + const headers: Record = { + "Content-Type": "application/json", + }; + if (agentConfig.bearerToken) { + headers["Authorization"] = `Bearer ${agentConfig.bearerToken}`; + } + + // Real agent call (SSE response expected) + const agentResponse = await fetch(agentUrl, { + method: 'POST', + headers, + body: JSON.stringify(agentPayload), + signal: AbortSignal.timeout(AGENT_REQUEST_TIMEOUT), + }); + + if (!agentResponse.ok) { + throw new Error(`Agent responded with status: ${agentResponse.status}`); + } + + // Process streaming response + let accumulatedContent = ""; + let finalTutorialData: TutorialData | undefined = undefined; + + const transformStream = new TransformStream({ + async transform(chunk, controller) { + // Forward the chunk to the client + controller.enqueue(chunk); + + // Process the chunk to accumulate the full response + const text = new TextDecoder().decode(chunk); + const lines = text.split("\n"); + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)) as StreamingChunk; + + if (data.type === "text-delta" && data.textDelta) { + accumulatedContent += data.textDelta; + } else if (data.type === "tutorial-data" && data.tutorialData) { + finalTutorialData = data.tutorialData; + + // Update user's tutorial progress + await TutorialStateManager.updateTutorialProgress( + userId, + finalTutorialData.tutorialId, + finalTutorialData.currentStep, + finalTutorialData.totalSteps + ); + } else if (data.type === "finish") { + // When the stream is finished, save the assistant message + const assistantMessage: Message = { + id: assistantMessageId, + author: "ASSISTANT", + content: accumulatedContent, + timestamp: getCurrentTimestamp(), + tutorialData: finalTutorialData, + }; + + const finalSession = { + ...updatedSession, + messages: [...updatedSession.messages, assistantMessage], + }; + + await setKVValue(sessionKey, finalSession, { + storeName: config.defaultStoreName, + }); + + // Trigger background title generation if missing + // Do not await to avoid delaying the client stream completion + void generateAndPersistTitle(sessionId, sessionKey, finalSession); + + // Send the final session in the finish event + controller.enqueue( + new TextEncoder().encode( + `data: ${JSON.stringify({ + type: "finish", + session: finalSession, + })}\n\n` + ) + ); + } + } catch (error) { + console.error("Error processing stream chunk:", error); + } + } + } + }, + }); + + // Pipe the agent response through our transform stream + const reader = agentResponse.body?.getReader(); + if (!reader) { + throw new Error("No response body from agent"); + } + const writer = transformStream.writable.getWriter(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + try { + await writer.write(value); + } catch (writeError) { + console.error('Error writing to transform stream:', writeError); + throw writeError; + } + } + await writer.close(); + } catch (error) { + console.error('Error in stream processing:', error); + writer.abort(error); + } + })(); + + return new NextResponse(transformStream.readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Error in messages API:", error); + // Log the full error stack trace for debugging + if (error instanceof Error) { + console.error("Error stack:", error.stack); + } + return new Response( + JSON.stringify({ + error: "Internal server error", + details: error instanceof Error ? error.message : String(error), + }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +} diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts new file mode 100644 index 00000000..a3900650 --- /dev/null +++ b/app/api/sessions/[sessionId]/route.ts @@ -0,0 +1,266 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getKVValue, setKVValue, deleteKVValue } from '@/lib/kv-store'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; +import { toISOString } from '@/app/chat/utils/dateUtils'; +import { config } from '@/lib/config'; +import { parseAndValidateJSON, SessionMessageOnlyRequestSchema } from '@/lib/validation/middleware'; + +/** + * GET /api/sessions/[sessionId] - Get a specific session + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + const response = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + + if (!response.success) { + return NextResponse.json( + { error: response.error || 'Session not found' }, + { status: response.statusCode || 404 } + ); + } + + return NextResponse.json({ session: response.data }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * PUT /api/sessions/[sessionId] - Update a session + */ +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + + const validation = await parseAndValidateJSON(request, SessionSchema); + if (!validation.success) { + return validation.response; + } + + const session = validation.data; + + if (session.sessionId !== sessionId) { + return NextResponse.json( + { error: 'Session ID mismatch' }, + { status: 400 } + ); + } + + // Process any messages to ensure timestamps are in ISO string format + if (session.messages && session.messages.length > 0) { + session.messages = session.messages.map((message: Message) => { + if (message.timestamp) { + return { + ...message, + timestamp: toISOString(message.timestamp) + }; + } + return message; + }); + } + + // Update the individual session + const response = await setKVValue( + sessionKey, + session, + { storeName: config.defaultStoreName } + ); + + if (!response.success) { + return NextResponse.json( + { error: response.error || 'Failed to update session' }, + { status: response.statusCode || 500 } + ); + } + + // Update the master list if needed (ensure the session ID is in the list) + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // If the session ID isn't in the list, add it to the beginning + if (!sessionIds.includes(sessionKey)) { + const updatedSessionIds = [sessionKey, ...sessionIds]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + // Log the error but don't fail the request + console.error('Failed to update sessions list:', sessionsListResponse.error); + } + } + + return NextResponse.json({ success: true, session }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/sessions/[sessionId] - Delete a session + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + // Delete the session data + const sessionResponse = await deleteKVValue( + sessionKey, + { storeName: config.defaultStoreName } + ); + + if (!sessionResponse.success) { + return NextResponse.json( + { error: sessionResponse.error || 'Failed to delete session' }, + { status: sessionResponse.statusCode || 500 } + ); + } + + // Remove from sessions list + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + const updatedSessionIds = sessionIds.filter(id => id !== sessionKey); + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + return NextResponse.json( + { error: sessionsListResponse.error || 'Failed to update sessions list' }, + { status: sessionsListResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * POST /api/sessions/[sessionId]/messages - Add a message to a session + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const paramsData = await params; + const sessionId = paramsData.sessionId; + const sessionKey = `${userId}_${sessionId}`; + + const validation = await parseAndValidateJSON(request, SessionMessageOnlyRequestSchema); + + if (!validation.success) { + return validation.response; + } + + const { message } = validation.data; + + // Get current session + const sessionResponse = await getKVValue(sessionKey, { storeName: config.defaultStoreName }); + if (!sessionResponse.success || !sessionResponse.data) { + return NextResponse.json( + { error: 'Session not found' }, + { status: 404 } + ); + } + + const session = sessionResponse.data; + const updatedSession: Session = { + ...session, + messages: [...session.messages, message] + }; + + // Update the individual session + const updateResponse = await setKVValue( + sessionKey, + updatedSession, + { storeName: config.defaultStoreName } + ); + + if (!updateResponse.success) { + return NextResponse.json( + { error: updateResponse.error || 'Failed to update session' }, + { status: updateResponse.statusCode || 500 } + ); + } + + // Move this session ID to the top of the master list (most recently used) + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // Remove the current session ID if it exists and add it to the beginning + const filteredSessionIds = sessionIds.filter(id => id !== sessionKey); + const updatedSessionIds = [sessionKey, ...filteredSessionIds]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + // Log the error but don't fail the request since we already updated the individual session + console.error('Failed to update sessions list:', sessionsListResponse.error); + } + + return NextResponse.json({ success: true, session: updatedSession }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 00000000..bd6a97f2 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getKVValue, setKVValue } from '@/lib/kv-store'; +import { Session, Message, SessionSchema } from '@/app/chat/types'; +import { toISOString } from '@/app/chat/utils/dateUtils'; +import { config } from '@/lib/config'; +import { parseAndValidateJSON } from '@/lib/validation/middleware'; + +// Constants +const DEFAULT_SESSIONS_LIMIT = 10; +const MAX_SESSIONS_LIMIT = 50; + +/** + * GET /api/sessions - Get all sessions (paginated) + */ +export async function GET(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const searchParams = request.nextUrl.searchParams; + const parsedLimit = Number.parseInt(searchParams.get('limit') ?? String(DEFAULT_SESSIONS_LIMIT)); + const parsedCursor = Number.parseInt(searchParams.get('cursor') ?? '0'); + + const limit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), MAX_SESSIONS_LIMIT) : DEFAULT_SESSIONS_LIMIT; + const cursor = Number.isFinite(parsedCursor) ? Math.max(parsedCursor, 0) : 0; + + const response = await getKVValue(userId, { storeName: config.defaultStoreName }); + if (!response.success) { + if (response.statusCode === 404) { + return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); + } + return NextResponse.json( + { error: response.error || 'Failed to retrieve sessions' }, + { status: response.statusCode || 500 } + ); + } + + if (!response.data?.length) { + return NextResponse.json({ sessions: [], pagination: { cursor, nextCursor: null, hasMore: false, total: 0, limit } }); + } + + const sessionIds = response.data; + const total = sessionIds.length; + + const start = Math.min(cursor, total); + const end = Math.min(start + limit, total); + const pageIds = sessionIds.slice(start, end); + + const sessionPromises = pageIds.map(sessionId => getKVValue(sessionId, { storeName: config.defaultStoreName })); + const sessionResults = await Promise.all(sessionPromises); + const sessions = sessionResults + .filter(result => result.success && result.data) + .map(result => result.data as Session); + + const hasMore = end < total; + const nextCursor = hasMore ? end : null; + + return NextResponse.json({ + sessions, + pagination: { cursor: start, nextCursor, hasMore, total, limit } + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} + +/** + * POST /api/sessions - Create a new session + */ +export async function POST(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, SessionSchema); + if (!validation.success) { + return validation.response; + } + + const session = validation.data; + + // Process any messages to ensure timestamps are in ISO string format + if (session.messages && session.messages.length > 0) { + session.messages = session.messages.map((message: Message) => { + if (message.timestamp) { + return { + ...message, + timestamp: toISOString(message.timestamp) + }; + } + return message; + }); + } + + const sessionKey = `${userId}_${session.sessionId}`; + + // Save the session data + const sessionResponse = await setKVValue( + sessionKey, + session, + { storeName: config.defaultStoreName } + ); + + if (!sessionResponse.success) { + return NextResponse.json( + { error: sessionResponse.error || 'Failed to create session' }, + { status: sessionResponse.statusCode || 500 } + ); + } + + // Update the sessions list with just the session ID + const allSessionsResponse = await getKVValue(userId, { storeName: config.defaultStoreName }); + const sessionIds = allSessionsResponse.success ? allSessionsResponse.data || [] : []; + + // Add the new session ID to the beginning of the array + const updatedSessionIds = [sessionKey, ...sessionIds.filter(id => id !== sessionKey)]; + + const sessionsListResponse = await setKVValue( + userId, + updatedSessionIds, + { storeName: config.defaultStoreName } + ); + + if (!sessionsListResponse.success) { + return NextResponse.json( + { error: sessionsListResponse.error || 'Failed to update sessions list' }, + { status: sessionsListResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ + success: true, + session, + ...(session.title ? {} : { titleGeneration: 'pending' }) + }); + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error occurred' }, + { status: 500 } + ); + } +} diff --git a/app/api/users/tutorial-state/route.ts b/app/api/users/tutorial-state/route.ts new file mode 100644 index 00000000..70557fa7 --- /dev/null +++ b/app/api/users/tutorial-state/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { TutorialStateManager } from '@/lib/tutorial/state-manager'; +import { setKVValue } from '@/lib/kv-store'; +import { config } from '@/lib/config'; +import { + parseAndValidateJSON, + TutorialProgressRequestSchema, + TutorialResetRequestSchema +} from '@/lib/validation/middleware'; + +/** + * GET /api/users/tutorial-state - Get current user's tutorial state + */ +export async function GET(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const tutorialState = await TutorialStateManager.getUserTutorialState(userId); + + return NextResponse.json({ + success: true, + data: tutorialState + }); + } catch (error) { + console.error('Error getting tutorial state:', error); + return NextResponse.json( + { error: 'Failed to get tutorial state' }, + { status: 500 } + ); + } +} + +/** + * POST /api/users/tutorial-state - Update tutorial progress + */ +export async function POST(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, TutorialProgressRequestSchema); + if (!validation.success) { + return validation.response; + } + + const { tutorialId, currentStep, totalSteps } = validation.data; + + await TutorialStateManager.updateTutorialProgress( + userId, + tutorialId, + currentStep, + totalSteps + ); + + return NextResponse.json({ + success: true, + message: 'Tutorial progress updated' + }); + } catch (error) { + console.error('Error updating tutorial state:', error); + return NextResponse.json( + { error: 'Failed to update tutorial state' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/users/tutorial-state - Reset tutorial progress + */ +export async function DELETE(request: NextRequest) { + try { + const userId = request.cookies.get('chat_user_id')?.value; + if (!userId) { + return NextResponse.json({ error: 'User ID not found' }, { status: 401 }); + } + + const validation = await parseAndValidateJSON(request, TutorialResetRequestSchema); + if (!validation.success) { + return validation.response; + } + + const { tutorialId } = validation.data; + + const state = await TutorialStateManager.getUserTutorialState(userId); + if (!state.tutorials) { + state.tutorials = {}; + } + delete state.tutorials[tutorialId]; + + // Save the updated state + const kvResponse = await setKVValue(`tutorial_state_${userId}`, state, { + storeName: config.defaultStoreName + }); + + if (!kvResponse.success) { + return NextResponse.json( + { error: kvResponse.error || 'Failed to reset tutorial state' }, + { status: kvResponse.statusCode || 500 } + ); + } + + return NextResponse.json({ + success: true, + message: 'Tutorial progress reset' + }); + } catch (error) { + console.error('Error resetting tutorial state:', error); + return NextResponse.json( + { error: 'Failed to reset tutorial state' }, + { status: 500 } + ); + } +} diff --git a/app/chat/SessionContext.tsx b/app/chat/SessionContext.tsx new file mode 100644 index 00000000..a4a3b42b --- /dev/null +++ b/app/chat/SessionContext.tsx @@ -0,0 +1,21 @@ +'use client'; +import { createContext, useContext } from 'react'; +import { Session } from './types'; + +interface SessionContextType { + sessions: Session[]; + setSessions: (updater: React.SetStateAction, options?: { revalidate: boolean }) => void; + currentSessionId: string; + // A simple trigger to revalidate sessions; implementation may vary under the hood + revalidateSessions?: () => void | Promise; +} + +export const SessionContext = createContext(undefined); + +export const useSessions = () => { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSessions must be used within a SessionProvider'); + } + return context; +}; diff --git a/app/chat/[sessionId]/page.tsx b/app/chat/[sessionId]/page.tsx new file mode 100644 index 00000000..ef569ef4 --- /dev/null +++ b/app/chat/[sessionId]/page.tsx @@ -0,0 +1,233 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from "next/navigation"; +import { Allotment } from "allotment"; +import "allotment/dist/style.css"; +import { v4 as uuidv4 } from 'uuid'; +import { ChatMessagesArea } from '../components/ChatMessagesArea'; +import { CodeEditor } from '../components/CodeEditor'; +import { Session, Message } from '../types'; +import { useSessions } from '../SessionContext'; +import { sessionService } from '../services/sessionService'; +import { Skeleton } from '@/components/ui/skeleton'; + +export default function ChatSessionPage() { + const { sessionId } = useParams<{ sessionId: string }>(); + const [session, setSession] = useState(); + const [editorOpen, setEditorOpen] = useState(false); + const [editorContent, setEditorContent] = useState(''); + const { sessions, setSessions, revalidateSessions } = useSessions(); + const [creationError, setCreationError] = useState(null); + + + const handleSendMessage = async (content: string, sessionId: string) => { + if (!content || !sessionId) return; + + const newMessage: Message = { + id: uuidv4(), + author: 'USER', + content: content, + timestamp: new Date().toISOString() + }; + + const assistantMessage: Message = { + id: uuidv4(), + author: 'ASSISTANT', + content: '', + timestamp: new Date().toISOString() + }; + + try { + setSession(prevSession => { + if (!prevSession) return prevSession; + return { + ...prevSession, + messages: [...prevSession.messages, newMessage, assistantMessage] + }; + }); + + await sessionService.addMessageToSessionStreaming( + sessionId, + newMessage, + { + onTextDelta: (textDelta) => { + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => { + if (msg.id === assistantMessage.id) { + return { + ...msg, + content: msg.content + textDelta + }; + } + return msg; + }); + return { ...prev, messages: updatedMessages }; + }); + }, + + onTutorialData: (tutorialData) => { + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => + msg.id === assistantMessage.id + ? { ...msg, tutorialData: tutorialData } + : msg + ); + return { ...prev, messages: updatedMessages }; + }); + }, + + onFinish: (finalSession) => { + setSession(finalSession); + setSessions(prev => prev.map(s => s.sessionId === sessionId ? finalSession : s)); + }, + + onError: (error) => { + console.error('Error sending message:', error); + setSession(prev => { + if (!prev) return prev; + const updatedMessages = prev.messages.map(msg => + msg.id === assistantMessage.id + ? { ...msg, content: 'Sorry, I encountered an error. Please try again.' } + : msg + ); + return { ...prev, messages: updatedMessages }; + }); + } + } + ); + + } catch (error) { + console.error('Error sending message:', error); + setSession(prevSession => { + if (!prevSession) return prevSession; + const filteredMessages = prevSession.messages.filter(msg => + msg.id !== newMessage.id && msg.id !== assistantMessage.id + ); + return { ...prevSession, messages: filteredMessages }; + }); + } + }; + + useEffect(() => { + const foundSession = sessions.find(s => s.sessionId === sessionId); + if (foundSession) { + setSession(foundSession); + return; + } + + const storageKey = `initialMessage:${sessionId}`; + const initialMessage = sessionStorage.getItem(storageKey); + if (!initialMessage) { + return; + } + sessionStorage.removeItem(storageKey); + + const userMessage: Message = { + id: uuidv4(), + author: 'USER', + content: initialMessage, + timestamp: new Date().toISOString(), + }; + const assistantPlaceholder: Message = { + id: uuidv4(), + author: 'ASSISTANT', + content: '', + timestamp: new Date().toISOString(), + }; + const temporarySession: Session = { + sessionId: sessionId as string, + messages: [userMessage, assistantPlaceholder], + }; + + setSession(temporarySession); + + sessionService.createSession({ + sessionId: sessionId as string, + messages: [] + }) + .then(async response => { + if (response.success && response.data) { + setSession(response.data); + await handleSendMessage(initialMessage, sessionId); + } else { + setCreationError(response.error || 'Failed to create session'); + revalidateSessions?.(); + } + }) + .catch(error => { + setCreationError(error.message || 'Error creating session'); + revalidateSessions?.(); + }); + }, [sessionId]); + + + const toggleEditor = () => { setEditorOpen(false) }; + const stopServer = () => { }; + + return ( +
+ {/* Non-blocking error banner */} + {creationError && ( +
+
+ Error creating session: {creationError} + +
+
+ )} + + + +
+
+ {session ? ( + { setEditorOpen(true) }} + /> + ) : ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ )} +
+
+
+ {editorOpen && ( + +
+ { }} + stopServer={stopServer} + editorContent={editorContent} + setEditorContent={setEditorContent} + toggleEditor={toggleEditor} + /> +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/chat/components/ChatInput.tsx b/app/chat/components/ChatInput.tsx new file mode 100644 index 00000000..0422b2ac --- /dev/null +++ b/app/chat/components/ChatInput.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useEffect, KeyboardEvent, useState } from 'react'; +import { Send } from 'lucide-react'; +import { useAutoResize } from '../utils/useAutoResize'; + +interface ChatInputProps { + loading?: boolean; + onSendMessage: (message: string) => void; +} + +export function ChatInput({ + loading = false, + onSendMessage +}: ChatInputProps) { + const [currentInput, setCurrentInput] = useState(''); + const { textareaRef } = useAutoResize(currentInput, { maxHeight: 320 }); + + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + useEffect(() => { + if (!loading) { + textareaRef.current?.focus(); + } + }, [loading]); + + const handleSend = () => { + if (currentInput.trim() && !loading) { + onSendMessage(currentInput.trim()); + setCurrentInput(''); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + } + }; + + return ( +
+ {/* Textarea Container */} +
+
+