diff --git a/.env b/.env index 0ae69d85..a146a1f7 100644 --- a/.env +++ b/.env @@ -2,6 +2,7 @@ AUTH_DISABLED_FOR_DEV=false DATABASE_URL="postgresql://user:password@host:port/db" SERVER_ACTIONS_ALLOWED_ORIGINS=* STANDARD_TIER_BILLING_CYCLE="yearly" -STANDARD_TIER_CREDITS=8000 -STANDARD_TIER_MONTHLY_PRICE=41 -STANDARD_TIER_PRICE_ID="price_standard_41_yearly" +STANDARD_TIER_CREDITS=500 +STANDARD_TIER_MONTHLY_PRICE=500 +STANDARD_TIER_PRICE_ID="price_standard_500_yearly" +ENABLE_SHARE=true diff --git a/.gitignore b/.gitignore index 6ef8344c..3401bf59 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,39 @@ -# Dependency directories -node_modules/ +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz .bun/ -# Build outputs -.next/ -dist/ -build/ -out/ +# testing +/coverage +/playwright-report/ +/test-results/ -# Environment variables +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem +*.swp +*.swo + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dev_server.log +server.log +dev.log +*.log + +# local env files .env .env.local .env.development.local @@ -19,26 +44,21 @@ out/ # IDE/Editor .vscode/ .idea/ -*.swp -*.swo -.DS_Store - -# Logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* # Lock files bun.lockb -# Testing -playwright-report/ -test-results/ -coverage/ - # Supabase local CLI state supabase/.temp/ -# Misc -.vercel/ +# vercel +.vercel + +# typescript *.tsbuildinfo +next-env.d.ts + +# AlphaEarth Embeddings - Sensitive Files +gcp_credentials.json +**/gcp_credentials.json +aef_index.csv diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b736623..89d1965f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" -} +} \ No newline at end of file diff --git a/README.md b/README.md index 3eb5e187..d090ea1f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ -[**Pricing**]  |  [**Land**](https://wwww.queue.cx)  |  [**X**](https://x.com/tryqcx) +[**Pricing**](https://buy.stripe.com/14A3cv7K72TR3go14Nasg02)  |  [**Land**](https://wwww.queue.cx)  |  [**X**](https://x.com/tryqcx) QCX - Artificial General Intelligence. | Product Hunt diff --git a/app/actions.tsx b/app/actions.tsx index 929644ba..edc81fef 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -7,16 +7,14 @@ import { getMutableAIState } from 'ai/rsc' import { CoreMessage, ToolResultPart } from 'ai' -import { nanoid } from 'nanoid' +import { nanoid } from '@/lib/utils' import type { FeatureCollection } from 'geojson' import { Spinner } from '@/components/ui/spinner' import { Section } from '@/components/section' import { FollowupPanel } from '@/components/followup-panel' -import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } from '@/lib/agents' -// Removed import of useGeospatialToolMcp as it no longer exists and was incorrectly used here. -// The geospatialTool (if used by agents like researcher) now manages its own MCP client. +import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, type DrawnFeature } from '@/lib/agents' import { writer } from '@/lib/agents/writer' -import { saveChat, getSystemPrompt } from '@/lib/actions/chat' // Added getSystemPrompt +import { saveChat, getSystemPrompt } from '@/lib/actions/chat' import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' import { retrieveContext } from '@/lib/actions/rag' import { Chat, AIMessage } from '@/lib/types' @@ -25,17 +23,18 @@ import { BotMessage } from '@/components/message' import { SearchSection } from '@/components/search-section' import SearchRelated from '@/components/search-related' import { GeoJsonLayer } from '@/components/map/geojson-layer' +import { ResolutionCarousel } from '@/components/resolution-carousel' +import { ResolutionImage } from '@/components/resolution-image' import { CopilotDisplay } from '@/components/copilot-display' import RetrieveSection from '@/components/retrieve-section' import { VideoSearchSection } from '@/components/video-search-section' -import { MapQueryHandler } from '@/components/map/map-query-handler' // Add this import +import { MapQueryHandler } from '@/components/map/map-query-handler' // Define the type for related queries type RelatedQueries = { items: { query: string }[] } -// Removed mcp parameter from submit, as geospatialTool now handles its client. async function submit(formData?: FormData, skip?: boolean) { 'use server' @@ -45,111 +44,177 @@ async function submit(formData?: FormData, skip?: boolean) { const isCollapsed = createStreamableValue(false) const action = formData?.get('action') as string; + const drawnFeaturesString = formData?.get('drawnFeatures') as string; + let drawnFeatures: DrawnFeature[] = []; + try { + drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : []; + } catch (e) { + console.error('Failed to parse drawnFeatures:', e); + } + if (action === 'resolution_search') { - const file = formData?.get('file') as File; + const file_mapbox = formData?.get('file_mapbox') as File; + const file_google = formData?.get('file_google') as File; + const file = (formData?.get('file') as File) || file_mapbox || file_google; + const timezone = (formData?.get('timezone') as string) || 'UTC'; + const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined; + const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined; + const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined; + if (!file) { throw new Error('No file provided for resolution search.'); } + const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null; + const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null; + + const googleBuffer = file_google ? await file_google.arrayBuffer() : null; + const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null; + const buffer = await file.arrayBuffer(); const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`; - // Get the current messages, excluding tool-related ones. const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => + (message: any) => message.role !== 'tool' && message.type !== 'followup' && message.type !== 'related' && - message.type !== 'end' + message.type !== 'end' && + message.type !== 'resolution_search_result' ); - // The user's prompt for this action is static. - const userInput = 'Analyze this map view.'; - - // Construct the multimodal content for the user message. + const userInputRes = 'Analyze this map view.'; const content: CoreMessage['content'] = [ - { type: 'text', text: userInput }, + { type: 'text', text: userInputRes }, { type: 'image', image: dataUrl, mimeType: file.type } ]; - // Add the new user message to the AI state. aiState.update({ ...aiState.get(), messages: [ ...aiState.get().messages, - { id: nanoid(), role: 'user', content: JSON.stringify(content) } + { id: nanoid(), role: 'user', content, type: 'input' } ] }); messages.push({ role: 'user', content }); - // Call the simplified agent, which now returns data directly. - const analysisResult = await resolutionSearch(messages) as any; + const summaryStream = createStreamableValue('Analyzing map view...'); + const groupeId = nanoid(); - // Create a streamable value for the summary and mark it as done. - const summaryStream = createStreamableValue(); - summaryStream.done(analysisResult.summary || 'Analysis complete.'); + async function processResolutionSearch() { + try { + const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location); - // Update the UI stream with the BotMessage component. - uiStream.update( - - ); + let fullSummary = ''; + for await (const partialObject of streamResult.partialObjectStream) { + if (partialObject.summary) { + fullSummary = partialObject.summary; + summaryStream.update(fullSummary); + } + } - messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); + const analysisResult = await streamResult.object; + summaryStream.done(analysisResult.summary || 'Analysis complete.'); - const sanitizedMessages: CoreMessage[] = messages.map(m => { - if (Array.isArray(m.content)) { - return { - ...m, - content: m.content.filter(part => part.type !== 'image') - } as CoreMessage - } - return m - }) + if (analysisResult.geoJson) { + uiStream.append( + + ); + } - const relatedQueries = await querySuggestor(uiStream, sanitizedMessages); - uiStream.append( -
- -
- ); + messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); - await new Promise(resolve => setTimeout(resolve, 500)); + const sanitizedMessages: CoreMessage[] = messages.map((m: any) => { + if (Array.isArray(m.content)) { + return { + ...m, + content: m.content.filter((part: any) => part.type !== 'image') + } as CoreMessage + } + return m + }) - const groupeId = nanoid(); + const currentMessages = aiState.get().messages; + const sanitizedHistory = currentMessages.map((m: any) => { + if (m.role === "user" && Array.isArray(m.content)) { + return { + ...m, + content: m.content.map((part: any) => + part.type === "image" ? { ...part, image: "IMAGE_PROCESSED" } : part + ) + } + } + return m + }); + const relatedQueries = await querySuggestor(uiStream, sanitizedMessages); + uiStream.append( +
+ +
+ ); - aiState.done({ - ...aiState.get(), - messages: [ + await new Promise(resolve => setTimeout(resolve, 500)); + + aiState.done({ + ...aiState.get(), + messages: [ ...aiState.get().messages, { - id: groupeId, - role: 'assistant', - content: analysisResult.summary || 'Analysis complete.', - type: 'response' + id: groupeId, + role: 'assistant', + content: analysisResult.summary || 'Analysis complete.', + type: 'response' }, { - id: groupeId, - role: 'assistant', - content: JSON.stringify(analysisResult), - type: 'resolution_search_result' + id: groupeId, + role: 'assistant', + content: JSON.stringify({ + ...analysisResult, + image: dataUrl, + mapboxImage: mapboxDataUrl, + googleImage: googleDataUrl + }), + type: 'resolution_search_result' }, { - id: groupeId, - role: 'assistant', - content: JSON.stringify(relatedQueries), - type: 'related' + id: groupeId, + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related' }, { - id: groupeId, - role: 'assistant', - content: 'followup', - type: 'followup' + id: groupeId, + role: 'assistant', + content: 'followup', + type: 'followup' } - ] - }); + ] + }); + } catch (error) { + console.error('Error in resolution search:', error); + summaryStream.error(error); + } finally { + isGenerating.done(false); + uiStream.done(); + } + } + + processResolutionSearch(); + + uiStream.update( +
+ + +
+ ); - isGenerating.done(false); - uiStream.done(); return { id: nanoid(), isGenerating: isGenerating.value, @@ -158,32 +223,20 @@ async function submit(formData?: FormData, skip?: boolean) { }; } - const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => - message.role !== 'tool' && - message.type !== 'followup' && - message.type !== 'related' && - message.type !== 'end' - ) - - const groupeId = nanoid() - const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' - const maxMessages = useSpecificAPI ? 5 : 10 - messages.splice(0, Math.max(messages.length - maxMessages, 0)) - + const file = !skip ? (formData?.get('file') as File) : undefined const userInput = skip ? `{"action": "skip"}` : ((formData?.get('related_query') as string) || (formData?.get('input') as string)) - if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') { + if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) { const definition = userInput.toLowerCase().trim() === 'what is a planet computer?' ? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)` - : `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`; const content = JSON.stringify(Object.fromEntries(formData!)); const type = 'input'; + const groupeId = nanoid(); aiState.update({ ...aiState.get(), @@ -207,9 +260,8 @@ async function submit(formData?: FormData, skip?: boolean) { ); - uiStream.append(answerSection); + uiStream.update(answerSection); - const groupeId = nanoid(); const relatedQueries = { items: [] }; aiState.done({ @@ -244,10 +296,9 @@ async function submit(formData?: FormData, skip?: boolean) { id: nanoid(), isGenerating: isGenerating.value, component: uiStream.value, - isCollapsed: isCollapsed.value, + isCollapsed: isCollapsed.value }; } - const file = !skip ? (formData?.get('file') as File) : undefined if (!userInput && !file) { isGenerating.done(false) @@ -259,6 +310,30 @@ async function submit(formData?: FormData, skip?: boolean) { } } + const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( + (message: any) => + message.role !== 'tool' && + message.type !== 'followup' && + message.type !== 'related' && + message.type !== 'end' && + message.type !== 'resolution_search_result' + ).map((m: any) => { + if (Array.isArray(m.content)) { + return { + ...m, + content: m.content.filter((part: any) => + part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:")) + ) + } as any + } + return m + }) + + const groupeId = nanoid() + const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' + const maxMessages = useSpecificAPI ? 5 : 10 + messages.splice(0, Math.max(messages.length - maxMessages, 0)) + const messageParts: { type: 'text' | 'image' text?: string @@ -293,7 +368,6 @@ async function submit(formData?: FormData, skip?: boolean) { } const hasImage = messageParts.some(part => part.type === 'image') - // Properly type the content based on whether it contains images const content: CoreMessage['content'] = hasImage ? messageParts as CoreMessage['content'] : messageParts.map(part => part.text).join('\n') @@ -314,7 +388,7 @@ async function submit(formData?: FormData, skip?: boolean) { { id: nanoid(), role: 'user', - content: typeof content === 'string' ? content : JSON.stringify(content), + content, type } ] @@ -326,17 +400,11 @@ async function submit(formData?: FormData, skip?: boolean) { } const userId = await getCurrentUserIdOnServer() - if (!userId) { - throw new Error('Unauthorized') - } - const currentSystemPrompt = (await getSystemPrompt(userId)) || '' - - const retrievedContext = userInput - ? await retrieveContext(userInput, aiState.get().chatId) - : [] - const augmentedSystemPrompt = retrievedContext.length > 0 - ? `Context: ${retrievedContext.join('\n')}\n${currentSystemPrompt}` - : currentSystemPrompt + if (!userId) throw new Error('Unauthorized') + const userInputAction = formData?.get('input') as string; + const currentSystemPrompt = (await getSystemPrompt(userId)) || ''; + const retrievedContext = userInputAction ? await retrieveContext(userInput, aiState.get().chatId) : []; + const augmentedSystemPrompt = retrievedContext.length > 0 ? `Context: ${retrievedContext.join('\n')}\n${currentSystemPrompt}` : currentSystemPrompt; const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' async function processEvents() { @@ -385,7 +453,8 @@ async function submit(formData?: FormData, skip?: boolean) { streamText, messages, mapProvider, - useSpecificAPI + useSpecificAPI, + drawnFeatures ) answer = fullResponse toolOutputs = toolResponses @@ -442,6 +511,8 @@ async function submit(formData?: FormData, skip?: boolean) { ) + await new Promise(resolve => setTimeout(resolve, 500)) + aiState.done({ ...aiState.get(), messages: [ @@ -466,8 +537,6 @@ async function submit(formData?: FormData, skip?: boolean) { } ] }) - } else { - aiState.done(aiState.get()) } isGenerating.done(false) @@ -486,35 +555,19 @@ async function submit(formData?: FormData, skip?: boolean) { async function clearChat() { 'use server' + const aiState = getMutableAIState() + aiState.done({ chatId: nanoid(), messages: [] }) } -export type Message = { - id: string - role: 'user' | 'assistant' | 'system' | 'tool' | 'function' | 'data' - content: string | any[] - name?: string - type?: - | 'response' - | 'inquiry' - | 'related' - | 'followup' - | 'input' - | 'input_related' - | 'tool' - | 'resolution_search_result' - | 'skip' - | 'end' - | 'drawing_context' -} - export type AIState = { + messages: AIMessage[] chatId: string - messages: Message[] + isSharePage?: boolean } export type UIState = { @@ -524,68 +577,102 @@ export type UIState = { isCollapsed?: StreamableValue }[] +const initialAIState: AIState = { + chatId: nanoid(), + messages: [] +} + +const initialUIState: UIState = [] + export const AI = createAI({ actions: { submit, clearChat }, - initialUIState: [], - initialAIState: { chatId: nanoid(), messages: [] }, + initialUIState, + initialAIState, onGetUIState: async () => { 'use server' - const aiState = getAIState() - + const aiState = getAIState() as AIState if (aiState) { - const uiState = getUIStateFromAIState(aiState as Chat) + const uiState = getUIStateFromAIState(aiState) return uiState - } else { - return } + return initialUIState }, - onSetAIState: async ({ state, done }) => { + onSetAIState: async ({ state }) => { 'use server' + if (!state.messages.some(e => e.type === 'response')) { + return + } + const { chatId, messages } = state + const createdAt = new Date() + const path = `/search/${chatId}` + + let title = 'Untitled Chat' + if (messages.length > 0) { + const firstMessageContent = messages[0].content + if (typeof firstMessageContent === 'string') { + try { + const parsedContent = JSON.parse(firstMessageContent) + title = parsedContent.input?.substring(0, 100) || 'Untitled Chat' + } catch (e) { + title = firstMessageContent.substring(0, 100) + } + } else if (Array.isArray(firstMessageContent)) { + const textPart = ( + firstMessageContent as { type: string; text?: string }[] + ).find(p => p.type === 'text') + title = + textPart && textPart.text + ? textPart.text.substring(0, 100) + : 'Image Message' + } + } + + const updatedMessages: AIMessage[] = [ + ...messages, + { + id: nanoid(), + role: 'assistant', + content: `end`, + type: 'end' + } + ] - const userId = await getCurrentUserIdOnServer() + const { getCurrentUserIdOnServer } = await import( + '@/lib/auth/get-current-user' + ) + const actualUserId = await getCurrentUserIdOnServer() + console.log(`[AIState] actualUserId for save: ${actualUserId}`); - if (!userId) { + if (!actualUserId) { + console.error('onSetAIState: User not authenticated. Chat not saved.') return } - const lastMessage = messages[messages.length - 1] - if (lastMessage && lastMessage.role === 'assistant' && done) { - const chat: Chat = { - id: chatId, - title: typeof messages[0].content === 'string' - ? messages[0].content.substring(0, 100) - : 'New Chat', - userId, - createdAt: new Date(), - messages: messages as any, // Cast to any to avoid type conflict with Chat interface - path: `/search/${chatId}` - } - - await saveChat(chat, userId) + const chat: Chat = { + id: chatId, + createdAt, + userId: actualUserId, + path, + title, + messages: updatedMessages } + console.log(`[AIState] Saving chat ${chatId} with ${messages.length} messages`); + await saveChat(chat, actualUserId) } }) -export const getUIStateFromAIState = (aiState: Chat) => { - const chatId = aiState.id - const isSharePage = false // Defaulting to false as it's not defined - - const messages = aiState.messages - .filter( - message => - message.role !== 'system' && - message.role !== 'tool' && - message.type !== 'followup' && - message.type !== 'related' - ) +export const getUIStateFromAIState = (aiState: AIState): UIState => { + const chatId = aiState.chatId + const isSharePage = aiState.isSharePage + return aiState.messages .map((message, index) => { - const { role, content, id, type } = message + const { role, content, id, type, name } = message if ( !type || @@ -602,17 +689,10 @@ export const getUIStateFromAIState = (aiState: Chat) => { case 'input_related': let messageContent: string | any[] try { - // For backward compatibility with old messages that stored a JSON string - const parsed = JSON.parse(content as string) - if (Array.isArray(parsed)) { - messageContent = parsed - } else if (typeof parsed === 'object' && parsed !== null) { - messageContent = type === 'input' ? parsed.input : parsed.related_query - } else { - messageContent = parsed - } + const json = JSON.parse(content as string) + messageContent = + type === 'input' ? json.input : json.related_query } catch (e) { - // New messages will store the content array or string directly messageContent = content } return { @@ -633,10 +713,10 @@ export const getUIStateFromAIState = (aiState: Chat) => { } break case 'assistant': + const answer = createStreamableValue(content as string) + answer.done(content as string) switch (type) { case 'response': - const answer = createStreamableValue() - answer.done(content) return { id, component: ( @@ -646,7 +726,9 @@ export const getUIStateFromAIState = (aiState: Chat) => { ) } case 'related': - const relatedQueries = createStreamableValue() + const relatedQueries = createStreamableValue({ + items: [] + }) relatedQueries.done(JSON.parse(content as string)) return { id, @@ -666,21 +748,21 @@ export const getUIStateFromAIState = (aiState: Chat) => { ) } case 'resolution_search_result': { - let analysisResult: any = {} - try { - analysisResult = JSON.parse(content as string); - } catch (e) { - // Not JSON - } + const analysisResult = JSON.parse(content as string); const geoJson = analysisResult.geoJson as FeatureCollection; - const summaryStream = createStreamableValue() - summaryStream.done(analysisResult.summary || 'Analysis complete.') + const image = analysisResult.image as string; + const mapboxImage = analysisResult.mapboxImage as string; + const googleImage = analysisResult.googleImage as string; return { id, component: ( <> - + {geoJson && ( )} @@ -688,35 +770,87 @@ export const getUIStateFromAIState = (aiState: Chat) => { ) } } - default: { - // Handle generic assistant messages that might not have a specific type or are 'answer' type - // Handle content that is not a string (e.g., array of parts) - let displayContent: string = '' - if (typeof content === 'string') { - displayContent = content - } else if (Array.isArray(content)) { - // Convert array content to string representation or extract text - displayContent = content.map(part => { - if ('text' in part) return part.text - return '' - }).join('\n') - } - - const contentStream = createStreamableValue() - contentStream.done(displayContent) + } + break + case 'tool': + try { + const toolOutput = JSON.parse(content as string) + const isCollapsed = createStreamableValue(true) + isCollapsed.done(true) + + if ( + toolOutput.type === 'MAP_QUERY_TRIGGER' && + name === 'geospatialQueryTool' + ) { + const mapUrl = toolOutput.mcp_response?.mapUrl; + const placeName = toolOutput.mcp_response?.location?.place_name; + return { + id, + component: ( + <> + {mapUrl && ( + + )} + + + ), + isCollapsed: false + } + } + + const searchResults = createStreamableValue( + JSON.stringify(toolOutput) + ) + searchResults.done(JSON.stringify(toolOutput)) + switch (name) { + case 'search': + return { + id, + component: , + isCollapsed: isCollapsed.value + } + case 'retrieve': + return { + id, + component: , + isCollapsed: isCollapsed.value + } + case 'videoSearch': return { id, - component: + component: ( + + ), + isCollapsed: isCollapsed.value } + default: + console.warn( + `Unhandled tool result in getUIStateFromAIState: ${name}` + ) + return { id, component: null } + } + } catch (error) { + console.error( + 'Error parsing tool content in getUIStateFromAIState:', + error + ) + return { + id, + component: null } } break default: - return null + return { + id, + component: null + } } }) .filter(message => message !== null) as UIState - - return messages } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 01aa7470..6d9ec11a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,55 +1,21 @@ -import { NextResponse, NextRequest } from 'next/server'; -import { saveChat } from '@/lib/actions/chat'; -import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; -import { type Chat } from '@/lib/types'; -import { v4 as uuidv4 } from 'uuid'; +import { getModel } from '@/lib/utils' +import { LanguageModel, streamText } from 'ai' +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' -export async function POST(request: NextRequest) { - try { - const userId = await getCurrentUserIdOnServer(); - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } +export const maxDuration = 60 - const body = await request.json(); - const { title, initialMessageContent, role = 'user' } - = body; +export async function POST(req: Request) { + const { messages } = await req.json() + const userId = await getCurrentUserIdOnServer() - if (!initialMessageContent) { - return NextResponse.json({ error: 'Initial message content is required' }, { status: 400 }); - } - - const chatId = uuidv4(); - const newChat: Chat = { - id: chatId, - userId: userId, - title: title || 'New Chat', - createdAt: new Date(), - path: `/search/${chatId}`, - messages: [ - { - id: uuidv4(), - role: role, - content: initialMessageContent, - createdAt: new Date(), - } - ] - }; - - const savedChatId = await saveChat(newChat, userId); - - if (!savedChatId) { - return NextResponse.json({ error: 'Failed to save chat' }, { status: 500 }); - } + if (!userId) { + return new Response('Unauthorized', { status: 401 }) + } - return NextResponse.json({ message: 'Chat created successfully', chatId: savedChatId }, { status: 201 }); + const result = await streamText({ + model: (await getModel()) as LanguageModel, + messages, + }) - } catch (error) { - console.error('Error in POST /api/chat:', error); - let errorMessage = 'Internal Server Error'; - if (error instanceof Error) { - errorMessage = error.message; - } - return NextResponse.json({ error: errorMessage }, { status: 500 }); - } + return result.toDataStreamResponse() } diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts new file mode 100644 index 00000000..3e01f31b --- /dev/null +++ b/app/api/stripe/checkout/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; + +export async function POST(req: NextRequest) { + try { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error('STRIPE_SECRET_KEY is not set'); + } + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-01-27-ac' as any, + }); + + const userId = await getCurrentUserIdOnServer(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { priceId } = await req.json(); + if (!priceId) { + return NextResponse.json({ error: 'Price ID is required' }, { status: 400 }); + } + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: 'subscription', + success_url: `${process.env.NEXT_PUBLIC_APP_URL}/?success=true`, + cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/?canceled=true`, + client_reference_id: userId, + metadata: { + userId: userId, + }, + }); + + return NextResponse.json({ url: session.url }); + } catch (error: any) { + console.error('Stripe checkout error:', error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts new file mode 100644 index 00000000..52f5f78f --- /dev/null +++ b/app/api/stripe/webhook/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { TIER_CONFIGS, TIERS, Tier } from '@/lib/utils/subscription'; + +export async function POST(req: NextRequest) { + if (!process.env.STRIPE_SECRET_KEY) { + return NextResponse.json({ error: 'STRIPE_SECRET_KEY is not set' }, { status: 500 }); + } + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: '2025-01-27-ac' as any, + }); + + const body = await req.text(); + const signature = req.headers.get('stripe-signature')!; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!webhookSecret) { + return NextResponse.json({ error: 'STRIPE_WEBHOOK_SECRET is not set' }, { status: 500 }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err: any) { + console.error(`Webhook signature verification failed.`, err.message); + return NextResponse.json({ error: err.message }, { status: 400 }); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session; + const userId = session.client_reference_id || session.metadata?.userId; + + if (userId) { + const standardPriceId = process.env.STANDARD_TIER_PRICE_ID; + let tier: Tier = TIERS.FREE; + let creditsToAdd = 0; + + if (session.line_items?.data[0]?.price?.id === standardPriceId) { + tier = TIERS.STANDARD; + creditsToAdd = TIER_CONFIGS[TIERS.STANDARD].credits; + } else { + tier = TIERS.STANDARD; + creditsToAdd = TIER_CONFIGS[TIERS.STANDARD].credits; + } + + const currentUser = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + + if (currentUser) { + await db + .update(users) + .set({ + tier: tier, + credits: currentUser.credits + creditsToAdd, + }) + .where(eq(users.id, userId)); + + console.log(`[Webhook] Successfully upgraded user ${userId} to ${tier}`); + } + } + } + + return NextResponse.json({ received: true }); +} diff --git a/app/auth/auth-code-error/page.tsx b/app/auth/auth-code-error/page.tsx new file mode 100644 index 00000000..bdc5e861 --- /dev/null +++ b/app/auth/auth-code-error/page.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link' +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { AlertCircle, ArrowLeft, RefreshCcw } from 'lucide-react' + +export default function AuthCodeErrorPage({ + searchParams, +}: { + searchParams: { error?: string } +}) { + const error = searchParams.error || 'Unexpected authentication failure' + + return ( +
+ + +
+ +
+ + Authentication Error + +
+ +
+ {error} +
+ +
+

Common Causes:

+
    +
  • Redirect URI mismatch in Supabase dashboard.
  • +
  • Missing or incorrect environment variables on Vercel.
  • +
  • OAuth code expired or already used.
  • +
  • Cookies blocked by browser or extensions.
  • +
+
+
+ + + + +
+
+ ) +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index fffab410..bf61d555 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -8,6 +8,16 @@ export async function GET(request: Request) { // if "next" is in search params, use it as the redirection URL const next = searchParams.get('next') ?? '/' + // Diagnostic logging + console.log('[Auth Callback] Request Details:', { + origin, + url: request.url, + hasCode: !!code, + next, + envUrl: process.env.NEXT_PUBLIC_SUPABASE_URL ? 'PRESENT' : 'MISSING', + envKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ? 'PRESENT' : 'MISSING', + }) + if (code) { const cookieStore = cookies() const supabase = createServerClient( @@ -30,20 +40,22 @@ export async function GET(request: Request) { }, } ) + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (error) { console.error('[Auth Callback] Exchange code error:', { message: error.message, status: error.status, name: error.name, - code: code?.substring(0, 10) + '...' + codeSnippet: code?.substring(0, 10) + '...', }) return NextResponse.redirect(`${origin}/auth/auth-code-error?error=${encodeURIComponent(error.message)}`) } else { try { const { data: { user }, error: userErr } = await supabase.auth.getUser() if (!userErr && user) { - console.log('[Auth Callback] User signed in:', user.email) + console.log('[Auth Callback] User signed in successfully:', user.email) } } catch (e) { console.warn('[Auth Callback] Could not fetch user after exchange', e) @@ -52,6 +64,13 @@ export async function GET(request: Request) { } } + // Check if there was an error from the provider in the URL + const error_description = searchParams.get('error_description') + if (error_description) { + console.error('[Auth Callback] Provider error:', error_description) + return NextResponse.redirect(`${origin}/auth/auth-code-error?error=${encodeURIComponent(error_description)}`) + } + // return the user to an error page with instructions return NextResponse.redirect(`${origin}/auth/auth-code-error`) } diff --git a/app/layout.tsx b/app/layout.tsx index 766cd265..b1c203d8 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,10 +12,13 @@ import { SpeedInsights } from "@vercel/speed-insights/next" import { Toaster } from '@/components/ui/sonner' import { MapToggleProvider } from '@/components/map-toggle-context' import { ProfileToggleProvider } from '@/components/profile-toggle-context' +import { UsageToggleProvider } from '@/components/usage-toggle-context' import { CalendarToggleProvider } from '@/components/calendar-toggle-context' +import { HistoryToggleProvider } from '@/components/history-toggle-context' +import { HistorySidebar } from '@/components/history-sidebar' import { MapLoadingProvider } from '@/components/map-loading-context'; import ConditionalLottie from '@/components/conditional-lottie'; -import { MapProvider } from '@/components/map/map-context' +import { MapProvider as MapContextProvider } from '@/components/map/map-context' import { getSupabaseUserAndSessionOnServer } from '@/lib/auth/get-current-user' import { PurchaseCreditsPopup } from '@/components/credits/purchase-credits-popup'; import { CreditsProvider } from '@/components/credits/credits-provider'; @@ -34,9 +37,8 @@ const fontPoppins = Poppins({ weight: ['400', '500', '600', '700'] }) -const title = '' -const description = - 'language to Maps' +const title = 'QCX' +const description = 'Language to Maps' export const metadata: Metadata = { metadataBase: new URL('https://www.qcx.world'), @@ -71,6 +73,29 @@ export default async function RootLayout({ return ( + +