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)
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 (
+
+