diff --git a/app/actions.tsx b/app/actions.tsx deleted file mode 100644 index 50e985bf..00000000 --- a/app/actions.tsx +++ /dev/null @@ -1,848 +0,0 @@ -import { - StreamableValue, - createAI, - createStreamableUI, - createStreamableValue, - getAIState, - getMutableAIState -} from 'ai/rsc' -import { CoreMessage, ToolResultPart } from 'ai' -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, type DrawnFeature } from '@/lib/agents' -import { writer } from '@/lib/agents/writer' -import { saveChat, getSystemPrompt } from '@/lib/actions/chat' -import { Chat, AIMessage } from '@/lib/types' -import { UserMessage } from '@/components/user-message' -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' - -// Define the type for related queries -type RelatedQueries = { - items: { query: string }[] -} - -async function submit(formData?: FormData, skip?: boolean) { - 'use server' - - const aiState = getMutableAIState() - const uiStream = createStreamableUI() - const isGenerating = createStreamableValue(true) - 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_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')}`; - - 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' - ); - - const userInput = 'Analyze this map view.'; - const content: CoreMessage['content'] = [ - { type: 'text', text: userInput }, - { type: 'image', image: dataUrl, mimeType: file.type } - ]; - - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { id: nanoid(), role: 'user', content, type: 'input' } - ] - }); - messages.push({ role: 'user', content }); - - const summaryStream = createStreamableValue('Analyzing map view...'); - const groupeId = nanoid(); - - async function processResolutionSearch() { - try { - const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location); - - let fullSummary = ''; - for await (const partialObject of streamResult.partialObjectStream) { - if (partialObject.summary) { - fullSummary = partialObject.summary; - summaryStream.update(fullSummary); - } - } - - const analysisResult = await streamResult.object; - summaryStream.done(analysisResult.summary || 'Analysis complete.'); - - if (analysisResult.geoJson) { - uiStream.append( - - ); - } - - messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); - - 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 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( -
- -
- ); - - 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: 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: 'followup', - type: 'followup' - } - ] - }); - } catch (error) { - console.error('Error in resolution search:', error); - summaryStream.error(error); - } finally { - isGenerating.done(false); - uiStream.done(); - } - } - - processResolutionSearch(); - - uiStream.update( -
- - -
- ); - - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: uiStream.value, - isCollapsed: isCollapsed.value - }; - } - - 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 && (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(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'user', - content, - type, - }, - ], - }); - - const definitionStream = createStreamableValue(); - definitionStream.done(definition); - - const answerSection = ( -
- -
- ); - - uiStream.update(answerSection); - - const relatedQueries = { items: [] }; - - 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', - }, - ], - }); - - isGenerating.done(false); - uiStream.done(); - - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: uiStream.value, - isCollapsed: isCollapsed.value - }; - } - - if (!userInput && !file) { - isGenerating.done(false) - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: null, - isCollapsed: isCollapsed.value - } - } - - 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 - image?: string - mimeType?: string - }[] = [] - - if (userInput) { - messageParts.push({ type: 'text', text: userInput }) - } - - if (file) { - const buffer = await file.arrayBuffer() - if (file.type.startsWith('image/')) { - const dataUrl = `data:${file.type};base64,${Buffer.from( - buffer - ).toString('base64')}` - messageParts.push({ - type: 'image', - image: dataUrl, - mimeType: file.type - }) - } else if (file.type === 'text/plain') { - const textContent = Buffer.from(buffer).toString('utf-8') - const existingTextPart = messageParts.find(p => p.type === 'text') - if (existingTextPart) { - existingTextPart.text = `${textContent}\n\n${existingTextPart.text}` - } else { - messageParts.push({ type: 'text', text: textContent }) - } - } - } - - const hasImage = messageParts.some(part => part.type === 'image') - const content: CoreMessage['content'] = hasImage - ? messageParts as CoreMessage['content'] - : messageParts.map(part => part.text).join('\n') - - const type = skip - ? undefined - : formData?.has('input') || formData?.has('file') - ? 'input' - : formData?.has('related_query') - ? 'input_related' - : 'inquiry' - - if (content) { - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'user', - content, - type - } - ] - }) - messages.push({ - role: 'user', - content - } as CoreMessage) - } - - const userId = 'anonymous' - const currentSystemPrompt = (await getSystemPrompt(userId)) || '' - const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' - - async function processEvents() { - let action: any = { object: { next: 'proceed' } } - if (!skip) { - const taskManagerResult = await taskManager(messages) - if (taskManagerResult) { - action.object = taskManagerResult.object - } - } - - if (action.object.next === 'inquire') { - const inquiry = await inquire(uiStream, messages) - uiStream.done() - isGenerating.done() - isCollapsed.done(false) - aiState.done({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'assistant', - content: `inquiry: ${inquiry?.question}` - } - ] - }) - return - } - - isCollapsed.done(true) - let answer = '' - let toolOutputs: ToolResultPart[] = [] - let errorOccurred = false - const streamText = createStreamableValue() - uiStream.update() - - while ( - useSpecificAPI - ? answer.length === 0 - : answer.length === 0 && !errorOccurred - ) { - const { fullResponse, hasError, toolResponses } = await researcher( - currentSystemPrompt, - uiStream, - streamText, - messages, - mapProvider, - useSpecificAPI, - drawnFeatures - ) - answer = fullResponse - toolOutputs = toolResponses - 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' - } - ] - }) - }) - } - } - - 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 latestMessages = modifiedMessages.slice(maxMessages * -1) - answer = await writer( - currentSystemPrompt, - uiStream, - streamText, - latestMessages - ) - } else { - streamText.done() - } - - if (!errorOccurred) { - const relatedQueries = await querySuggestor(uiStream, messages) - uiStream.append( -
- -
- ) - - await new Promise(resolve => setTimeout(resolve, 500)) - - 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' - } - ] - }) - } - - isGenerating.done(false) - uiStream.done() - } - - processEvents() - - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: uiStream.value, - isCollapsed: isCollapsed.value - } -} - -async function clearChat() { - 'use server' - - const aiState = getMutableAIState() - - aiState.done({ - chatId: nanoid(), - messages: [] - }) -} - -export type AIState = { - messages: AIMessage[] - chatId: string - isSharePage?: boolean -} - -export type UIState = { - id: string - component: React.ReactNode - isGenerating?: StreamableValue - isCollapsed?: StreamableValue -}[] - -const initialAIState: AIState = { - chatId: nanoid(), - messages: [] -} - -const initialUIState: UIState = [] - -export const AI = createAI({ - actions: { - submit, - clearChat - }, - initialUIState, - initialAIState, - onGetUIState: async () => { - 'use server' - - const aiState = getAIState() as AIState - if (aiState) { - const uiState = getUIStateFromAIState(aiState) - return uiState - } - 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) - } - } 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 { getCurrentUserIdOnServer } = await import( - '@/lib/auth/get-current-user' - ) - const actualUserId = await getCurrentUserIdOnServer() - - if (!actualUserId) { - console.error('onSetAIState: User not authenticated. Chat not saved.') - return - } - - const chat: Chat = { - id: chatId, - createdAt, - userId: actualUserId, - path, - title, - messages: updatedMessages - } - await saveChat(chat, actualUserId) - } -}) - -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, name } = message - - if ( - !type || - type === 'end' || - (isSharePage && type === 'related') || - (isSharePage && type === 'followup') - ) - return null - - switch (role) { - case 'user': - switch (type) { - case 'input': - case 'input_related': - let messageContent: string | any[] - try { - const json = JSON.parse(content as string) - messageContent = - type === 'input' ? json.input : json.related_query - } catch (e) { - messageContent = content - } - return { - id, - component: ( - - ) - } - case 'inquiry': - return { - id, - component: - } - } - break - case 'assistant': - const answer = createStreamableValue(content as string) - answer.done(content as string) - switch (type) { - case 'response': - return { - id, - component: ( -
- -
- ) - } - case 'related': - const relatedQueries = createStreamableValue({ - items: [] - }) - relatedQueries.done(JSON.parse(content as string)) - return { - id, - component: ( -
- -
- ) - } - case 'followup': - return { - id, - component: ( -
- -
- ) - } - case 'resolution_search_result': { - const analysisResult = JSON.parse(content as string); - const geoJson = analysisResult.geoJson as FeatureCollection; - const image = analysisResult.image as string; - const mapboxImage = analysisResult.mapboxImage as string; - const googleImage = analysisResult.googleImage as string; - - return { - id, - component: ( - <> - - {geoJson && ( - - )} - - ) - } - } - } - 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: ( - - ), - 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 { - id, - component: null - } - } - }) - .filter(message => message !== null) as UIState -} diff --git a/app/api/chat/stream/route.ts b/app/api/chat/stream/route.ts new file mode 100644 index 00000000..b11f919b --- /dev/null +++ b/app/api/chat/stream/route.ts @@ -0,0 +1,388 @@ +import { CoreMessage, ToolResultPart, streamText, LanguageModel } from 'ai' +import { nanoid } from '@/lib/utils' +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' +import { taskManager, inquire, querySuggestor } from '@/lib/agents' +import { researcher } from '@/lib/agents/researcher' +import { writer } from '@/lib/agents/writer' +import { resolutionSearch, type DrawnFeature } from '@/lib/agents/resolution-search' +import { getModel } from '@/lib/utils' +import { getSystemPrompt, saveChat } from '@/lib/actions/chat' +import type { Chat, AIMessage } from '@/lib/types' +import type { MapProvider } from '@/lib/store/settings' + +export const maxDuration = 60 + +const streamHeaders = { + 'Content-Type': 'text/plain; charset=utf-8', + 'x-vercel-ai-data-stream': 'v1', +} + +export async function POST(request: Request) { + const userId = await getCurrentUserIdOnServer() + if (!userId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }) + } + + const body = await request.json() + const { + messages: clientMessages, + chatId = nanoid(), + action, + mapProvider = 'mapbox', + drawnFeatures: drawnFeaturesRaw, + timezone, + latitude, + longitude, + fileData, + mapboxImageData, + googleImageData, + } = body + + const drawnFeatures: DrawnFeature[] = drawnFeaturesRaw || [] + const location = (latitude !== undefined && longitude !== undefined) + ? { lat: parseFloat(latitude), lng: parseFloat(longitude) } + : undefined + + const currentSystemPrompt = (await getSystemPrompt(userId)) || '' + const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' + const maxMsgs = useSpecificAPI ? 5 : 10 + + // Build core messages from client messages + const messages: CoreMessage[] = (clientMessages || []) + .filter((m: any) => m.role === 'user' || m.role === 'assistant') + .map((m: any) => ({ + role: m.role, + content: m.content + })) + + // Trim to max messages + if (messages.length > maxMsgs) { + messages.splice(0, messages.length - maxMsgs) + } + + // Resolution search action + if (action === 'resolution_search' && fileData) { + return handleResolutionSearch({ + messages, chatId, userId, fileData, mapboxImageData, googleImageData, + timezone, drawnFeatures, location + }) + } + + // Hardcoded responses + const lastMsg = messages[messages.length - 1] + const lastText = typeof lastMsg?.content === 'string' ? lastMsg.content.trim().toLowerCase() : '' + if (lastText === 'what is a planet computer?' || lastText === 'what is qcx-terra?') { + const definition = lastText === 'what is a planet computer?' + ? `A planet computer is a proprietary environment aware system that interoperates Climate 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 encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`0:${JSON.stringify(definition)}\n`)) + controller.enqueue(encoder.encode(`2:[{"relatedQueries":{"items":[]},"type":"related"}]\n`)) + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.enqueue(encoder.encode(`d:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + + saveChatAsync(chatId, userId, messages, definition) + + return new Response(stream, { + headers: streamHeaders + }) + } + + // Task manager: decide inquire vs proceed + let nextAction = 'proceed' + try { + const taskResult = await taskManager(messages) + if (taskResult?.object?.next === 'inquire') { + nextAction = 'inquire' + } + } catch (e) { + console.error('Task manager error:', e) + } + + // Inquiry path + if (nextAction === 'inquire') { + const inquiryResult = await inquire(messages) + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + // Send inquiry data as a data annotation + const annotation = { type: 'inquiry', data: inquiryResult } + controller.enqueue(encoder.encode(`2:[${JSON.stringify(annotation)}]\n`)) + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.enqueue(encoder.encode(`d:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + return new Response(stream, { + headers: streamHeaders + }) + } + + // Proceed path: researcher -> optionally writer -> query suggestor + let answer = '' + let toolOutputs: ToolResultPart[] = [] + let errorOccurred = false + const allToolOutputs: ToolResultPart[] = [] + const maxAttempts = 3 + let attempts = 0 + + while ( + attempts < maxAttempts && + (useSpecificAPI + ? answer.length === 0 + : answer.length === 0 && !errorOccurred) + ) { + attempts++ + const { fullResponse, hasError, toolResponses, newSegments } = await researcher( + currentSystemPrompt, + messages, + mapProvider as MapProvider, + useSpecificAPI, + drawnFeatures + ) + answer = fullResponse + toolOutputs = toolResponses + errorOccurred = hasError + allToolOutputs.push(...toolResponses) + // Only append segments to messages on success or final attempt + if (answer.length > 0 || errorOccurred || attempts >= maxAttempts) { + messages.push(...newSegments) + } + } + + if (useSpecificAPI && answer.length === 0) { + const latestMessages = messages.slice(maxMsgs * -1) + answer = await writer(currentSystemPrompt, latestMessages) + } + + // Get related queries (sanitize to remove image parts) + let relatedQueries = {} + if (!errorOccurred) { + 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 + }) + relatedQueries = await querySuggestor(sanitizedMessages) + } + + // Build streaming response + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + // Send tool results as annotations + for (const toolResult of allToolOutputs) { + const annotation = { + type: 'tool_result', + toolName: toolResult.toolName, + result: toolResult.result + } + controller.enqueue(encoder.encode(`2:[${JSON.stringify(annotation)}]\n`)) + } + + // Stream the text response + if (answer) { + controller.enqueue(encoder.encode(`0:${JSON.stringify(answer)}\n`)) + } + + // Send related queries as annotation + const relatedAnnotation = { type: 'related', relatedQueries } + controller.enqueue(encoder.encode(`2:[${JSON.stringify(relatedAnnotation)}]\n`)) + + // Finish + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.enqueue(encoder.encode(`d:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + + // Save chat asynchronously + saveChatAsync(chatId, userId, messages, answer, allToolOutputs, relatedQueries) + + return new Response(stream, { + headers: streamHeaders + }) +} + +async function handleResolutionSearch({ + messages, chatId, userId, fileData, mapboxImageData, googleImageData, + timezone, drawnFeatures, location +}: { + messages: CoreMessage[] + chatId: string + userId: string + fileData: string + mapboxImageData?: string + googleImageData?: string + timezone?: string + drawnFeatures: DrawnFeature[] + location?: { lat: number; lng: number } +}) { + const content: CoreMessage['content'] = [ + { type: 'text', text: 'Analyze this map view.' }, + { type: 'image', image: fileData, mimeType: 'image/png' } + ] + messages.push({ role: 'user', content }) + + try { + const streamResult = await resolutionSearch( + messages, + timezone || 'UTC', + drawnFeatures, + location + ) + + const analysisResult = await streamResult.object + + // Get related queries + 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 relatedQueries = await querySuggestor(sanitizedMessages) + + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + // Send resolution result as annotation + const resAnnotation = { + type: 'resolution_search_result', + data: { + ...analysisResult, + image: fileData, + mapboxImage: mapboxImageData, + googleImage: googleImageData + } + } + controller.enqueue(encoder.encode(`2:[${JSON.stringify(resAnnotation)}]\n`)) + + // Stream summary text + if (analysisResult.summary) { + controller.enqueue(encoder.encode(`0:${JSON.stringify(analysisResult.summary)}\n`)) + } + + // Related queries + const relatedAnnotation = { type: 'related', relatedQueries } + controller.enqueue(encoder.encode(`2:[${JSON.stringify(relatedAnnotation)}]\n`)) + + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.enqueue(encoder.encode(`d:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + + saveChatAsync(chatId, userId, messages, analysisResult.summary || '') + + return new Response(stream, { + headers: streamHeaders + }) + } catch (error) { + console.error('Resolution search error:', error) + return new Response(JSON.stringify({ error: 'Resolution search failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } +} + +async function saveChatAsync( + chatId: string, + userId: string, + messages: CoreMessage[], + answer: string, + toolOutputs?: ToolResultPart[], + relatedQueries?: any +) { + try { + let title = 'Untitled Chat' + const firstMsg = messages[0] + if (firstMsg) { + if (typeof firstMsg.content === 'string') { + title = firstMsg.content.substring(0, 100) + } else if (Array.isArray(firstMsg.content)) { + const textPart = (firstMsg.content as any[]).find(p => p.type === 'text') + title = textPart?.text?.substring(0, 100) || 'Image Message' + } + } + + const aiMessages: AIMessage[] = [] + + for (const msg of messages) { + let content: CoreMessage['content'] = msg.content + if (Array.isArray(content)) { + content = (content as any[]).filter((part: any) => part.type !== 'image') as CoreMessage['content'] + } + aiMessages.push({ + id: (msg as any).id || nanoid(), + role: msg.role as AIMessage['role'], + content, + type: msg.role === 'user' ? 'input' : undefined + }) + } + + // Add tool outputs + if (toolOutputs) { + for (const tool of toolOutputs) { + aiMessages.push({ + id: nanoid(), + role: 'tool', + content: JSON.stringify(tool.result), + name: tool.toolName, + type: 'tool' + }) + } + } + + // Add response + if (answer) { + aiMessages.push({ + id: nanoid(), + role: 'assistant', + content: answer, + type: 'response' + }) + } + + // Add related queries + if (relatedQueries) { + aiMessages.push({ + id: nanoid(), + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related' + }) + } + + // Add end marker + aiMessages.push({ + id: nanoid(), + role: 'assistant', + content: 'end', + type: 'end' + }) + + const chat: Chat = { + id: chatId, + createdAt: new Date(), + userId, + path: `/search/${chatId}`, + title, + messages: aiMessages + } + await saveChat(chat, userId) + } catch (error) { + console.error('Error saving chat:', error) + } +} diff --git a/app/page.tsx b/app/page.tsx index e098417e..82ac57cd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,17 @@ import { Chat } from '@/components/chat' import { nanoid } from '@/lib/utils' -import { AI } from './actions' +import { ChatProvider } from '@/components/chat-provider' +import { MapDataProvider } from '@/components/map/map-data-context' export const maxDuration = 60 -import { MapDataProvider } from '@/components/map/map-data-context' - export default function Page() { const id = nanoid() return ( - + - + ) } diff --git a/app/search/[id]/page.tsx b/app/search/[id]/page.tsx index 8db74186..4308ad99 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -1,76 +1,58 @@ 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 { ChatProvider } from '@/components/chat-provider'; +import { getChat, getChatMessages } from '@/lib/actions/chat'; 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 { Message } from 'ai/react'; +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 }>; } 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 { + const validRoles = new Set(['user', 'assistant', 'system']) + const initialMessages: Message[] = dbMessages + .filter((dbMsg) => validRoles.has(dbMsg.role)) + .map((dbMsg): Message => ({ id: dbMsg.id, - role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities + role: dbMsg.role as Message['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 ( - + - + ); -} \ No newline at end of file +} diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index 6bfa3642..319a4c2c 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -1,70 +1,128 @@ 'use client' -import { StreamableValue, useUIState } from 'ai/rsc' -import type { AI, UIState } from '@/app/actions' +import type { Message } from 'ai/react' import { CollapsibleMessage } from './collapsible-message' +import { Section } from './section' +import { BotMessage } from './message' +import { UserMessage } from './user-message' +import { ToolResultRenderer } from './tool-result-renderer' +import { useChatContext, type Annotation } from './chat-provider' +import { Copilot } from './copilot' +import SearchRelated from './search-related' interface ChatMessagesProps { - messages: UIState + messages: Message[] } export function ChatMessages({ messages }: ChatMessagesProps) { - if (!messages.length) { + const { annotations, isLoading } = useChatContext() + + if (!messages.length && !annotations.length) { return null } - // Group messages based on ID, and if there are multiple messages with the same ID, combine them into one message - const groupedMessages = messages.reduce( - (acc: { [key: string]: any }, message) => { - if (!acc[message.id]) { - acc[message.id] = { + const renderedMessages: { + id: string + component: React.ReactNode + isCollapsed?: boolean + isAssistant?: boolean + }[] = [] + + // Render tool result annotations first (they come before the text) + const toolAnnotations = annotations.filter((a: Annotation) => a.type === 'tool_result') + for (let i = 0; i < toolAnnotations.length; i++) { + const ann = toolAnnotations[i] + renderedMessages.push({ + id: `tool-${ann.toolName}-${i}`, + component: , + isCollapsed: true + }) + } + + // Render chat messages + for (const message of messages) { + if (message.role === 'user') { + renderedMessages.push({ + id: message.id, + component: + }) + } else if (message.role === 'assistant') { + if (message.content) { + renderedMessages.push({ id: message.id, - components: [], - isCollapsed: message.isCollapsed + component: ( +
+ +
+ ), + isAssistant: true + }) + } + + // Render tool invocations + if (message.toolInvocations) { + for (const invocation of message.toolInvocations) { + if (invocation.state === 'result') { + renderedMessages.push({ + id: `${message.id}-tool-${invocation.toolCallId}`, + component: ( + + ), + isCollapsed: true + }) + } } } - acc[message.id].components.push(message.component) - return acc - }, - {} - ) + } + } - // Convert grouped messages into an array with explicit type - const groupedMessagesArray = Object.values(groupedMessages).map(group => ({ - ...group, - components: group.components as React.ReactNode[] - })) as { - id: string - components: React.ReactNode[] - isCollapsed?: StreamableValue - }[] + // Render inquiry annotation if present + const inquiry = annotations.find((a: Annotation) => a.type === 'inquiry') + if (inquiry) { + renderedMessages.push({ + id: 'inquiry', + component: + }) + } + + // Render related queries annotation + const related = annotations.findLast?.((a: Annotation) => a.type === 'related') + if (related && related.relatedQueries?.items?.length > 0) { + renderedMessages.push({ + id: 'related', + component: ( +
+ +
+ ) + }) + } + + // Find last assistant message index for isLastMessage prop + let lastAssistantIndex = -1 + for (let i = renderedMessages.length - 1; i >= 0; i--) { + if (renderedMessages[i].isAssistant) { + lastAssistantIndex = i + break + } + } return ( <> - {groupedMessagesArray.map( - ( - groupedMessage: { - id: string - components: React.ReactNode[] - isCollapsed?: StreamableValue - }, - index - ) => ( - ( -
{component}
- )), - isCollapsed: groupedMessage.isCollapsed - }} - isLastMessage={ - groupedMessage.id === messages[messages.length - 1].id - } - /> - ) - )} + {renderedMessages.map((msg, index) => ( + {msg.component}, + isCollapsed: msg.isCollapsed + }} + isLastMessage={index === lastAssistantIndex} + /> + ))} ) } diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index ca2fbc6f..6b6181fa 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -1,8 +1,9 @@ 'use client' import { useEffect, useState, useRef, ChangeEvent, forwardRef, useImperativeHandle, useCallback } from 'react' -import type { AI, UIState } from '@/app/actions' -import { useUIState, useActions, readStreamableValue } from 'ai/rsc' +import { useRouter } from 'next/navigation' +import { useChatContext } from './chat-provider' +import type { Message } from 'ai/react' import { cn } from '@/lib/utils' import { UserMessage } from './user-message' import { Button } from './ui/button' @@ -16,7 +17,7 @@ import { useMapData } from './map/map-data-context' import SuggestionsDropdown from './suggestions-dropdown' interface ChatPanelProps { - messages: UIState + messages: Message[] input: string setInput: (value: string) => void onSuggestionsChange?: (suggestions: PartialRelated | null) => void @@ -28,8 +29,8 @@ export interface ChatPanelRef { } export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { - const [, setMessages] = useUIState() - const { submit, clearChat } = useActions() + const router = useRouter() + const { append, setMessages } = useChatContext() const { mapProvider } = useSettingsStore() const [isMobile, setIsMobile] = useState(false) const [selectedFile, setSelectedFile] = useState(null) @@ -53,7 +54,6 @@ export const ChatPanel = forwardRef(({ messages, i } })); - // Detect mobile layout useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth <= 1024) @@ -91,44 +91,47 @@ export const ChatPanel = forwardRef(({ messages, i return } - const content: ({ type: 'text'; text: string } | { type: 'image'; image: string })[] = [] - if (input) { - content.push({ type: 'text', text: input }) - } - if (selectedFile && selectedFile.type.startsWith('image/')) { - content.push({ - type: 'image', - image: URL.createObjectURL(selectedFile) - }) - } - - setMessages(currentMessages => [ - ...currentMessages, - { - id: nanoid(), - component: - } - ]) - - const formData = new FormData(e.currentTarget) - if (selectedFile) { - formData.append('file', selectedFile) - } - - // Include drawn features in the form data - formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) - + const userContent = input.trim() || 'Analyze this image.' + const file = selectedFile setInput('') clearAttachment() - const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) + if (file) { + const reader = new FileReader() + const fileData = await new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result as string) + reader.onerror = () => reject(new Error('Failed to read file')) + reader.readAsDataURL(file) + }) + + await append( + { role: 'user', content: userContent }, + { + body: { + action: 'resolution_search', + fileData, + mapProvider, + drawnFeatures: mapData.drawnFeatures || [], + } + } + ) + } else { + await append( + { role: 'user', content: userContent }, + { + body: { + mapProvider, + drawnFeatures: mapData.drawnFeatures || [], + } + } + ) + } } - const handleClear = async () => { + const handleClear = () => { setMessages([]) clearAttachment() - await clearChat() + router.push('/') } const debouncedGetSuggestions = useCallback( @@ -144,15 +147,11 @@ export const ChatPanel = forwardRef(({ messages, i } debounceTimeoutRef.current = setTimeout(async () => { - const suggestionsStream = await getSuggestions(value, mapData) - for await (const partialSuggestions of readStreamableValue( - suggestionsStream - )) { - if (partialSuggestions) { - setSuggestions(partialSuggestions as PartialRelated) - } + const suggestions = await getSuggestions(value, mapData) + if (suggestions) { + setSuggestions(suggestions as PartialRelated) } - }, 500) // 500ms debounce delay + }, 500) }, [mapData, setSuggestions] ) @@ -161,7 +160,6 @@ export const ChatPanel = forwardRef(({ messages, i inputRef.current?.focus() }, []) - // New chat button (appears when there are messages) if (messages.length > 0 && !isMobile) { return (
(({ messages, i
diff --git a/components/chat-provider.tsx b/components/chat-provider.tsx new file mode 100644 index 00000000..a6912013 --- /dev/null +++ b/components/chat-provider.tsx @@ -0,0 +1,105 @@ +'use client' + +import React, { createContext, useContext, useCallback, useMemo } from 'react' +import { useChat, type Message } from 'ai/react' +import type { JSONValue, ChatRequestOptions, CreateMessage } from 'ai' +import type { PartialInquiry } from '@/lib/schema/inquiry' +import type { PartialRelated } from '@/lib/schema/related' + +export interface Annotation { + type: string + [key: string]: any +} + +function isAnnotation(value: unknown): value is Annotation { + return typeof value === 'object' && value !== null && 'type' in value && typeof (value as any).type === 'string' +} + +interface ChatContextValue { + messages: Message[] + input: string + setInput: (value: string) => void + handleSubmit: (e?: React.FormEvent, options?: any) => void + append: (message: Message | CreateMessage, options?: ChatRequestOptions) => Promise + setMessages: (messages: Message[] | ((messages: Message[]) => Message[])) => void + isLoading: boolean + chatId: string + annotations: Annotation[] + getToolResults: (toolName: string) => any[] + getInquiry: () => PartialInquiry | null + getRelatedQueries: () => PartialRelated | null +} + +const ChatContext = createContext(null) + +export function useChatContext() { + const ctx = useContext(ChatContext) + if (!ctx) throw new Error('useChatContext must be used within ChatProvider') + return ctx +} + +interface ChatProviderProps { + chatId: string + initialMessages?: Message[] + children: React.ReactNode +} + +export function ChatProvider({ chatId, initialMessages, children }: ChatProviderProps) { + const { + messages, + input, + setInput, + handleSubmit, + append, + setMessages, + isLoading, + data, + } = useChat({ + api: '/api/chat/stream', + id: chatId, + initialMessages, + body: { chatId }, + }) + + const annotations = useMemo(() => { + if (!data) return [] + return (data as JSONValue[]).filter(isAnnotation) + }, [data]) + + const getToolResults = useCallback((toolName: string) => { + return annotations + .filter((a) => a.type === 'tool_result' && a.toolName === toolName) + .map((a) => a.result) + }, [annotations]) + + const getInquiry = useCallback((): PartialInquiry | null => { + const inquiry = annotations.find((a) => a.type === 'inquiry') + return inquiry?.data || null + }, [annotations]) + + const getRelatedQueries = useCallback((): PartialRelated | null => { + const related = annotations.findLast?.((a) => a.type === 'related') + return related?.relatedQueries || null + }, [annotations]) + + const value = useMemo(() => ({ + messages, + input, + setInput, + handleSubmit, + append, + setMessages, + isLoading, + chatId, + annotations, + getToolResults, + getInquiry, + getRelatedQueries, + }), [messages, input, setInput, handleSubmit, append, setMessages, isLoading, chatId, annotations, getToolResults, getInquiry, getRelatedQueries]) + + return ( + + {children} + + ) +} diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..02338c12 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useCallback } from 'react' import { usePathname, useRouter } from 'next/navigation' import { ChatPanel, ChatPanelRef } from './chat-panel' import { ChatMessages } from './chat-messages' @@ -11,26 +11,25 @@ import { cn } from '@/lib/utils' import { useCalendarToggle } from './calendar-toggle-context' import { CalendarNotepad } from './calendar-notepad' import { MapProvider } from './map/map-provider' -import { useUIState, useAIState } from 'ai/rsc' +import { useChatContext } from './chat-provider' import MobileIconsBar from './mobile-icons-bar' import { useProfileToggle, ProfileToggleEnum } from "@/components/profile-toggle-context"; import { useUsageToggle } from "@/components/usage-toggle-context"; import SettingsView from "@/components/settings/settings-view"; import { UsageView } from "@/components/usage-view"; -import { MapDataProvider, useMapData } from './map/map-data-context'; // Add this and useMapData -import { updateDrawingContext } from '@/lib/actions/chat'; // Import the server action +import { MapDataProvider, useMapData } from './map/map-data-context' +import { updateDrawingContext } from '@/lib/actions/chat' import dynamic from 'next/dynamic' import { HeaderSearchButton } from './header-search-button' type ChatProps = { - id?: string // This is the chatId + id?: string } export function Chat({ id }: ChatProps) { const router = useRouter() const path = usePathname() - const [messages] = useUIState() - const [aiState] = useAIState() + const { messages, isLoading } = useChatContext() const [isMobile, setIsMobile] = useState(false) const { activeView } = useProfileToggle(); const { isUsageOpen } = useUsageToggle(); @@ -48,24 +47,17 @@ export function Chat({ id }: ChatProps) { const handleMobileSubmit = () => { chatPanelRef.current?.submitForm(); }; - + useEffect(() => { setShowEmptyScreen(messages.length === 0) }, [messages]) useEffect(() => { - // Check if device is mobile const checkMobile = () => { setIsMobile(window.innerWidth < 768) } - - // Initial check checkMobile() - - // Add event listener for window resize window.addEventListener('resize', checkMobile) - - // Cleanup return () => window.removeEventListener('resize', checkMobile) }, []) @@ -75,14 +67,18 @@ export function Chat({ id }: ChatProps) { } }, [id, path, messages]) + const prevIsLoadingRef = useRef(false) useEffect(() => { - if (aiState.messages[aiState.messages.length - 1]?.type === 'response') { - // Refresh the page to chat history updates - router.refresh() + const wasLoading = prevIsLoadingRef.current + prevIsLoadingRef.current = isLoading + if (wasLoading && !isLoading) { + const lastMsg = messages[messages.length - 1] + if (lastMsg?.role === 'assistant') { + router.refresh() + } } - }, [aiState, router]) + }, [messages, isLoading, router]) - // Get mapData to access drawnFeatures const { mapData } = useMapData(); useEffect(() => { @@ -92,7 +88,6 @@ export function Chat({ id }: ChatProps) { } }, [isSubmitting]) - // useEffect to call the server action when drawnFeatures changes useEffect(() => { if (id && mapData.drawnFeatures && mapData.cameraState) { console.log('Chat.tsx: drawnFeatures changed, calling updateDrawingContext', mapData.drawnFeatures); @@ -112,7 +107,6 @@ export function Chat({ id }: ChatProps) { onSelect={query => { setInput(query) setSuggestions(null) - // Use a small timeout to ensure state update before submission setIsSubmitting(true) }} onClose={() => setSuggestions(null)} @@ -122,10 +116,9 @@ export function Chat({ id }: ChatProps) { ); }; - // Mobile layout if (isMobile) { return ( - {/* Add Provider */} +
@@ -135,10 +128,10 @@ export function Chat({ id }: ChatProps) {
- @@ -169,21 +162,19 @@ export function Chat({ id }: ChatProps) { ); } - // Desktop layout return ( - {/* Add Provider */} +
- {/* This is the new div for scrolling */}
{isCalendarOpen ? ( ) : ( <> -
@@ -206,7 +197,7 @@ export function Chat({ id }: ChatProps) {
{activeView ? : isUsageOpen ? : }
diff --git a/components/collapsible-message.tsx b/components/collapsible-message.tsx index b3349bc7..5fd393f9 100644 --- a/components/collapsible-message.tsx +++ b/components/collapsible-message.tsx @@ -8,7 +8,6 @@ import { } from '@radix-ui/react-collapsible' import { Button } from './ui/button' import { ChevronDown } from 'lucide-react' -import { StreamableValue, useStreamableValue } from 'ai/rsc' import { motion, AnimatePresence } from 'framer-motion' import { cn } from '@/lib/utils' import { Separator } from './ui/separator' @@ -16,7 +15,7 @@ import { Separator } from './ui/separator' interface CollapsibleMessageProps { message: { id: string - isCollapsed?: StreamableValue + isCollapsed?: boolean component: React.ReactNode } isLastMessage?: boolean @@ -26,15 +25,13 @@ export const CollapsibleMessage: React.FC = ({ message, isLastMessage = false }) => { - const [data] = useStreamableValue(message.isCollapsed) - const isCollapsed = data ?? false + const isCollapsed = message.isCollapsed ?? false const [open, setOpen] = useState(isLastMessage) useEffect(() => { setOpen(isLastMessage) - }, [isCollapsed, isLastMessage]) + }, [isLastMessage]) - // if not collapsed, return the component if (!isCollapsed) { return message.component } diff --git a/components/copilot.tsx b/components/copilot.tsx index b62b37ed..832aeda6 100644 --- a/components/copilot.tsx +++ b/components/copilot.tsx @@ -7,13 +7,7 @@ import { Checkbox } from './ui/checkbox' import { Button } from './ui/button' import { Card } from './ui/card' import { ArrowRight, Check, FastForward, Sparkles } from 'lucide-react' -import { useActions, useStreamableValue, useUIState } from 'ai/rsc' -// Removed import of useGeospatialToolMcp as it's no longer used/available -import type { AI } from '@/app/actions' -import { - - - } from './ui/icons' +import { useChatContext } from './chat-provider' import { cn } from '@/lib/utils' export type CopilotProps = { @@ -25,14 +19,11 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => { const [completed, setCompleted] = useState(false) const [query, setQuery] = useState('') const [skipped, setSkipped] = useState(false) - const [data, error, pending] = useStreamableValue() const [checkedOptions, setCheckedOptions] = useState<{ [key: string]: boolean }>({}) const [isButtonDisabled, setIsButtonDisabled] = useState(true) - const [, setMessages] = useUIState() - const { submit } = useActions() - // Removed mcp instance as it's no longer passed to submit + const { append } = useChatContext() const handleInputChange = (event: React.ChangeEvent) => { setQuery(event.target.value) @@ -75,37 +66,20 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => { setCompleted(true) setSkipped(skip || false) - const formData = skip - ? undefined - : new FormData(e.target as HTMLFormElement) - - if (formData) { - formData.set('input', updatedQuery()) - formData.delete('additional_query') + if (!skip) { + await append({ role: 'user', content: updatedQuery() }) + } else { + await append( + { role: 'user', content: updatedQuery() || 'Skipped' }, + { body: { action: 'skip' } } + ) } - - // Removed mcp argument from submit call - const response = await submit(formData, skip) - setMessages(currentMessages => [...currentMessages, response]) } const handleSkip = (e: React.MouseEvent) => { onFormSubmit(e as unknown as React.FormEvent, true) } - if (error) { - return ( - -
- -
- {`error: ${error}`} -
-
-
- ) - } - if (skipped) { return null } @@ -126,9 +100,7 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => {

- {data?.question || value.question} - - + {value.question}

@@ -154,17 +126,17 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => {
))}
- {data?.allowsInput && ( + {value.allowsInput && (
@@ -175,12 +147,11 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => { type="button" variant="outline" onClick={handleSkip} - disabled={pending} > Skip - diff --git a/components/followup-panel.tsx b/components/followup-panel.tsx index c57f141f..8ddefad2 100644 --- a/components/followup-panel.tsx +++ b/components/followup-panel.tsx @@ -3,42 +3,37 @@ import { useState } from 'react' import { Button } from './ui/button' import { Input } from './ui/input' -import { useActions, useUIState } from 'ai/rsc' -import type { AI } from '@/app/actions' -import { UserMessage } from './user-message' +import { useChatContext } from './chat-provider' import { ArrowRight } from 'lucide-react' import { useMapData } from './map/map-data-context' -import { nanoid } from '@/lib/utils' +import { useSettingsStore } from '@/lib/store/settings' export function FollowupPanel() { const [input, setInput] = useState('') - const { submit } = useActions() - const [, setMessages] = useUIState() + const { append, isLoading } = useChatContext() const { mapData } = useMapData() + const { mapProvider } = useSettingsStore() const handleSubmit = async (event: React.FormEvent) => { event.preventDefault() - const formData = new FormData() - formData.append("input", input) - formData.append("action", "resolution_search") - - const userMessage = { - id: nanoid(), - isGenerating: false, - component: - } - - // Include drawn features in the form data - formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) - - const responseMessage = await submit(formData) - setMessages(currentMessages => [ - ...currentMessages, - userMessage, - responseMessage - ]) + if (!input.trim() || isLoading) return + const content = input.trim() setInput('') + + try { + await append( + { role: 'user', content }, + { + body: { + mapProvider, + drawnFeatures: mapData.drawnFeatures || [], + } + } + ) + } catch (error) { + console.error('Failed to send follow-up:', error) + } } return ( @@ -57,7 +52,7 @@ export function FollowupPanel() {