diff --git a/app/actions.tsx b/app/actions.tsx index bce44e40..5c84e9a5 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -33,6 +33,32 @@ type RelatedQueries = { items: { query: string }[] } +function ensureConversations(aiState: AIState): AIState { + // Migration: Handle old state structure without conversations array + if (!aiState.conversations || !Array.isArray(aiState.conversations)) { + return { + conversations: [ + { + id: nanoid(), + chatId: nanoid(), + messages: [] + } + ] + } + } + + // Ensure at least one conversation exists + if (aiState.conversations.length === 0) { + aiState.conversations.push({ + id: nanoid(), + chatId: nanoid(), + messages: [] + }) + } + + return aiState +} + // Removed mcp parameter from submit, as geospatialTool now handles its client. async function submit(formData?: FormData, skip?: boolean) { 'use server' @@ -42,7 +68,21 @@ async function submit(formData?: FormData, skip?: boolean) { const isGenerating = createStreamableValue(true) const isCollapsed = createStreamableValue(false) - const action = formData?.get('action') as string; + const action = formData?.get('action') as string + const newChat = formData?.get('newChat') === 'true' + + if (newChat) { + const newConversation = { + id: nanoid(), + chatId: nanoid(), + messages: [] + } + const currentAIState = aiState.get() + aiState.update({ + ...currentAIState, + conversations: [...currentAIState.conversations, newConversation] + }) + } if (action === 'resolution_search') { const file = formData?.get('file') as File; if (!file) { @@ -52,8 +92,11 @@ async function submit(formData?: FormData, skip?: boolean) { 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( + // Get the current messages from the last conversation, excluding tool-related ones. + const currentAIState = ensureConversations(aiState.get()) + const lastConversation = + currentAIState.conversations[currentAIState.conversations.length - 1] + const messages: CoreMessage[] = [...(lastConversation.messages as any[])].filter( message => message.role !== 'tool' && message.type !== 'followup' && @@ -71,13 +114,13 @@ async function submit(formData?: FormData, skip?: boolean) { ]; // Add the new user message to the AI state. + const newUserMessage: AIMessage = { id: nanoid(), role: 'user', content }; + lastConversation.messages.push(newUserMessage); aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { id: nanoid(), role: 'user', content } - ] + ...currentAIState, + conversations: [...currentAIState.conversations] }); + messages.push({ role: 'user', content }); // Call the simplified agent, which now returns data directly. @@ -92,17 +135,16 @@ async function submit(formData?: FormData, skip?: boolean) { ); + const assistantMessage: AIMessage = { + id: nanoid(), + role: 'assistant', + content: JSON.stringify(analysisResult), + type: 'resolution_search_result' + }; + lastConversation.messages.push(assistantMessage); aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'assistant', - content: JSON.stringify(analysisResult), - type: 'resolution_search_result' - } - ] + ...currentAIState, + conversations: [...currentAIState.conversations] }); isGenerating.done(false); @@ -115,7 +157,10 @@ async function submit(formData?: FormData, skip?: boolean) { }; } - const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( + const currentAIState = ensureConversations(aiState.get()) + const lastConversation = + currentAIState.conversations[currentAIState.conversations.length - 1] + const messages: CoreMessage[] = [...(lastConversation.messages as any[])].filter( message => message.role !== 'tool' && message.type !== 'followup' && @@ -139,24 +184,19 @@ async function submit(formData?: FormData, skip?: boolean) { : `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 textContent = formData?.get('input') as string || ''; + const content = JSON.stringify({ input: textContent }); + const type = 'input' as const + const userMessage: AIMessage = { id: nanoid(), role: 'user', content, type } + lastConversation.messages.push(userMessage) aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'user', - content, - type, - }, - ], - }); + ...currentAIState, + conversations: [...currentAIState.conversations] + }) - const definitionStream = createStreamableValue(); - definitionStream.done(definition); + const definitionStream = createStreamableValue() + definitionStream.done(definition) const answerSection = (
@@ -166,33 +206,32 @@ async function submit(formData?: FormData, skip?: boolean) { uiStream.append(answerSection); - const groupeId = nanoid(); - const relatedQueries = { items: [] }; + const groupeId = nanoid() + const relatedQueries = { items: [] } + const groupId = nanoid(); + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: definition, + type: 'response' + } as AIMessage) + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related' + } as AIMessage) + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: 'followup', + type: 'followup' + } as AIMessage) aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: groupeId, - role: 'assistant', - content: definition, - type: 'response', - }, - { - id: groupeId, - role: 'assistant', - content: JSON.stringify(relatedQueries), - type: 'related', - }, - { - id: groupeId, - role: 'assistant', - content: 'followup', - type: 'followup', - }, - ], - }); + ...currentAIState, + conversations: [...currentAIState.conversations] + }) isGenerating.done(false); uiStream.done(); @@ -263,17 +302,16 @@ async function submit(formData?: FormData, skip?: boolean) { : 'inquiry' if (content) { + const userMessage: AIMessage = { + id: nanoid(), + role: 'user', + content, + type + } + lastConversation.messages.push(userMessage) aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'user', - content, - type - } - ] + ...currentAIState, + conversations: [...currentAIState.conversations] }) messages.push({ role: 'user', @@ -298,16 +336,15 @@ async function submit(formData?: FormData, skip?: boolean) { uiStream.done() isGenerating.done() isCollapsed.done(false) + lastConversation.messages.push({ + id: nanoid(), + role: 'user', + content: inquiry?.question || '', + type: 'inquiry' + } as AIMessage) aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'assistant', - content: `inquiry: ${inquiry?.question}` - } - ] + ...currentAIState, + conversations: [...currentAIState.conversations] }) return } @@ -336,37 +373,33 @@ async function submit(formData?: FormData, skip?: boolean) { errorOccurred = hasError if (toolOutputs.length > 0) { - toolOutputs.map(output => { - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: groupeId, - role: 'tool', - content: JSON.stringify(output.result), - name: output.toolName, - type: 'tool' - } - ] - }) + toolOutputs.forEach(output => { + lastConversation.messages.push({ + id: nanoid(), + role: 'tool', + content: JSON.stringify(output.result), + name: output.toolName, + type: 'tool' + } as AIMessage) + }) + aiState.update({ + ...currentAIState, + conversations: [...currentAIState.conversations] }) } } if (useSpecificAPI && answer.length === 0) { - const modifiedMessages = aiState - .get() - .messages.map(msg => - msg.role === 'tool' - ? { - ...msg, - role: 'assistant', - content: JSON.stringify(msg.content), - type: 'tool' - } - : msg - ) as CoreMessage[] + const modifiedMessages = lastConversation.messages.map(msg => + msg.role === 'tool' + ? { + ...msg, + role: 'assistant', + content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), + type: 'tool' + } + : msg + ) as CoreMessage[] const latestMessages = modifiedMessages.slice(maxMessages * -1) answer = await writer( currentSystemPrompt, @@ -388,29 +421,27 @@ async function submit(formData?: FormData, skip?: boolean) { await new Promise(resolve => setTimeout(resolve, 500)) + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: answer, + type: 'response' + } as AIMessage) + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related' + } as AIMessage) + lastConversation.messages.push({ + id: nanoid(), + role: 'assistant', + content: 'followup', + type: 'followup' + } as AIMessage) aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: groupeId, - role: 'assistant', - content: answer, - type: 'response' - }, - { - id: groupeId, - role: 'assistant', - content: JSON.stringify(relatedQueries), - type: 'related' - }, - { - id: groupeId, - role: 'assistant', - content: 'followup', - type: 'followup' - } - ] + ...currentAIState, + conversations: [...currentAIState.conversations] }) } @@ -428,21 +459,15 @@ async function submit(formData?: FormData, skip?: boolean) { } } -async function clearChat() { - 'use server' - - const aiState = getMutableAIState() - - aiState.done({ - chatId: nanoid(), - messages: [] - }) +export type Conversation = { + id: string + chatId: string + messages: AIMessage[] + isSharePage?: boolean } export type AIState = { - messages: AIMessage[] - chatId: string - isSharePage?: boolean + conversations: Conversation[] } export type UIState = { @@ -453,97 +478,111 @@ export type UIState = { }[] const initialAIState: AIState = { - chatId: nanoid(), - messages: [] + conversations: [ + { + id: nanoid(), + chatId: nanoid(), + messages: [] + } + ] } const initialUIState: UIState = [] export const AI = createAI({ actions: { - submit, - clearChat + submit }, initialUIState, initialAIState, onGetUIState: async () => { 'use server' - - const aiState = getAIState() as AIState + const aiState = ensureConversations(getAIState() as AIState) if (aiState) { - const uiState = getUIStateFromAIState(aiState) - return uiState + const allUiComponents: UIState = [] + aiState.conversations.forEach((conversation, index) => { + const uiStateForConvo = getUIStateFromAIState(conversation) + if (index > 0 && uiStateForConvo.length > 0) { + allUiComponents.push({ + id: `separator-${conversation.id}`, + component:
+ }) + } + allUiComponents.push(...uiStateForConvo) + }) + return allUiComponents } return initialUIState }, 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) + // Find the conversation that was updated and save it. + for (const conversation of state.conversations) { + if (conversation.messages.some(e => e.type === 'response')) { + const { chatId, messages } = conversation + 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' + } } - } 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 updatedMessages: AIMessage[] = [ + ...messages, + { + id: nanoid(), + role: 'assistant', + content: `end`, + type: 'end' + } + ] - const { getCurrentUserIdOnServer } = await import( - '@/lib/auth/get-current-user' - ) - const actualUserId = await getCurrentUserIdOnServer() + const { getCurrentUserIdOnServer } = await import( + '@/lib/auth/get-current-user' + ) + const actualUserId = await getCurrentUserIdOnServer() - if (!actualUserId) { - console.error('onSetAIState: User not authenticated. Chat not saved.') - return - } + if (!actualUserId) { + console.error( + 'onSetAIState: User not authenticated. Chat not saved.' + ) + continue // Continue to the next conversation + } - const chat: Chat = { - id: chatId, - createdAt, - userId: actualUserId, - path, - title, - messages: updatedMessages + const chat: Chat = { + id: chatId, + createdAt, + userId: actualUserId, + path, + title, + messages: updatedMessages + } + await saveChat(chat, actualUserId) + } } - await saveChat(chat, actualUserId) } }) - -export const getUIStateFromAIState = (aiState: AIState): UIState => { - const chatId = aiState.chatId - const isSharePage = aiState.isSharePage - return aiState.messages +export const getUIStateFromAIState = (conversation: Conversation): UIState => { + const { chatId, isSharePage, messages } = conversation + return messages .map((message, index) => { const { role, content, id, type, name } = message diff --git a/app/page.tsx b/app/page.tsx index 051e54bb..8126854a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import { Chat } from '@/components/chat' import {nanoid } from 'nanoid' -import { AI } from './actions' +import { AI, AIState } from './actions' export const maxDuration = 60 @@ -8,8 +8,17 @@ import { MapDataProvider } from '@/components/map/map-data-context' export default function Page() { const id = nanoid() + const initialAIState: AIState = { + conversations: [ + { + id: nanoid(), + chatId: id, + messages: [] + } + ] + } return ( - + diff --git a/app/search/[id]/page.tsx b/app/search/[id]/page.tsx index 8db74186..9887bb69 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -1,76 +1,79 @@ +import { nanoid } from 'nanoid'; import { notFound, redirect } from 'next/navigation'; import { Chat } from '@/components/chat'; -import { getChat, getChatMessages } from '@/lib/actions/chat'; // Added getChatMessages -import { AI } from '@/app/actions'; +import { getChat, getChatMessages } from '@/lib/actions/chat'; +import { AI, AIState } from '@/app/actions'; import { MapDataProvider } from '@/components/map/map-data-context'; -import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; // For server-side auth -import type { AIMessage } from '@/lib/types'; // For AIMessage type -import type { Message as DrizzleMessage } from '@/lib/actions/chat-db'; // For DrizzleMessage type +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import type { AIMessage } from '@/lib/types'; +import type { Message as DrizzleMessage } from '@/lib/actions/chat-db'; export const maxDuration = 60; export interface SearchPageProps { - params: Promise<{ id: string }>; // Keep as is for now + params: Promise<{ id: string }>; } +const validRoles: AIMessage['role'][] = ['user', 'assistant', 'system', 'function', 'data', 'tool']; + +function safeGetRole(role: string): AIMessage['role'] { + if (validRoles.includes(role as AIMessage['role'])) { + return role as AIMessage['role']; + } + console.warn(`Invalid role "${role}" found in database, defaulting to 'user'.`); + return 'user'; +} + + export async function generateMetadata({ params }: SearchPageProps) { - const { id } = await params; // Keep as is for now - // TODO: Metadata generation might need authenticated user if chats are private - // For now, assuming getChat can be called or it handles anon access for metadata appropriately - const userId = await getCurrentUserIdOnServer(); // Attempt to get user for metadata - const chat = await getChat(id, userId || 'anonymous'); // Pass userId or 'anonymous' if none + const { id } = await params; + const userId = await getCurrentUserIdOnServer(); + const chat = await getChat(id, userId || 'anonymous'); return { title: chat?.title?.toString().slice(0, 50) || 'Search', }; } export default async function SearchPage({ params }: SearchPageProps) { - const { id } = await params; // Keep as is for now + const { id } = await params; const userId = await getCurrentUserIdOnServer(); if (!userId) { - // If no user, redirect to login or show appropriate page - // For now, redirecting to home, but a login page would be better. redirect('/'); } const chat = await getChat(id, userId); if (!chat) { - // If chat doesn't exist or user doesn't have access (handled by getChat) notFound(); } - // Fetch messages for the chat const dbMessages: DrizzleMessage[] = await getChatMessages(chat.id); - // Transform DrizzleMessages to AIMessages const initialMessages: AIMessage[] = dbMessages.map((dbMsg): AIMessage => { return { id: dbMsg.id, - role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities + role: safeGetRole(dbMsg.role), content: dbMsg.content, createdAt: dbMsg.createdAt ? new Date(dbMsg.createdAt) : undefined, - // 'type' and 'name' are not in the basic Drizzle 'messages' schema. - // These would be undefined unless specific logic is added to derive them. - // For instance, if a message with role 'tool' should have a 'name', - // or if some messages have a specific 'type' based on content or other flags. - // This mapping assumes standard user/assistant messages primarily. }; }); - return ( - + messages: initialMessages, + } + ] + } satisfies AIState; + + return ( + ); -} \ No newline at end of file +} diff --git a/bun.lock b/bun.lock index fa5118f8..6d4207d8 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,6 @@ "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", - "QCX": ".", "ai": "^4.3.19", "build": "^0.1.4", "class-variance-authority": "^0.7.1", @@ -1032,8 +1031,6 @@ "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], - "QCX": ["QCX@file:", { "dependencies": { "@ai-sdk/amazon-bedrock": "^1.1.6", "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", "@ai-sdk/openai": "^1.3.24", "@ai-sdk/xai": "^1.2.18", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.0.1", "@mapbox/mapbox-gl-draw": "^1.5.0", "@modelcontextprotocol/sdk": "^1.13.0", "@radix-ui/react-alert-dialog": "^1.1.10", "@radix-ui/react-avatar": "^1.1.6", "@radix-ui/react-checkbox": "^1.2.2", "@radix-ui/react-collapsible": "^1.1.7", "@radix-ui/react-dialog": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.11", "@radix-ui/react-label": "^2.1.4", "@radix-ui/react-radio-group": "^1.3.4", "@radix-ui/react-separator": "^1.1.4", "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-switch": "^1.2.2", "@radix-ui/react-tabs": "^1.1.9", "@radix-ui/react-toast": "^1.2.11", "@radix-ui/react-tooltip": "^1.2.3", "@smithery/cli": "^1.2.5", "@smithery/sdk": "^1.0.4", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.0.0", "@tailwindcss/typography": "^0.5.16", "@turf/turf": "^7.2.0", "@types/mapbox__mapbox-gl-draw": "^1.4.8", "@types/pg": "^8.15.4", "@upstash/redis": "^1.35.0", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "QCX": ".", "ai": "^4.3.19", "build": "^0.1.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cookie": "^0.6.0", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", "drizzle-orm": "^0.29.0", "embla-carousel-react": "^8.6.0", "exa-js": "^1.6.13", "framer-motion": "^12.15.0", "katex": "^0.16.22", "lottie-react": "^2.4.1", "lucide-react": "^0.507.0", "mapbox-gl": "^3.11.0", "next": "^15.3.3", "next-themes": "^0.3.0", "open-codex": "^0.1.30", "pg": "^8.16.2", "radix-ui": "^1.3.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.56.2", "react-icons": "^5.5.0", "react-markdown": "^9.1.0", "react-textarea-autosize": "^8.5.9", "react-toastify": "^10.0.6", "rehype-external-links": "^3.0.0", "rehype-katex": "^7.0.1", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "smithery": "^0.5.2", "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "use-mcp": "^0.0.9", "uuid": "^9.0.0", "zod": "^3.23.8" }, "devDependencies": { "@types/cookie": "^0.6.0", "@types/mapbox-gl": "^3.4.1", "@types/node": "^20.17.30", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/uuid": "^9.0.0", "cross-env": "^7.0.3", "eslint": "^8.57.1", "eslint-config-next": "^14.2.28", "postcss": "^8.5.3", "tailwindcss": "^3.4.17", "typescript": "^5.8.3" } }], - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -2568,8 +2565,6 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - "QCX/QCX": ["QCX@file:.", {}], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="], diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index b0bf2166..a363522c 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -23,7 +23,7 @@ export interface ChatPanelRef { export const ChatPanel = forwardRef(({ messages, input, setInput }, ref) => { const [, setMessages] = useUIState() - const { submit, clearChat } = useActions() + const { submit } = useActions() // Removed mcp instance as it's no longer passed to submit const [isMobile, setIsMobile] = useState(false) const [selectedFile, setSelectedFile] = useState(null) @@ -69,7 +69,10 @@ export const ChatPanel = forwardRef(({ messages, i } } - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async ( + e: React.FormEvent, + newChat?: boolean + ) => { e.preventDefault() if (!input && !selectedFile) { return @@ -86,18 +89,23 @@ export const ChatPanel = forwardRef(({ messages, i }) } - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - component: - } - ]) + if (!newChat) { + setMessages(currentMessages => [ + ...currentMessages, + { + id: nanoid(), + component: + } + ]) + } const formData = new FormData(e.currentTarget) if (selectedFile) { formData.append('file', selectedFile) } + if (newChat) { + formData.append('newChat', 'true') + } setInput('') clearAttachment() @@ -106,10 +114,12 @@ export const ChatPanel = forwardRef(({ messages, i setMessages(currentMessages => [...currentMessages, responseMessage as any]) } - const handleClear = async () => { - setMessages([]) + const handleNewConversation = async () => { clearAttachment() - await clearChat() + const formData = new FormData() + formData.append('newChat', 'true') + const responseMessage = await submit(formData) + setMessages(currentMessages => [...currentMessages, responseMessage as any]) } useEffect(() => { @@ -129,10 +139,10 @@ export const ChatPanel = forwardRef(({ messages, i type="button" variant={'secondary'} className="rounded-full bg-secondary/80 group transition-all hover:scale-105 pointer-events-auto" - onClick={() => handleClear()} + onClick={handleNewConversation} > - New + New Conversation diff --git a/components/chat.tsx b/components/chat.tsx index 2a775600..b91ed44c 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -64,11 +64,20 @@ export function Chat({ id }: ChatProps) { }, [id, path, messages]) useEffect(() => { - if (aiState.messages[aiState.messages.length - 1]?.type === 'response') { - // Refresh the page to chat history updates - router.refresh() + if ( + aiState.conversations && + aiState.conversations.length > 0 + ) { + const lastConversation = aiState.conversations[aiState.conversations.length - 1]; + if (lastConversation && lastConversation.messages.length > 0) { + const lastMessage = lastConversation.messages[lastConversation.messages.length - 1]; + if (lastMessage?.type === 'response') { + // Refresh the page to chat history updates + router.refresh(); + } + } } - }, [aiState, router]) + }, [aiState, router]); // Get mapData to access drawnFeatures const { mapData } = useMapData(); diff --git a/components/map-toggle.tsx b/components/map-toggle.tsx index 1ad35f59..fd240583 100644 --- a/components/map-toggle.tsx +++ b/components/map-toggle.tsx @@ -29,7 +29,7 @@ export function MapToggle() { {setMapType(MapToggleEnum.FreeMode)}}> My Maps - {setMapType(MapToggleEnum.DrawingMode)}}> + {setMapType(MapToggleEnum.DrawingMode)}}> Draw & Measure diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index e472c705..4422bbff 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useCallback } from 'react' // Removed useState import mapboxgl from 'mapbox-gl' import MapboxDraw from '@mapbox/mapbox-gl-draw' -import * as turf from '@turf/turf' import { toast } from 'react-toastify' import 'react-toastify/dist/ReactToastify.css' import 'mapbox-gl/dist/mapbox-gl.css' @@ -12,6 +11,7 @@ import { useMapToggle, MapToggleEnum } from '../map-toggle-context' import { useMapData } from './map-data-context'; // Add this import import { useMapLoading } from '../map-loading-context'; // Import useMapLoading import { useMap } from './map-context' +import { useWorker } from '@/hooks/useWorker' mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN as string; @@ -38,6 +38,8 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number // Refs for long-press functionality const longPressTimerRef = useRef(null); const isMouseDownRef = useRef(false); + const turfWorker = useWorker(new URL('../../workers/turf.worker.ts', import.meta.url)); + // const [isMapLoaded, setIsMapLoaded] = useState(false); // Removed local state @@ -71,98 +73,71 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number lineLabelsRef.current = {} const features = drawRef.current.getAll().features - const currentDrawnFeatures: Array<{ id: string; type: 'Polygon' | 'LineString'; measurement: string; geometry: any }> = [] - - features.forEach(feature => { - const id = feature.id as string - let featureType: 'Polygon' | 'LineString' | null = null; - let measurement = ''; - - if (feature.geometry.type === 'Polygon') { - featureType = 'Polygon'; - // Calculate area for polygons - const area = turf.area(feature) - const formattedArea = formatMeasurement(area, true) - measurement = formattedArea; - - // Get centroid for label placement - const centroid = turf.centroid(feature) - const coordinates = centroid.geometry.coordinates - - // Create a label - const el = document.createElement('div') - el.className = 'area-label' - el.style.background = 'rgba(255, 255, 255, 0.8)' - el.style.padding = '4px 8px' - el.style.borderRadius = '4px' - el.style.fontSize = '12px' - el.style.fontWeight = 'bold' - el.style.color = '#333333' // Added darker color - el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)' - el.style.pointerEvents = 'none' - el.textContent = formattedArea - - // Add marker for the label - + turfWorker.postMessage({ features }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [turfWorker.postMessage]) + useEffect(() => { + if (turfWorker.data && map.current && drawRef.current) { + const features = drawRef.current.getAll().features; + const currentDrawnFeatures: Array<{ id: string; type: 'Polygon' | 'LineString'; measurement: string; geometry: any }> = []; + + turfWorker.data.forEach(result => { + const { id, calculation } = result; + if (!calculation) return; + + const feature = features.find(f => f.id === id); + if (!feature) return; + + let featureType: 'Polygon' | 'LineString' | null = null; + let measurement = ''; + let coordinates: [number, number] | undefined; + + if (calculation.type === 'Polygon') { + featureType = 'Polygon'; + measurement = formatMeasurement(calculation.area, true); + coordinates = calculation.center; + } else if (calculation.type === 'LineString') { + featureType = 'LineString'; + measurement = formatMeasurement(calculation.length, false); + coordinates = calculation.center; + } + if (featureType && measurement && coordinates && map.current) { + const el = document.createElement('div'); + el.className = `${featureType.toLowerCase()}-label`; + el.style.background = 'rgba(255, 255, 255, 0.8)'; + el.style.padding = '4px 8px'; + el.style.borderRadius = '4px'; + el.style.fontSize = '12px'; + el.style.fontWeight = 'bold'; + el.style.color = '#333333'; + el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; + el.style.pointerEvents = 'none'; + el.textContent = measurement; - if (map.current) { const marker = new mapboxgl.Marker({ element: el }) - .setLngLat(coordinates as [number, number]) - .addTo(map.current) - - polygonLabelsRef.current[id] = marker - } - } - else if (feature.geometry.type === 'LineString') { - featureType = 'LineString'; - // Calculate length for lines - const length = turf.length(feature, { units: 'kilometers' }) * 1000 // Convert to meters - const formattedLength = formatMeasurement(length, false) - measurement = formattedLength; - - // Get midpoint for label placement - const line = feature.geometry.coordinates - const midIndex = Math.floor(line.length / 2) - 1 - const midpoint = midIndex >= 0 ? line[midIndex] : line[0] - - // Create a label - const el = document.createElement('div') - el.className = 'distance-label' - el.style.background = 'rgba(255, 255, 255, 0.8)' - el.style.padding = '4px 8px' - el.style.borderRadius = '4px' - el.style.fontSize = '12px' - el.style.fontWeight = 'bold' - el.style.color = '#333333' // Added darker color - el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)' - el.style.pointerEvents = 'none' - el.textContent = formattedLength - - // Add marker for the label - if (map.current) { - const marker = new mapboxgl.Marker({ element: el }) - .setLngLat(midpoint as [number, number]) - .addTo(map.current) - - lineLabelsRef.current[id] = marker - } - } + .setLngLat(coordinates) + .addTo(map.current); - if (featureType && id && measurement && feature.geometry) { - currentDrawnFeatures.push({ - id, - type: featureType, - measurement, - geometry: feature.geometry, - }); - } - }) + if (featureType === 'Polygon') { + polygonLabelsRef.current[id] = marker; + } else { + lineLabelsRef.current[id] = marker; + } - setMapData(prevData => ({ ...prevData, drawnFeatures: currentDrawnFeatures })) - }, [formatMeasurement, setMapData]) + currentDrawnFeatures.push({ + id, + type: featureType, + measurement, + geometry: feature.geometry, + }); + } + }); + setMapData(prevData => ({ ...prevData, drawnFeatures: currentDrawnFeatures })); + } + }, [turfWorker.data, formatMeasurement, setMapData]) // Handle map rotation const rotateMap = useCallback(() => { diff --git a/hooks/useWorker.ts b/hooks/useWorker.ts new file mode 100644 index 00000000..a4ceea37 --- /dev/null +++ b/hooks/useWorker.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; + +type UseWorkerReturnType = { + postMessage: (data: any) => void; + data: T | null; + error: string | null; + isLoading: boolean; +}; + +export function useWorker(workerUrl: URL): UseWorkerReturnType { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const workerRef = useRef(null); + + useEffect(() => { + const worker = new Worker(workerUrl, { type: 'module' }); + workerRef.current = worker; + + worker.onmessage = (event: MessageEvent) => { + setData(event.data); + setIsLoading(false); + }; + + worker.onerror = (err: ErrorEvent) => { + setError(err.message); + setIsLoading(false); + }; + + worker.onmessageerror = (err: MessageEvent) => { + setError('Worker message deserialization error'); + setIsLoading(false); + }; + + return () => { + if (workerRef.current) { + workerRef.current.terminate(); + workerRef.current = null; + } + }; + }, [workerUrl]); + + const postMessage = useCallback((messageData: any) => { + if (workerRef.current) { + setIsLoading(true); + setError(null); + setData(null); + workerRef.current.postMessage(messageData); + } + }, []); + + return useMemo(() => ({ + postMessage, + data, + error, + isLoading + }), [postMessage, data, error, isLoading]); +} diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index e54b1428..fe3bd86f 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -16,7 +16,6 @@ export async function researcher( uiStream: ReturnType, streamText: ReturnType>, messages: CoreMessage[], - // mcp: any, // Removed mcp parameter useSpecificModel?: boolean ) { let fullResponse = '' @@ -28,7 +27,6 @@ export async function researcher( ) const currentDate = new Date().toLocaleString() - // Default system prompt, used if dynamicSystemPrompt is not provided const default_system_prompt = `As a comprehensive AI assistant, you can search the web, retrieve information from URLs except from maps -here use the Geospatial tools provided, and understand geospatial queries to assist the user and display information on a map. Current date and time: ${currentDate}. When tools are not needed, provide direct, helpful answers based on your knowledge.Match the language of your response to the user's language. Always aim to directly address the user's question. If using information from a tool (like web search), cite the source URL. @@ -71,61 +69,37 @@ Analysis & Planning const result = await nonexperimental_streamText({ model: getModel() as LanguageModel, maxTokens: 2500, - system: systemToUse, // Use the dynamic or default system prompt + system: systemToUse, messages, - tools: getTools({ - uiStream, - fullResponse, - // mcp // mcp parameter is no longer passed to getTools - }) + tools: getTools({ uiStream, fullResponse }) }) - // Remove the spinner uiStream.update(null) + uiStream.update(answerSection); - // Process the response - const toolCalls: ToolCallPart[] = [] - const toolResponses: ToolResultPart[] = [] - for await (const delta of result.fullStream) { - switch (delta.type) { - case 'text-delta': - if (delta.textDelta) { - // If the first text delta is available, add a UI section - if (fullResponse.length === 0 && delta.textDelta.length > 0) { - // Update the UI - uiStream.update(answerSection) - } - - fullResponse += delta.textDelta - streamText.update(fullResponse) - } - break - case 'tool-call': - toolCalls.push(delta) - break - case 'tool-result': - // Append the answer section if the specific model is not used - if (!useSpecificModel && toolResponses.length === 0 && delta.result) { - uiStream.append(answerSection) - } - if (!delta.result) { - hasError = true - } - toolResponses.push(delta) - break - case 'error': - hasError = true - fullResponse += `\nError occurred while executing the tool` - break - } + const [text, toolResults, toolCalls] = await Promise.all([ + result.text, + result.toolResults, + result.toolCalls, + ]); + + fullResponse = text; + streamText.done(fullResponse); + + const toolResponses: ToolResultPart[] = (toolResults || []).map(toolResult => ({ + ...toolResult + })); + + if (toolResponses.some(tr => tr.result === undefined || tr.result === null)) { + hasError = true; } + messages.push({ role: 'assistant', - content: [{ type: 'text', text: fullResponse }, ...toolCalls] + content: [{ type: 'text', text: fullResponse }, ...(toolCalls || [])] }) if (toolResponses.length > 0) { - // Add tool responses to the messages messages.push({ role: 'tool', content: toolResponses }) } diff --git a/mapbox_mcp/hooks.ts b/mapbox_mcp/hooks.ts index 326056db..2a3bc86a 100644 --- a/mapbox_mcp/hooks.ts +++ b/mapbox_mcp/hooks.ts @@ -1,6 +1,8 @@ import { useState, useCallback, useRef } from 'react'; -import { generateText } from 'ai'; +import { generateText, CoreMessage } from 'ai'; import { useMcp } from 'use-mcp/react'; +import { getModel } from '@/lib/utils'; +import { z } from 'zod'; // Define Tool type locally if needed @@ -8,7 +10,6 @@ type Tool = { name: string; // Add other properties as needed based on your usage }; -import { getModel } from 'QCX/lib/utils'; // Types for location and mapping data interface LocationResult { @@ -39,7 +40,14 @@ interface PlaceResult { mapUrl: string; }>; } - +const safeParseJson = (jsonString: string, fallback: any = {}) => { + try { + return JSON.parse(jsonString); + } catch (e) { + console.error('JSON parsing failed:', e); + return fallback; + } +}; /** * Custom React hook to interact with the Mapbox MCP server. * Manages client connection, tool invocation, and state (loading, error, connection status). @@ -68,10 +76,13 @@ export const useMCPMapClient = () => { try { setIsLoading(true); setError(null); - toolsRef.current = mcp.tools; + toolsRef.current = mcp.tools.reduce((acc: any, tool: any) => { + acc[tool.name] = tool; + return acc; + }, {}); setIsConnected(true); console.log('✅ Connected to MCP server'); - console.log('Available tools:', mcp.tools.map((tool: Tool) => tool.name)); + console.log('Available tools:', Object.keys(toolsRef.current)); } catch (err) { setError(`Failed to connect to MCP server: ${err}`); console.error('❌ MCP connection error:', err); @@ -154,7 +165,7 @@ Focus on extracting and presenting factual data from the tools.`, } finally { setIsLoading(false); } - }, [mcp.state, mcp.tools]); + }, [mcp.state]); const geocodeLocation = useCallback(async (address: string): Promise => { if (mcp.state !== 'ready') { @@ -165,8 +176,11 @@ Focus on extracting and presenting factual data from the tools.`, query: address, includeMapPreview: true, }); + if (result.content[1]?.json) { + return result.content[1].json; + } const match = result.content[1]?.text?.match(/```json\n([\s\S]*?)\n```/); - return JSON.parse(match?.[1] || '{}'); + return safeParseJson(match?.[1]); } catch (err) { console.error('Geocoding error:', err); setError(`Geocoding error: ${err}`); @@ -185,7 +199,10 @@ Focus on extracting and presenting factual data from the tools.`, profile, includeRouteMap: true, }); - return JSON.parse(result.content[1]?.text?.match(/```json\n(.*?)\n```/s)?.[1] || '{}'); + if (result.content[1]?.json) { + return result.content[1].json; + } + return safeParseJson(result.content[1]?.text?.match(/```json\n(.*?)\n```/s)?.[1]); } catch (err) { console.error('Distance calculation error:', err); setError(`Distance calculation error: ${err}`); @@ -204,7 +221,10 @@ Focus on extracting and presenting factual data from the tools.`, radius, limit, }); - return JSON.parse(result.content[1]?.text?.match(/```json\n(.*?)\n```/s)?.[1] || '{}'); + if (result.content[1]?.json) { + return result.content[1].json; + } + return safeParseJson(result.content[1]?.text?.match(/```json\n(.*?)\n```/s)?.[1]); } catch (err) { console.error('Places search error:', err); setError(`Places search error: ${err}`); diff --git a/workers/turf.worker.ts b/workers/turf.worker.ts new file mode 100644 index 00000000..198b6286 --- /dev/null +++ b/workers/turf.worker.ts @@ -0,0 +1,40 @@ +/// +import { centerOfMass, length as turfLength, along as turfAlong, lineString as turfLineString } from '@turf/turf'; +import * as turf from '@turf/turf' + +self.onmessage = (event: MessageEvent<{ features: any[] }>) => { + const { features } = event.data; + + const results = features.map(feature => { + const id = feature.id as string; + let calculation = null; + let error: string | null = null; + + try { + if (feature.geometry.type === 'Polygon') { + const center = centerOfMass(feature).geometry.coordinates; + const area = turf.area(feature); + calculation = { + type: 'Polygon', + area, + center, + }; + } else if (feature.geometry.type === 'LineString') { + const line = turfLineString(feature.geometry.coordinates); + const len = turfLength(line, { units: 'kilometers' }); + const midpoint = turfAlong(line, len / 2, { units: 'kilometers' }).geometry.coordinates; + calculation = { + type: 'LineString', + length: len * 1000, // convert to meters + center: midpoint, + }; + } + } catch (e: any) { + error = e.message; + } + + return { id, calculation, error }; + }); + + self.postMessage(results); +};