diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..2da375f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,170 @@ +--- +# Dependabot Configuration for ChartSmith +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + # Go Modules - Root + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "go" + commit-message: + prefix: "deps" + include: "scope" + # Group minor and patch updates together to reduce PR noise + groups: + go-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Go Modules - Helm Utils + - package-ecosystem: "gomod" + directory: "/helm-utils" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "go" + - "helm-utils" + commit-message: + prefix: "deps(helm-utils)" + include: "scope" + groups: + helm-utils-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Go Modules - Dagger + - package-ecosystem: "gomod" + directory: "/dagger" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "go" + - "dagger" + commit-message: + prefix: "deps(dagger)" + include: "scope" + groups: + dagger-dependencies: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # NPM - ChartSmith App + - package-ecosystem: "npm" + directory: "/chartsmith-app" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "npm" + - "chartsmith-app" + commit-message: + prefix: "deps(app)" + include: "scope" + # Ignore major version updates for React and Next.js (handle manually) + ignore: + - dependency-name: "react" + update-types: ["version-update:semver-major"] + - dependency-name: "react-dom" + update-types: ["version-update:semver-major"] + - dependency-name: "next" + update-types: ["version-update:semver-major"] + # Group non-breaking updates to reduce PR noise + groups: + react-ecosystem: + patterns: + - "react*" + - "@types/react*" + update-types: + - "minor" + - "patch" + development-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + production-dependencies: + dependency-type: "production" + update-types: + - "patch" + + # NPM - ChartSmith Extension + - package-ecosystem: "npm" + directory: "/chartsmith-extension" + schedule: + interval: "weekly" + day: "tuesday" + time: "09:00" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "npm" + - "chartsmith-extension" + commit-message: + prefix: "deps(extension)" + include: "scope" + # Ignore major version updates for React (handle manually) + ignore: + - dependency-name: "react" + update-types: ["version-update:semver-major"] + - dependency-name: "react-dom" + update-types: ["version-update:semver-major"] + groups: + react-ecosystem: + patterns: + - "react*" + - "@types/react*" + update-types: + - "minor" + - "patch" + development-dependencies: + dependency-type: "development" + update-types: + - "minor" + - "patch" + + # GitHub Actions (if/when workflows are added) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "wednesday" + time: "09:00" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" + groups: + github-actions: + patterns: + - "*" diff --git a/.gitignore b/.gitignore index 7b10e193..5022b9a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ test-results/ .specstory/ chart/chartsmith/*.tgz .direnv/ + +# Local setup documentation and scripts (not committed to git) +docs/ +scripts/ +.env diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d1d8d590..481b0942 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -24,4 +24,26 @@ It's made for both the developer working on it and for AI models to read and app ## Workers - The go code is where we put all workers. - Jobs for workers are enqueued and scheduled using postgres notify and a work_queue table. -- Status from the workers is communicated via Centrifugo messages to the client. \ No newline at end of file +- Status from the workers is communicated via Centrifugo messages to the client. + +## Chat & LLM Integration + +Chartsmith uses the Vercel AI SDK for all conversational chat functionality. +The Go worker outputs AI SDK Data Stream Protocol format, which the frontend +consumes via the useChat hook. + +### Architecture +- Frontend: useChat hook manages chat state +- API Route: /api/chat proxies to Go worker +- Backend: Go worker outputs AI SDK protocol (HTTP SSE) +- Streaming: Server-Sent Events instead of WebSocket + +### Key Components +- pkg/llm/aisdk.go: Adapter for AI SDK protocol +- pkg/api/chat.go: HTTP endpoint for chat streaming +- chartsmith-app/hooks/useAIChat.ts: Frontend hook wrapper +- chartsmith-app/app/api/chat/route.ts: Next.js API route + +### Note on Centrifugo +Centrifugo is still used for non-chat events (plans, renders, artifacts). +Chat messages flow exclusively through the AI SDK HTTP SSE protocol. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f9479fff..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,12 +0,0 @@ -See the following files for details: - -ARCHITECTURE.md: Our core design principles -chartsmith-app/ARCHITECTURE.md: Our design principles for the frontend - -CONTRIBUTING.md: How to run and test this project -chartsmith-app/CONTRIBUTING.md: How to run and test the frontend - -- `/chart.go:106:2: declared and not used: repoUrl -pkg/workspace/chart.go:169:41: cannot use conn (variable of type *pgxpool.Conn) as *pgxpool.Pool value in argument to updatePublishStatus -pkg/workspace/chart.go:178:40: cannot use conn (variable of type *pgxpool.Conn) as *pgxpool.Pool value in argument to updatePublishStatus -make: *** [build] Error 1` \ No newline at end of file diff --git a/chartsmith-app/ARCHITECTURE.md b/chartsmith-app/ARCHITECTURE.md index 93e7d7b4..2f7f4899 100644 --- a/chartsmith-app/ARCHITECTURE.md +++ b/chartsmith-app/ARCHITECTURE.md @@ -21,3 +21,28 @@ This is a next.js project that is the front end for chartsmith. - We aren't using Next.JS API routes, except when absolutely necessary. - Front end should call server actions, which call lib/* functions. - Database queries are not allowed in the server action. Server actions are just wrappers for which lib functions we expose. + +## Chat & LLM Integration + +Chartsmith uses the Vercel AI SDK for all chat functionality: + +- **Frontend**: `useChat` hook from `@ai-sdk/react` manages chat state +- **API Route**: `/api/chat` Next.js route proxies to Go worker +- **Backend**: Go worker outputs AI SDK Data Stream Protocol (HTTP SSE) +- **Streaming**: Server-Sent Events (SSE) instead of WebSocket +- **State**: Managed by AI SDK hook, integrated with Jotai for workspace state + +### Flow +``` +User Input → ChatContainer → useAIChat → /api/chat → Go Worker → AI SDK Protocol → useChat → UI +``` + +### Key Components +- `useAIChat`: Wraps `useChat` with Chartsmith-specific logic +- `/api/chat`: Next.js API route that proxies to Go worker +- `pkg/llm/aisdk.go`: Go adapter for AI SDK protocol +- `pkg/api/chat.go`: HTTP endpoint for chat streaming + +### Note on Centrifugo +Centrifugo is still used for non-chat events (plans, renders, artifacts). +Chat messages flow exclusively through the AI SDK HTTP SSE protocol. diff --git a/chartsmith-app/app/api/auth/test-auth/route.ts b/chartsmith-app/app/api/auth/test-auth/route.ts new file mode 100644 index 00000000..d9670658 --- /dev/null +++ b/chartsmith-app/app/api/auth/test-auth/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { validateTestAuth } from '@/lib/auth/actions/test-auth'; +import { logger } from '@/lib/utils/logger'; + +export async function GET(request: NextRequest) { + // Only allow in development/test mode + if (process.env.NODE_ENV === 'production') { + return NextResponse.json({ error: 'Test auth not allowed in production' }, { status: 403 }); + } + + if (process.env.ENABLE_TEST_AUTH !== 'true' && process.env.NEXT_PUBLIC_ENABLE_TEST_AUTH !== 'true') { + return NextResponse.json({ error: 'Test auth not enabled' }, { status: 403 }); + } + + try { + logger.debug('Test auth API called'); + const jwt = await validateTestAuth(); + + if (!jwt) { + return NextResponse.json({ error: 'Failed to generate test token' }, { status: 500 }); + } + + logger.debug('Test auth successful, setting cookie via API', { jwtLength: jwt.length }); + + // Check if this is a programmatic request (e.g., from Playwright) that wants the JWT + const wantsJson = request.headers.get('accept')?.includes('application/json') || + request.nextUrl.searchParams.get('format') === 'json'; + + if (wantsJson) { + // Return JWT in JSON for programmatic access (e.g., Playwright tests) + return NextResponse.json({ + token: jwt, + redirect: '/' + }); + } + + // Set cookie expiration + const expires = new Date(); + expires.setDate(expires.getDate() + 7); + + // Create redirect response + const redirectUrl = new URL('/', request.url); + const response = NextResponse.redirect(redirectUrl); + + // Try both methods: cookies API and manual header + // Method 1: Use cookies() API + response.cookies.set('session', jwt, { + expires, + path: '/', + sameSite: 'lax', + httpOnly: false, + }); + + // Method 2: Also manually set header as backup + const cookieValue = `session=${jwt}; Path=/; SameSite=Lax; Expires=${expires.toUTCString()}`; + const existingSetCookie = response.headers.get('Set-Cookie'); + if (existingSetCookie) { + // Append if header already exists + response.headers.set('Set-Cookie', `${existingSetCookie}, ${cookieValue}`); + } else { + response.headers.set('Set-Cookie', cookieValue); + } + + logger.debug('Cookie set via both methods', { + jwtLength: jwt.length, + jwtPrefix: jwt.substring(0, 30) + '...', + setCookieHeader: response.headers.get('Set-Cookie')?.substring(0, 150) || 'none' + }); + + return response; + } catch (error) { + logger.error('Test auth API failed', { error }); + return NextResponse.json({ + error: 'Test authentication failed', + details: error instanceof Error ? error.message : String(error) + }, { status: 500 }); + } +} diff --git a/chartsmith-app/app/api/chat/route.ts b/chartsmith-app/app/api/chat/route.ts new file mode 100644 index 00000000..593214b3 --- /dev/null +++ b/chartsmith-app/app/api/chat/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { findSession } from '@/lib/auth/session'; +import { cookies } from 'next/headers'; +import { getTestAuthTokenFromHeaders, validateTestAuthToken, isTestAuthBypassEnabled } from '@/lib/auth/test-auth-bypass'; +import { getGoWorkerUrl } from '@/lib/utils/go-worker'; +import { z } from 'zod'; + +export const dynamic = 'force-dynamic'; + +/** + * Zod schema for validating chat request body. + * Provides strict validation for security and data integrity. + */ +const ChatMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system']), + content: z.string().max(100000, 'Message content too large'), // 100KB limit per message +}); + +const ChatRequestSchema = z.object({ + messages: z.array(ChatMessageSchema).min(1, 'At least one message is required').max(100, 'Too many messages'), + workspaceId: z.string().uuid('Invalid workspace ID format'), + role: z.enum(['auto', 'developer', 'operator']).optional(), +}); + +/** + * @fileoverview Next.js API route that proxies chat requests to Go backend. + * + * This route acts as a bridge between the frontend useChat hook and + * the Go backend. It handles authentication, request validation, and + * streams responses in AI SDK Data Stream Protocol format (HTTP SSE). + * + * Authentication supports both: + * - Cookie-based auth (web): Reads session cookie + * - Bearer token auth (extension): Reads Authorization header + * + * @see https://sdk.vercel.ai/docs/ai-sdk-ui/stream-protocol + */ + +/** + * POST /api/chat + * + * Proxies chat requests to the Go backend and streams the response. + * Used by the useChat hook from @ai-sdk/react. + * + * Request body: + * ```json + * { + * "messages": [...], // AI SDK message format + * "workspaceId": "string", + * "role": "auto" | "developer" | "operator" + * } + * ``` + * + * Response: Streaming Server-Sent Events (SSE) with AI SDK Data Stream Protocol + * + * @param req - Next.js request object with chat messages + * @returns Streaming response with AI SDK Data Stream Protocol (text/event-stream) + * + * @example + * ```typescript + * const response = await fetch('/api/chat', { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/json', + * }, + * body: JSON.stringify({ + * messages: [{ role: 'user', content: 'Hello' }], + * workspaceId: 'workspace-123', + * }), + * }); + * ``` + */ +export async function POST(req: NextRequest) { + // Authenticate: try test auth bypass first (test mode only), then cookies (web), then authorization header (extension) + let userId: string | undefined; + + try { + // TEST AUTH BYPASS: Check for test auth header first (only in non-production test mode) + // Double-check NODE_ENV here as defense-in-depth + if (process.env.NODE_ENV !== 'production' && isTestAuthBypassEnabled()) { + const testAuthToken = getTestAuthTokenFromHeaders(req.headers); + if (testAuthToken) { + const authResult = await validateTestAuthToken(testAuthToken); + if (authResult) { + const session = await findSession(authResult.token); + if (session?.user?.id) { + userId = session.user.id; + } + } + } + } + + // Try to get session from cookies (web-based auth) + if (!userId) { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session')?.value; + + if (sessionToken) { + const session = await findSession(sessionToken); + if (session?.user?.id) { + userId = session.user.id; + } + } + } + + // Fall back to authorization header (extension-based auth) + if (!userId) { + const authHeader = req.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const session = await findSession(token); + if (session?.user?.id) { + userId = session.user.id; + } + } + } + } catch { + // Auth error - continue to check userId below + } + + if (!userId) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Parse and validate request body with Zod + let validatedBody: z.infer; + try { + const rawBody = await req.json(); + validatedBody = ChatRequestSchema.parse(rawBody); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation error', details: error.issues.map(issue => issue.message) }, + { status: 400 } + ); + } + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ); + } + + const { messages, workspaceId, role } = validatedBody; + + // Get Go worker URL (from env var, database param, or localhost default) + const goWorkerUrl = await getGoWorkerUrl(); + + // Forward request to Go backend and stream response back + try { + const response = await fetch(`${goWorkerUrl}/api/v1/chat/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages, + workspaceId, + userId, + role: role || 'auto', + }), + }); + + if (!response.ok) { + return NextResponse.json( + { error: 'Backend error' }, + { status: response.status } + ); + } + + if (!response.body) { + return NextResponse.json( + { error: 'No response body from backend' }, + { status: 500 } + ); + } + + // Stream the response back as Server-Sent Events (SSE) + // The Go backend outputs AI SDK Data Stream Protocol format + return new Response(response.body, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } catch { + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/chartsmith-app/app/api/prompt-type/route.ts b/chartsmith-app/app/api/prompt-type/route.ts new file mode 100644 index 00000000..27a05869 --- /dev/null +++ b/chartsmith-app/app/api/prompt-type/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { findSession } from '@/lib/auth/session'; +import { cookies } from 'next/headers'; +import { getGoWorkerUrl } from '@/lib/utils/go-worker'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/prompt-type + * + * Proxies prompt type classification requests to the Go backend. + * Classifies a user message as either "plan" or "chat". + * + * @param req - Next.js request object + * @returns JSON response with classification result + */ +export async function POST(req: NextRequest) { + // Authenticate - try cookies first (for web), then authorization header (for extension) + let userId: string | undefined; + + try { + // Try to get session from cookies (web-based auth) + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session')?.value; + + if (sessionToken) { + const session = await findSession(sessionToken); + if (session?.user?.id) { + userId = session.user.id; + } + } + + // Fall back to authorization header (extension-based auth) + if (!userId) { + const authHeader = req.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const session = await findSession(token); + if (session?.user?.id) { + userId = session.user.id; + } + } + } + } catch (error) { + console.error('Auth error:', error); + // Continue to check userId below + } + + if (!userId) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Parse and validate request body + let body; + try { + body = await req.json(); + } catch (error) { + return NextResponse.json( + { error: 'Invalid request body' }, + { status: 400 } + ); + } + + const { message } = body; + + if (!message || typeof message !== 'string') { + return NextResponse.json( + { error: 'Message is required' }, + { status: 400 } + ); + } + + // Get Go worker URL + const goWorkerUrl = await getGoWorkerUrl(); + + // Forward to Go backend + try { + const response = await fetch(`${goWorkerUrl}/api/prompt-type`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ message }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Go backend error:', response.status, errorText); + return NextResponse.json( + { error: 'Failed to classify prompt type' }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error in prompt-type API route:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/chartsmith-app/app/api/workspace/[workspaceId]/messages/[messageId]/route.ts b/chartsmith-app/app/api/workspace/[workspaceId]/messages/[messageId]/route.ts new file mode 100644 index 00000000..8096600c --- /dev/null +++ b/chartsmith-app/app/api/workspace/[workspaceId]/messages/[messageId]/route.ts @@ -0,0 +1,98 @@ +import { userIdFromExtensionToken } from "@/lib/auth/extension-token"; +import { findSession } from "@/lib/auth/session"; +import { getDB } from "@/lib/data/db"; +import { getParam } from "@/lib/data/param"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Extract workspaceId and messageId from the request URL + */ +function getIdsFromRequest(req: NextRequest): { workspaceId: string | null; messageId: string | null } { + const pathSegments = req.nextUrl.pathname.split('/'); + const messageId = pathSegments.pop() || null; + pathSegments.pop(); // Remove 'messages' + const workspaceId = pathSegments.pop() || null; + return { workspaceId, messageId }; +} + +/** + * Get userId from request - supports both cookie-based and extension token auth + */ +async function getUserIdFromRequest(req: NextRequest): Promise { + // Try cookie-based auth first (for web) + try { + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session')?.value; + + if (sessionToken) { + const session = await findSession(sessionToken); + if (session?.user?.id) { + return session.user.id; + } + } + } catch (error) { + // Continue to try extension token + } + + // Fall back to extension token auth + const authHeader = req.headers.get('authorization'); + if (authHeader) { + try { + const token = authHeader.split(' ')[1]; + const userId = await userIdFromExtensionToken(token); + return userId || null; + } catch (error) { + // Ignore + } + } + + return null; +} + +/** + * PATCH /api/workspace/[workspaceId]/messages/[messageId] + * Update an existing chat message (typically to add/update the response) + */ +export async function PATCH(req: NextRequest) { + try { + const userId = await getUserIdFromRequest(req); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { workspaceId, messageId } = getIdsFromRequest(req); + if (!workspaceId || !messageId) { + return NextResponse.json( + { error: 'Workspace ID and Message ID are required' }, + { status: 400 } + ); + } + + const body = await req.json(); + const { response } = body; + + if (response === undefined) { + return NextResponse.json( + { error: 'Response field is required' }, + { status: 400 } + ); + } + + // Update the message in the database + const db = getDB(await getParam("DB_URI")); + await db.query( + `UPDATE workspace_chat SET response = $1 WHERE id = $2 AND workspace_id = $3`, + [response, messageId, workspaceId] + ); + + return NextResponse.json({ success: true }); + + } catch (error) { + console.error('Failed to update message:', error); + return NextResponse.json( + { error: 'Failed to update message' }, + { status: 500 } + ); + } +} diff --git a/chartsmith-app/app/api/workspace/[workspaceId]/messages/route.ts b/chartsmith-app/app/api/workspace/[workspaceId]/messages/route.ts index 81ae6bd3..435c4ee8 100644 --- a/chartsmith-app/app/api/workspace/[workspaceId]/messages/route.ts +++ b/chartsmith-app/app/api/workspace/[workspaceId]/messages/route.ts @@ -1,31 +1,72 @@ import { userIdFromExtensionToken } from "@/lib/auth/extension-token"; +import { findSession } from "@/lib/auth/session"; import { listMessagesForWorkspace } from "@/lib/workspace/chat"; +import { createChatMessage } from "@/lib/workspace/workspace"; +import { getDB } from "@/lib/data/db"; +import { getParam } from "@/lib/data/param"; +import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; -export async function GET(req: NextRequest) { +/** + * Extract workspaceId from the request URL + */ +function getWorkspaceId(req: NextRequest): string | null { + const pathSegments = req.nextUrl.pathname.split('/'); + pathSegments.pop(); // Remove the last segment (e.g., 'messages') + return pathSegments.pop() || null; +} + +/** + * Get userId from request - supports both cookie-based and extension token auth + */ +async function getUserIdFromRequest(req: NextRequest): Promise { + // Try cookie-based auth first (for web) try { - // if there's an auth header, use that to find the user - const authHeader = req.headers.get('authorization'); - if (!authHeader) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const cookieStore = await cookies(); + const sessionToken = cookieStore.get('session')?.value; + + if (sessionToken) { + const session = await findSession(sessionToken); + if (session?.user?.id) { + return session.user.id; + } } + } catch (error) { + // Continue to try extension token + } + + // Fall back to extension token auth + const authHeader = req.headers.get('authorization'); + if (authHeader) { + try { + const token = authHeader.split(' ')[1]; + const userId = await userIdFromExtensionToken(token); + return userId || null; + } catch (error) { + // Ignore + } + } - const userId = await userIdFromExtensionToken(authHeader.split(' ')[1]) + return null; +} +/** + * GET /api/workspace/[workspaceId]/messages + * Load chat history for a workspace + */ +export async function GET(req: NextRequest) { + try { + const userId = await getUserIdFromRequest(req); if (!userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - // Use URLPattern to extract workspaceId - const pathSegments = req.nextUrl.pathname.split('/'); - pathSegments.pop(); // Remove the last segment (e.g., 'messages') - const workspaceId = pathSegments.pop(); // Get the workspaceId + const workspaceId = getWorkspaceId(req); if (!workspaceId) { return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }); } const messages = await listMessagesForWorkspace(workspaceId); - return NextResponse.json(messages); } catch (err) { @@ -35,4 +76,40 @@ export async function GET(req: NextRequest) { { status: 500 } ); } +} + +/** + * POST /api/workspace/[workspaceId]/messages + * Save a new chat message + */ +export async function POST(req: NextRequest) { + try { + const userId = await getUserIdFromRequest(req); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const workspaceId = getWorkspaceId(req); + if (!workspaceId) { + return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }); + } + + const body = await req.json(); + const { prompt, response } = body; + + // Create the message using the existing function + const chatMessage = await createChatMessage(userId, workspaceId, { + prompt: prompt || undefined, + response: response || undefined, + }); + + return NextResponse.json({ id: chatMessage.id }); + + } catch (error) { + console.error('Failed to save message:', error); + return NextResponse.json( + { error: 'Failed to save message' }, + { status: 500 } + ); + } } \ No newline at end of file diff --git a/chartsmith-app/app/auth/google/page.tsx b/chartsmith-app/app/auth/google/page.tsx index d4d6da04..6fe1ea61 100644 --- a/chartsmith-app/app/auth/google/page.tsx +++ b/chartsmith-app/app/auth/google/page.tsx @@ -23,27 +23,62 @@ function GoogleCallback() { exchangeGoogleCodeForSession(code) .then((jwt) => { try { - const payload = JSON.parse(atob(jwt.split('.')[1])); - console.log(payload); - if (payload.isWaitlisted) { - window.opener?.postMessage({ type: 'google-auth', jwt }, window.location.origin); - if (window.opener) { + // Try to parse JWT to check waitlist status + let isWaitlisted = false; + try { + const payload = JSON.parse(atob(jwt.split('.')[1])); + console.log("JWT payload:", payload); + isWaitlisted = payload.isWaitlisted === true; + } catch (e) { + // If it's a test token, try to extract info differently + if (jwt.startsWith('test-token-')) { + console.log("Test token received, checking waitlist status..."); + } else { + logger.error("Failed to parse JWT:", e); + } + } + + // Set cookie first + const expires = new Date(); + expires.setDate(expires.getDate() + 7); + document.cookie = `session=${jwt}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; + + // Handle popup vs direct navigation + if (window.opener) { + // Popup window - send message to opener + window.opener.postMessage({ type: 'google-auth', jwt }, window.location.origin); + if (isWaitlisted) { window.opener.location.href = '/waitlist'; - window.close(); } else { + window.opener.location.href = '/'; + } + window.close(); + } else { + // Direct navigation - redirect directly + if (isWaitlisted) { router.push('/waitlist'); + } else { + router.push('/'); } - return; } } catch (e) { - logger.error("Failed to parse JWT:", e); + logger.error("Failed to handle auth response:", e); + if (window.opener) { + window.opener.postMessage({ type: 'google-auth', error: true }, window.location.origin); + window.close(); + } else { + router.push('/login?error=auth_failed'); + } } - - window.opener?.postMessage({ type: 'google-auth', jwt }, window.location.origin); }) .catch((error) => { logger.error("Auth Error:", error); - window.opener?.postMessage({ type: 'google-auth', error: true }, window.location.origin); + if (window.opener) { + window.opener.postMessage({ type: 'google-auth', error: true }, window.location.origin); + window.close(); + } else { + router.push('/login?error=auth_failed'); + } }); }, [searchParams, router]); diff --git a/chartsmith-app/app/hooks/useSession.ts b/chartsmith-app/app/hooks/useSession.ts index 59082020..486fe27b 100644 --- a/chartsmith-app/app/hooks/useSession.ts +++ b/chartsmith-app/app/hooks/useSession.ts @@ -51,37 +51,103 @@ export const useSession = (redirectIfNotLoggedIn: boolean = false) => { const router = useRouter(); useEffect(() => { - const token = document.cookie - .split("; ") - .find((cookie) => cookie.startsWith("session=")) - ?.split("=")[1]; + const getCookieValue = (name: string): string | undefined => { + const cookies = document.cookie.split("; "); + const cookie = cookies.find((c) => c.trim().startsWith(`${name}=`)); + if (!cookie) return undefined; + + // Get the value after the = sign + const value = cookie.split("=").slice(1).join("="); + // URL decode the value (cookies might be encoded) + try { + return decodeURIComponent(value); + } catch { + return value; + } + }; - if (!token) { - setIsLoading(false); - return; - } + let mounted = true; // Track if component is still mounted - const validate = async (token: string) => { - try { - const sess = await validateSession(token); - if (!sess && redirectIfNotLoggedIn) { - router.replace("/"); - return; + // In test mode, try to get token from cookie, but if not found, wait a bit + // (middleware might be setting it asynchronously) + const checkForSession = async () => { + let token = getCookieValue("session"); + + // If no token and we're in test mode, wait a bit for middleware to set it + // But only check once to avoid infinite loops + if (!token && process.env.NEXT_PUBLIC_ENABLE_TEST_AUTH === 'true') { + logger.debug("No session cookie found in test mode, waiting..."); + // Wait up to 2 seconds for cookie to appear (only once) + for (let i = 0; i < 4 && mounted; i++) { + await new Promise(resolve => setTimeout(resolve, 500)); + if (!mounted) return; // Component unmounted, stop + token = getCookieValue("session"); + if (token) { + logger.debug("Session cookie found after waiting"); + break; + } } + } - setSession(sess); - setIsLoading(false); - } catch (error) { - logger.error("Session validation failed:", error); - if (redirectIfNotLoggedIn) { - router.replace("/"); + if (!mounted) return; // Component unmounted during wait + + if (!token) { + logger.debug("No session cookie found", { + allCookies: document.cookie, + cookieCount: document.cookie.split(';').filter(c => c.trim()).length + }); + if (mounted) { + setIsLoading(false); } - setIsLoading(false); + return; } + + logger.debug("Found session cookie, validating...", { tokenPrefix: token.substring(0, 30) + '...' }); + + const validate = async (token: string) => { + if (!mounted) return; // Check again before async operation + + try { + const sess = await validateSession(token); + if (!mounted) return; // Check after async operation + + if (!sess) { + logger.warn("Session validation returned undefined", { tokenPrefix: token.substring(0, 30) + '...' }); + if (redirectIfNotLoggedIn && mounted) { + router.replace("/"); + } + if (mounted) { + setIsLoading(false); + } + return; + } + + logger.debug("Session validated successfully", { userId: sess.user.id, email: sess.user.email }); + if (mounted) { + setSession(sess); + setIsLoading(false); + } + } catch (error) { + if (!mounted) return; + logger.error("Session validation failed:", error); + if (redirectIfNotLoggedIn && mounted) { + router.replace("/"); + } + if (mounted) { + setIsLoading(false); + } + } + }; + + validate(token); }; - validate(token); - }, [router, redirectIfNotLoggedIn]); + checkForSession(); + + return () => { + mounted = false; // Cleanup: mark as unmounted + }; + }, [router, redirectIfNotLoggedIn]); // Only run once on mount return { isLoading, diff --git a/chartsmith-app/app/login/page.tsx b/chartsmith-app/app/login/page.tsx index e6aac102..73fdfd7a 100644 --- a/chartsmith-app/app/login/page.tsx +++ b/chartsmith-app/app/login/page.tsx @@ -36,14 +36,10 @@ export default function LoginPage() { // Check for test auth parameter const params = new URLSearchParams(window.location.search); if (params.get('test-auth') === 'true') { - validateTestAuth().then((jwt) => { - if (jwt) { - const expires = new Date(); - expires.setDate(expires.getDate() + 7); - document.cookie = `session=${jwt}; expires=${expires.toUTCString()}; path=/; SameSite=Lax`; - window.location.href = '/'; - } - }); + console.log('Test auth detected, redirecting to API endpoint...'); + // Use server-side API route to set cookie properly + window.location.href = '/api/auth/test-auth'; + return; } } }, [publicEnv.NEXT_PUBLIC_ENABLE_TEST_AUTH]); diff --git a/chartsmith-app/components/ChatContainer.tsx b/chartsmith-app/components/ChatContainer.tsx index 5761674a..3235a660 100644 --- a/chartsmith-app/components/ChatContainer.tsx +++ b/chartsmith-app/components/ChatContainer.tsx @@ -1,15 +1,31 @@ +/** + * @fileoverview Chat container component that manages chat UI and state. + * + * This component uses the Vercel AI SDK's useChat hook (via useAIChat wrapper) + * for all chat functionality. It handles: + * - Message display and input + * - Role selection (auto/developer/operator) + * - Integration with workspace state (Jotai atoms) + * + * Messages are loaded by WorkspaceContent from the server and stored in messagesAtom. + * This component passes those messages to useAIChat as initialMessages. + * + * @see useAIChat - Main chat hook wrapper + */ + "use client"; -import React, { useState, useRef, useEffect } from "react"; -import { Send, Loader2, Users, Code, User, Sparkles } from "lucide-react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { Send, Loader2, Code, User, Sparkles } from "lucide-react"; import { useTheme } from "../contexts/ThemeContext"; import { Session } from "@/lib/types/session"; import { ChatMessage } from "./ChatMessage"; -import { messagesAtom, workspaceAtom, isRenderingAtom } from "@/atoms/workspace"; +import { messagesAtom, workspaceAtom } from "@/atoms/workspace"; import { useAtom } from "jotai"; -import { createChatMessageAction } from "@/lib/workspace/actions/create-chat-message"; import { ScrollingContent } from "./ScrollingContent"; -import { NewChartChatMessage } from "./NewChartChatMessage"; import { NewChartContent } from "./NewChartContent"; +import { useAIChat } from "@/hooks/useAIChat"; +import { Message } from "./types"; +import { ChatPersistenceService } from "@/lib/services/chat-persistence"; interface ChatContainerProps { session: Session; @@ -18,12 +34,51 @@ interface ChatContainerProps { export function ChatContainer({ session }: ChatContainerProps) { const { theme } = useTheme(); const [workspace] = useAtom(workspaceAtom) - const [messages, setMessages] = useAtom(messagesAtom) - const [isRendering] = useAtom(isRenderingAtom) - const [chatInput, setChatInput] = useState(""); - const [selectedRole, setSelectedRole] = useState<"auto" | "developer" | "operator">("auto"); + const [messages] = useAtom(messagesAtom) const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false); const roleMenuRef = useRef(null); + + // Create persistence service ref + const persistenceServiceRef = useRef(null); + + // Initialize persistence service when workspace changes + useEffect(() => { + if (workspace?.id) { + persistenceServiceRef.current = new ChatPersistenceService(workspace.id); + } + }, [workspace?.id]); + + // Callback to persist messages when streaming completes + const handleMessageComplete = useCallback(async (userMessage: Message, assistantMessage: Message) => { + if (!persistenceServiceRef.current) return; + + try { + await persistenceServiceRef.current.saveMessagePair( + { role: 'user', content: userMessage.prompt }, + { role: 'assistant', content: assistantMessage.response || '' } + ); + } catch { + // Silently ignore persistence errors - don't break the chat experience + } + }, []); + + // Messages are already loaded by WorkspaceContent from the server into messagesAtom. + // We pass them directly to useAIChat as initialMessages to avoid re-fetching. + // useAIChat will use these as the initial state and sync back any updates. + const aiChatHook = useAIChat({ + workspaceId: workspace?.id || '', + session, + // Pass the messages directly from the atom - they're already loaded by WorkspaceContent + initialMessages: messages, + // Persist messages when streaming completes + onMessageComplete: handleMessageComplete, + }); + + // Use hook's state (AI SDK manages input, loading, and role selection) + const effectiveChatInput = aiChatHook.input; + const effectiveIsRendering = aiChatHook.isLoading; + const effectiveSelectedRole = aiChatHook.selectedRole; + const effectiveSetSelectedRole = aiChatHook.setSelectedRole; // No need for refs as ScrollingContent manages its own scrolling @@ -45,16 +100,10 @@ export function ChatContainer({ session }: ChatContainerProps) { return null; } - const handleSubmitChat = async (e: React.FormEvent) => { + const handleSubmitChat = async (e: React.FormEvent) => { e.preventDefault(); - if (!chatInput.trim() || isRendering) return; // Don't submit if rendering is in progress - - if (!session || !workspace) return; - - const chatMessage = await createChatMessageAction(session, workspace.id, chatInput.trim(), selectedRole); - setMessages(prev => [...prev, chatMessage]); - - setChatInput(""); + e.stopPropagation(); + aiChatHook.handleSubmit(e); }; const getRoleLabel = (role: "auto" | "developer" | "operator"): string => { @@ -74,21 +123,28 @@ export function ChatContainer({ session }: ChatContainerProps) { if (workspace?.currentRevisionNumber === 0) { // For NewChartContent, create a simpler version of handleSubmitChat that doesn't use role selector - const handleNewChartSubmitChat = async (e: React.FormEvent) => { + const handleNewChartSubmitChat = (e: React.FormEvent) => { e.preventDefault(); - if (!chatInput.trim() || isRendering) return; - if (!session || !workspace) return; - // Always use AUTO for new chart creation - const chatMessage = await createChatMessageAction(session, workspace.id, chatInput.trim(), "auto"); - setMessages(prev => [...prev, chatMessage]); - setChatInput(""); + // Use hook's handler (role is always "auto" for new charts) + // Ensure role is set to auto + if (aiChatHook.selectedRole !== "auto") { + aiChatHook.setSelectedRole("auto"); + } + // Cast to HTMLFormElement for the hook's handleSubmit + aiChatHook.handleSubmit(e as React.FormEvent); }; - + return { + // Update hook's input via synthetic event + const syntheticEvent = { + target: { value }, + } as React.ChangeEvent; + aiChatHook.handleInputChange(syntheticEvent); + }} handleSubmitChat={handleNewChartSubmitChat} /> } @@ -116,16 +172,33 @@ export function ChatContainer({ session }: ChatContainerProps) {