From 40f56ad0d9e9a0ffceae83a4c19d813bcc9e0497 Mon Sep 17 00:00:00 2001 From: ngoiyaeric Date: Fri, 24 Apr 2026 22:54:14 +0300 Subject: [PATCH 1/2] feat: migrate from ai/rsc to ai/ui (useChat) pattern Replace the experimental ai/rsc streaming-UI pattern with the stable ai/ui pattern using useChat hook and route handler. This removes all server-side React component streaming in favor of client-side rendering. - Create /api/chat/stream route handler with streamText - Create ChatProvider wrapping useChat for shared chat state - Refactor all tools to return JSON data (no uiStream) - Refactor all agents to return data (no UI rendering) - Migrate all client components from RSC hooks to useChat - Create ToolResultRenderer for client-side tool UI mapping - Remove createAI, createStreamableUI, StreamableValue usage - Zero remaining ai/rsc imports Closes #59 Co-Authored-By: Claude Sonnet 4.5 --- app/actions.tsx | 844 +--------------------------- app/api/chat/stream/route.ts | 359 ++++++++++++ app/page.tsx | 9 +- app/search/[id]/page.tsx | 59 +- components/chat-messages.tsx | 145 +++-- components/chat-panel.tsx | 65 +-- components/chat-provider.tsx | 97 ++++ components/chat.tsx | 54 +- components/collapsible-message.tsx | 7 +- components/copilot.tsx | 54 +- components/followup-panel.tsx | 34 +- components/header-search-button.tsx | 74 +-- components/message.tsx | 13 +- components/mobile-icons-bar.tsx | 8 +- components/resolution-carousel.tsx | 36 +- components/search-related.tsx | 33 +- components/search-section.tsx | 8 +- components/tool-result-renderer.tsx | 46 ++ components/video-search-section.tsx | 8 +- lib/actions/suggest.ts | 34 +- lib/agents/inquire.tsx | 49 +- lib/agents/query-suggestor.tsx | 14 - lib/agents/researcher.tsx | 59 +- lib/agents/tools/geospatial.tsx | 90 +-- lib/agents/tools/index.tsx | 27 +- lib/agents/tools/retrieve.tsx | 56 +- lib/agents/tools/search.tsx | 26 +- lib/agents/tools/video-search.tsx | 26 +- lib/agents/writer.tsx | 21 +- 29 files changed, 830 insertions(+), 1525 deletions(-) create mode 100644 app/api/chat/stream/route.ts create mode 100644 components/chat-provider.tsx create mode 100644 components/tool-result-renderer.tsx diff --git a/app/actions.tsx b/app/actions.tsx index 50e985bf..ce6808ee 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -1,848 +1,8 @@ -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: [] - }) -} +// Type exports preserved for backward compatibility +import type { AIMessage } from '@/lib/types' 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..0d6c8e5b --- /dev/null +++ b/app/api/chat/stream/route.ts @@ -0,0 +1,359 @@ +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 + +export async function POST(request: Request) { + const userId = await getCurrentUserIdOnServer() + if (!userId) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) + } + + 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 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 encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + // Send text content + controller.enqueue(encoder.encode(`0:${JSON.stringify(definition)}\n`)) + // Send finish message + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + // Send data with metadata + controller.enqueue(encoder.encode(`2:[{"relatedQueries":{"items":[]},"type":"related"}]\n`)) + controller.close() + } + }) + + saveChatAsync(chatId, userId, messages, definition) + + return new Response(stream, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }) + } + + // 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(`8:[${JSON.stringify(annotation)}]\n`)) + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + return new Response(stream, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }) + } + + // Proceed path: researcher -> optionally writer -> query suggestor + let answer = '' + let toolOutputs: ToolResultPart[] = [] + let errorOccurred = false + const allToolOutputs: ToolResultPart[] = [] + + while ( + useSpecificAPI + ? answer.length === 0 + : answer.length === 0 && !errorOccurred + ) { + const { fullResponse, hasError, toolResponses } = await researcher( + currentSystemPrompt, + messages, + mapProvider as MapProvider, + useSpecificAPI, + drawnFeatures + ) + answer = fullResponse + toolOutputs = toolResponses + errorOccurred = hasError + allToolOutputs.push(...toolResponses) + } + + if (useSpecificAPI && answer.length === 0) { + const latestMessages = messages.slice(maxMsgs * -1) + answer = await writer(currentSystemPrompt, latestMessages) + } + + // Get related queries + let relatedQueries = {} + if (!errorOccurred) { + relatedQueries = await querySuggestor(messages) + } + + // 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(`8:[${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(`8:[${JSON.stringify(relatedAnnotation)}]\n`)) + + // Finish + controller.enqueue(encoder.encode(`e:{"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: { 'Content-Type': 'text/plain; charset=utf-8' } + }) +} + +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(`8:[${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(`8:[${JSON.stringify(relatedAnnotation)}]\n`)) + + controller.enqueue(encoder.encode(`e:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n`)) + controller.close() + } + }) + + saveChatAsync(chatId, userId, messages, analysisResult.summary || '') + + return new Response(stream, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }) + } catch (error) { + console.error('Resolution search error:', error) + return new Response(JSON.stringify({ error: 'Resolution search failed' }), { status: 500 }) + } +} + +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[] = [] + + // Add user messages + for (const msg of messages) { + aiMessages.push({ + id: nanoid(), + role: msg.role as AIMessage['role'], + content: msg.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..ec1e6dd5 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -1,76 +1,55 @@ 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 { - id: dbMsg.id, - role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities - 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. - }; - }); + const initialMessages: Message[] = dbMessages.map((dbMsg): Message => ({ + id: dbMsg.id, + role: dbMsg.role as Message['role'], + content: dbMsg.content, + createdAt: dbMsg.createdAt ? new Date(dbMsg.createdAt) : undefined, + })); return ( - + - + ); -} \ No newline at end of file +} diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index 6bfa3642..656fc18a 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -1,70 +1,117 @@ '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' 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 + }[] = [] + + // Render tool result annotations first (they come before the text) + const toolAnnotations = annotations.filter((a: Annotation) => a.type === 'tool_result') + for (const ann of toolAnnotations) { + renderedMessages.push({ + id: `tool-${ann.toolName}-${Math.random().toString(36).slice(2)}`, + 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: ( +
+ +
+ ) + }) + } + + // 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) { + const { Copilot } = require('./copilot') + 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) { + const SearchRelated = require('./search-related').default + const { Section: SectionComp } = require('./section') + renderedMessages.push({ + id: 'related', + component: ( + + + + ) + }) + } 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 === renderedMessages.length - 1} + /> + ))} ) } diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index ca2fbc6f..6189a937 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -1,8 +1,8 @@ '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 { 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 +16,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 +28,7 @@ export interface ChatPanelRef { } export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { - const [, setMessages] = useUIState() - const { submit, clearChat } = useActions() + const { append, setMessages } = useChatContext() const { mapProvider } = useSettingsStore() const [isMobile, setIsMobile] = useState(false) const [selectedFile, setSelectedFile] = useState(null) @@ -53,7 +52,6 @@ export const ChatPanel = forwardRef(({ messages, i } })); - // Detect mobile layout useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth <= 1024) @@ -91,44 +89,24 @@ 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() setInput('') clearAttachment() - const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) + await append( + { role: 'user', content: userContent }, + { + body: { + mapProvider, + drawnFeatures: mapData.drawnFeatures || [], + } + } + ) } const handleClear = async () => { setMessages([]) clearAttachment() - await clearChat() } const debouncedGetSuggestions = useCallback( @@ -144,15 +122,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 +135,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..3f101fdf --- /dev/null +++ b/components/chat-provider.tsx @@ -0,0 +1,97 @@ +'use client' + +import React, { createContext, useContext, useCallback, useMemo } from 'react' +import { useChat, type Message } from 'ai/react' +import type { PartialInquiry } from '@/lib/schema/inquiry' +import type { PartialRelated } from '@/lib/schema/related' + +export interface Annotation { + type: string + [key: string]: any +} + +interface ChatContextValue { + messages: Message[] + input: string + setInput: (value: string) => void + handleSubmit: (e?: React.FormEvent, options?: any) => void + append: (message: { role: 'user'; content: string }, options?: any) => 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(() => (data as Annotation[] | undefined) || [], [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]) + + return ( + + {children} + + ) +} diff --git a/components/chat.tsx b/components/chat.tsx index e675f124..52c211ee 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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) }, []) @@ -76,13 +68,12 @@ 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 + const lastMsg = messages[messages.length - 1] + if (lastMsg?.role === 'assistant' && !isLoading) { router.refresh() } - }, [aiState, router]) + }, [messages, isLoading, router]) - // Get mapData to access drawnFeatures const { mapData } = useMapData(); useEffect(() => { @@ -92,7 +83,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 +102,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 +111,9 @@ export function Chat({ id }: ChatProps) { ); }; - // Mobile layout if (isMobile) { return ( - {/* Add Provider */} +
@@ -135,10 +123,10 @@ export function Chat({ id }: ChatProps) {
- @@ -169,21 +157,19 @@ export function Chat({ id }: ChatProps) { ); } - // Desktop layout return ( - {/* Add Provider */} +
- {/* This is the new div for scrolling */}
{isCalendarOpen ? ( ) : ( <> -
@@ -206,7 +192,7 @@ export function Chat({ id }: ChatProps) {
{activeView ? : isUsageOpen ? : }
diff --git a/components/collapsible-message.tsx b/components/collapsible-message.tsx index b3349bc7..38ed79c4 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]) - // if not collapsed, return the component if (!isCollapsed) { return message.component } diff --git a/components/copilot.tsx b/components/copilot.tsx index b62b37ed..03e60ce4 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,17 @@ 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: '{"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 +97,7 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => {

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

@@ -154,17 +123,17 @@ export const Copilot: React.FC = ({ inquiry }: CopilotProps) => {
))}
- {data?.allowsInput && ( + {value.allowsInput && (
@@ -175,12 +144,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..efe92445 100644 --- a/components/followup-panel.tsx +++ b/components/followup-panel.tsx @@ -3,40 +3,26 @@ 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' export function FollowupPanel() { const [input, setInput] = useState('') - const { submit } = useActions() - const [, setMessages] = useUIState() + const { append } = useChatContext() const { mapData } = useMapData() 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 - ]) + await append( + { role: 'user', content: input }, + { + body: { + drawnFeatures: mapData.drawnFeatures || [], + } + } + ) setInput('') } diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx index 7090a52c..f57a835d 100644 --- a/components/header-search-button.tsx +++ b/components/header-search-button.tsx @@ -5,36 +5,25 @@ import { createPortal } from 'react-dom' import { Button } from '@/components/ui/button' import { Search } from 'lucide-react' import { useMap } from './map/map-context' -import { useActions, useUIState } from 'ai/rsc' -import { AI } from '@/app/actions' +import { useChatContext } from './chat-provider' import { nanoid } from '@/lib/utils' -import { UserMessage } from './user-message' import { toast } from 'sonner' import { useSettingsStore } from '@/lib/store/settings' import { useMapData } from './map/map-data-context' import { compressImage } from '@/lib/utils/image-utils' -// Define an interface for the actions to help TypeScript during build -interface HeaderActions { - submit: (formData: FormData) => Promise; -} - export function HeaderSearchButton() { const { map } = useMap() const { mapProvider } = useSettingsStore() const { mapData } = useMapData() - // Cast the actions to our defined interface to avoid build errors - const actions = useActions() as unknown as HeaderActions - const [, setMessages] = useUIState() + const { append } = useChatContext() const [isAnalyzing, setIsAnalyzing] = useState(false) const [desktopPortal, setDesktopPortal] = useState(null) const [mobilePortal, setMobilePortal] = useState(null) useEffect(() => { - // Portals can only be used on the client-side after the DOM has mounted setDesktopPortal(document.getElementById('header-search-portal')) - // Mobile portal might mount later, so check periodically const checkMobilePortal = () => { const el = document.getElementById('mobile-header-search-portal') if (el) { @@ -60,27 +49,14 @@ export function HeaderSearchButton() { toast.error('Map is not available yet. Please wait for it to load.') return } - if (!actions) { - toast.error('Search actions are not available.') - return - } setIsAnalyzing(true) try { - setMessages((currentMessages: any[]) => [ - ...currentMessages, - { - id: nanoid(), - component: - } - ]) - let mapboxBlob: Blob | null = null; let googleBlob: Blob | null = null; if (mapProvider === 'mapbox' && map) { - // Capture Mapbox const canvas = map.getCanvas() const rawMapboxBlob = await new Promise(resolve => { canvas.toBlob(resolve, 'image/png') @@ -92,7 +68,6 @@ export function HeaderSearchButton() { }); } - // Also fetch Google Static Map for the same view const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY; if (apiKey) { const center = map.getCenter(); @@ -139,25 +114,36 @@ export function HeaderSearchButton() { throw new Error('Failed to capture map image.') } - const formData = new FormData() - if (mapboxBlob) formData.append('file_mapbox', mapboxBlob, 'mapbox_capture.png') - if (googleBlob) formData.append('file_google', googleBlob, 'google_capture.png') - - // Keep 'file' for backward compatibility if needed, or just use the first available - formData.append('file', (mapboxBlob || googleBlob)!, 'map_capture.png') + // Convert blobs to base64 for the API + const blobToBase64 = async (blob: Blob): Promise => { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.readAsDataURL(blob) + }) + } - formData.append('action', 'resolution_search') - formData.append('timezone', mapData.currentTimezone || 'UTC') - formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || [])) + const fileData = await blobToBase64((mapboxBlob || googleBlob)!) + const mapboxImageData = mapboxBlob ? await blobToBase64(mapboxBlob) : undefined + const googleImageData = googleBlob ? await blobToBase64(googleBlob) : undefined const center = mapProvider === 'mapbox' && map ? map.getCenter() : mapData.cameraState?.center; - if (center) { - formData.append('latitude', center.lat.toString()) - formData.append('longitude', center.lng.toString()) - } - const responseMessage = await actions.submit(formData) - setMessages((currentMessages: any[]) => [...currentMessages, responseMessage as any]) + await append( + { role: 'user', content: 'Analyze this map view.' }, + { + body: { + action: 'resolution_search', + fileData, + mapboxImageData, + googleImageData, + timezone: mapData.currentTimezone || 'UTC', + drawnFeatures: mapData.drawnFeatures || [], + latitude: center?.lat?.toString(), + longitude: center?.lng?.toString(), + } + } + ) } catch (error) { console.error('Failed to perform resolution search:', error) toast.error('An error occurred while analyzing the map.') @@ -171,7 +157,7 @@ export function HeaderSearchButton() { variant="ghost" size="icon" onClick={handleResolutionSearch} - disabled={isAnalyzing || !map || !actions} + disabled={isAnalyzing || !map} title="Analyze current map view" > {isAnalyzing ? ( @@ -183,7 +169,7 @@ export function HeaderSearchButton() { ) const mobileButton = ( -