diff --git a/app/actions.tsx b/app/actions.tsx deleted file mode 100644 index 4b078237..00000000 --- a/app/actions.tsx +++ /dev/null @@ -1,767 +0,0 @@ -import { - StreamableValue, - createAI, - createStreamableUI, - createStreamableValue, - getAIState, - getMutableAIState -} from 'ai/rsc' -import { CoreMessage, ToolResultPart } from 'ai' -import { nanoid } from 'nanoid' -import type { FeatureCollection } from 'geojson' -import { Spinner } from '@/components/ui/spinner' -import { Section } from '@/components/section' -import { FollowupPanel } from '@/components/followup-panel' -import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } from '@/lib/agents' -// Removed import of useGeospatialToolMcp as it no longer exists and was incorrectly used here. -// The geospatialTool (if used by agents like researcher) now manages its own MCP client. -import { writer } from '@/lib/agents/writer' -import { saveChat, getSystemPrompt } from '@/lib/actions/chat' // Added getSystemPrompt -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 { CopilotDisplay } from '@/components/copilot-display' -import RetrieveSection from '@/components/retrieve-section' -import { VideoSearchSection } from '@/components/video-search-section' -import { MapQueryHandler } from '@/components/map/map-query-handler' // Add this import - -// Define the type for related queries -type RelatedQueries = { - items: { query: string }[] -} - -// Removed mcp parameter from submit, as geospatialTool now handles its client. -async function submit(formData?: FormData, skip?: boolean) { - 'use server' - - const aiState = getMutableAIState() - const uiStream = createStreamableUI() - const isGenerating = createStreamableValue(true) - const isCollapsed = createStreamableValue(false) - - const action = formData?.get('action') as string; - if (action === 'resolution_search') { - const file = formData?.get('file') as File; - if (!file) { - throw new Error('No file provided for resolution search.'); - } - - const buffer = await file.arrayBuffer(); - const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`; - - // Get the current messages, excluding tool-related ones. - const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => - message.role !== 'tool' && - message.type !== 'followup' && - message.type !== 'related' && - message.type !== 'end' && - message.type !== 'resolution_search_result' - ); - - // The user's prompt for this action is static. - const userInput = 'Analyze this map view.'; - - // Construct the multimodal content for the user message. - const content: CoreMessage['content'] = [ - { type: 'text', text: userInput }, - { type: 'image', image: dataUrl, mimeType: file.type } - ]; - - // Add the new user message to the AI state. - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { id: nanoid(), role: 'user', content, type: 'input' } - ] - }); - messages.push({ role: 'user', content }); - - // Create a streamable value for the summary. - const summaryStream = createStreamableValue(); - - async function processResolutionSearch() { - try { - // Call the simplified agent, which now returns data directly. - const analysisResult = await resolutionSearch(messages) as any; - - // Mark the summary stream as done with the result. - summaryStream.done(analysisResult.summary || 'Analysis complete.'); - - messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); - - const sanitizedMessages: CoreMessage[] = messages.map(m => { - if (Array.isArray(m.content)) { - return { - ...m, - content: m.content.filter(part => part.type !== 'image') - } as CoreMessage - } - return m - }) - - const relatedQueries = await querySuggestor(uiStream, sanitizedMessages); - uiStream.append( -
- -
- ); - - await new Promise(resolve => setTimeout(resolve, 500)); - - const groupeId = nanoid(); - - 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), - 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(); - } - } - - // Start the background process without awaiting it. - processResolutionSearch(); - - // Immediately update the UI stream with the BotMessage component. - uiStream.update( -
- -
- ); - - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: uiStream.value, - isCollapsed: isCollapsed.value - }; - } - - const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( - message => - message.role !== 'tool' && - message.type !== 'followup' && - message.type !== 'related' && - message.type !== 'end' && - message.type !== 'resolution_search_result' - ) - - 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 userInput = skip - ? `{"action": "skip"}` - : ((formData?.get('related_query') as string) || - (formData?.get('input') as string)) - - if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') { - 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'; - - aiState.update({ - ...aiState.get(), - messages: [ - ...aiState.get().messages, - { - id: nanoid(), - role: 'user', - content, - type, - }, - ], - }); - - const definitionStream = createStreamableValue(); - definitionStream.done(definition); - - const answerSection = ( -
- -
- ); - - uiStream.append(answerSection); - - const groupeId = nanoid(); - 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, - }; - } - const file = !skip ? (formData?.get('file') as File) : undefined - - if (!userInput && !file) { - isGenerating.done(false) - return { - id: nanoid(), - isGenerating: isGenerating.value, - component: null, - isCollapsed: isCollapsed.value - } - } - - 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') - // Properly type the content based on whether it contains images - const content: CoreMessage['content'] = hasImage - ? messageParts as CoreMessage['content'] - : messageParts.map(part => part.text).join('\n') - - 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 - ) - 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 { - // For backward compatibility with old messages that stored a JSON string - const json = JSON.parse(content as string) - messageContent = - type === 'input' ? json.input : json.related_query - } catch (e) { - // New messages will store the content array or string directly - messageContent = content - } - return { - id, - component: ( - - ) - } - case 'inquiry': - return { - id, - component: - } - } - break - case 'assistant': - const answer = createStreamableValue() - answer.done(content) - switch (type) { - case 'response': - return { - id, - component: ( -
- -
- ) - } - case 'related': - const relatedQueries = createStreamableValue() - 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; - - return { - id, - component: ( - <> - {geoJson && ( - - )} - - ) - } - } - } - break - case 'tool': - try { - const toolOutput = JSON.parse(content as string) - const isCollapsed = createStreamableValue() - isCollapsed.done(true) - - if ( - toolOutput.type === 'MAP_QUERY_TRIGGER' && - name === 'geospatialQueryTool' - ) { - return { - id, - component: , - isCollapsed: false - } - } - - const searchResults = createStreamableValue() - 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/ai.tsx b/app/ai.tsx new file mode 100644 index 00000000..2a8024b4 --- /dev/null +++ b/app/ai.tsx @@ -0,0 +1,24 @@ +import { createAI } from 'ai/rsc' +import { nanoid } from 'nanoid' +import { submit, clearChat, onGetUIState, onSetAIState } from './chat-actions' +import { AIState, UIState } from '@/lib/chat/types' + +export type { AIState, UIState } + +const initialAIState: AIState = { + chatId: nanoid(), + messages: [] +} + +const initialUIState: UIState = [] + +export const AI = createAI({ + actions: { + submit, + clearChat + }, + initialUIState, + initialAIState, + onGetUIState, + onSetAIState +}) diff --git a/app/chat-actions.tsx b/app/chat-actions.tsx new file mode 100644 index 00000000..b4b4c452 --- /dev/null +++ b/app/chat-actions.tsx @@ -0,0 +1,558 @@ +'use server' + +import { + createStreamableUI, + createStreamableValue, + getAIState, + getMutableAIState, + StreamableValue +} from 'ai/rsc' +import { CoreMessage, ToolResultPart } from 'ai' +import { nanoid } from 'nanoid' +import type { FeatureCollection } from 'geojson' +import { Spinner } from '@/components/ui/spinner' +import { Section } from '@/components/section' +import { FollowupPanel } from '@/components/followup-panel' +import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } from '@/lib/agents' +import { writer } from '@/lib/agents/writer' +import { saveChat, getSystemPrompt } from '@/lib/actions/chat' +import { Chat, AIMessage } from '@/lib/types' +import { getChatTitle } from '@/lib/utils' +import { BotMessage } from '@/components/message' +import React from 'react' +import { AIState } from '@/lib/chat/types' +import { getUIStateFromAIState } from '@/lib/chat/ui-mapper' +import type { AI } from './ai' + +export async function submit(formData?: FormData, skip?: boolean) { + const aiState = getMutableAIState() + const threadId = formData?.get('threadId') as string + const uiStream = createStreamableUI() + const isGenerating = createStreamableValue(true) + const isCollapsed = createStreamableValue(false) + + const action = formData?.get('action') as string; + if (action === 'resolution_search') { + const file = formData?.get('file') as File; + if (!file) { + throw new Error('No file provided for resolution search.'); + } + + 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 => + (!threadId || message.threadId === threadId) && + 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', threadId } + ] + }); + messages.push({ role: 'user', content }); + + const summaryStream = createStreamableValue(''); + + async function processResolutionSearch() { + try { + const analysisResult = await resolutionSearch(messages) as any; + summaryStream.done(analysisResult.summary || 'Analysis complete.'); + messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' }); + + const sanitizedMessages: CoreMessage[] = messages.map(m => { + if (Array.isArray(m.content)) { + return { + ...m, + content: m.content.filter(part => part.type !== 'image') + } as CoreMessage + } + return m + }) + + const relatedQueries = await querySuggestor(uiStream, sanitizedMessages, threadId); + uiStream.append( +
+ +
+ ); + + await new Promise(resolve => setTimeout(resolve, 500)); + const groupeId = nanoid(); + + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: groupeId, + role: 'assistant', + content: analysisResult.summary || 'Analysis complete.', + type: 'response', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: JSON.stringify(analysisResult), + type: 'resolution_search_result', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: 'followup', + type: 'followup', + threadId + } + ] + }); + } 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 messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter( + message => + (!threadId || message.threadId === threadId) && + message.role !== 'tool' && + message.type !== 'followup' && + message.type !== 'related' && + message.type !== 'end' && + message.type !== 'resolution_search_result' + ) + + 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 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 = formData ? JSON.stringify(Object.fromEntries(formData)) : userInput; + const type = 'input'; + + aiState.update({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: nanoid(), + role: 'user', + content, + type, + threadId + }, + ], + }); + + const definitionStream = createStreamableValue(''); + definitionStream.done(definition); + + const answerSection = ( +
+ +
+ ); + + uiStream.append(answerSection); + + const relatedQueries = { items: [] }; + + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: groupeId, + role: 'assistant', + content: definition, + type: 'response', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: 'followup', + type: 'followup', + threadId + }, + ], + }); + + isGenerating.done(false); + uiStream.done(); + + return { + id: nanoid(), + isGenerating: isGenerating.value, + component: uiStream.value, + isCollapsed: isCollapsed.value, + }; + } + const file = !skip ? (formData?.get('file') as File) : undefined + + if (!userInput && !file) { + isGenerating.done(false) + return { + id: nanoid(), + isGenerating: isGenerating.value, + component: null, + isCollapsed: isCollapsed.value + } + } + + 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, + threadId + } + ] + }) + messages.push({ + role: 'user', + content + } as CoreMessage) + } + + const userId = 'anonymous' + const currentSystemPrompt = (await getSystemPrompt(userId)) || '' + const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' + + const streamText = createStreamableValue('') + + async function processEvents() { + try { + 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(false) + isCollapsed.done(false) + aiState.done({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: nanoid(), + role: 'assistant', + content: `inquiry: ${inquiry?.question}`, + threadId + } + ] + }) + return + } + + isCollapsed.done(true) + let answer = '' + let toolOutputs: ToolResultPart[] = [] + let errorOccurred = false + + while ( + useSpecificAPI + ? answer.length === 0 + : answer.length === 0 && !errorOccurred + ) { + const { fullResponse, hasError, toolResponses } = await researcher( + currentSystemPrompt, + uiStream, + streamText, + messages, + mapProvider, + useSpecificAPI + ) + 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', + threadId + } + ] + }) + }) + } + } + + if (useSpecificAPI && answer.length === 0) { + const modifiedMessages = aiState + .get() + .messages.map((msg: any) => + 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(answer) + } + + if (!errorOccurred) { + const relatedQueries = await querySuggestor(uiStream, messages, threadId) + 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', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: JSON.stringify(relatedQueries), + type: 'related', + threadId + }, + { + id: groupeId, + role: 'assistant', + content: 'followup', + type: 'followup', + threadId + } + ] + }) + } + } catch (error) { + console.error('Error in processEvents:', error) + } finally { + isGenerating.done(false); + uiStream.done(); + } + } + + processEvents() + + return { + id: nanoid(), + isGenerating: isGenerating.value, + component: uiStream.value, + isCollapsed: isCollapsed.value + } +} + +export async function clearChat() { + const aiState = getMutableAIState() + + aiState.done({ + chatId: nanoid(), + messages: [] + }) +} + +export async function onGetUIState() { + const aiState = getAIState() as AIState + if (aiState) { + const uiState = getUIStateFromAIState(aiState) + return uiState + } + return [] +} + +export async function onSetAIState({ state }: { state: AIState }) { + const { messages: allMessages } = state + + // Group messages by threadId. Default to state.chatId if no threadId. + const messagesByThread = allMessages.reduce((acc, msg) => { + const tid = msg.threadId || state.chatId + if (!acc[tid]) acc[tid] = [] + acc[tid].push(msg) + return acc + }, {} as Record) + + 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 + } + + for (const [tid, messages] of Object.entries(messagesByThread)) { + if (!messages.some(e => e.type === 'response')) { + continue + } + + const createdAt = new Date() + const path = `/search/${tid}` + const title = getChatTitle(messages) + + const updatedMessages: AIMessage[] = [ + ...messages, + { + id: nanoid(), + role: 'assistant', + content: `end`, + type: 'end', + threadId: tid + } + ] + + const chat: Chat = { + id: tid, + createdAt, + userId: actualUserId, + path, + title, + messages: updatedMessages + } + + // Background save + saveChat(chat, actualUserId).catch(err => { + console.error(`Failed to save chat ${tid} in onSetAIState:`, err) + }) + } +} diff --git a/app/page.tsx b/app/page.tsx index 051e54bb..a89f4f92 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,16 @@ -import { Chat } from '@/components/chat' -import {nanoid } from 'nanoid' -import { AI } from './actions' +import { nanoid } from 'nanoid' +import { ChatThreadManager } from '@/components/chat-thread-manager' +import { MapDataProvider } from '@/components/map/map-data-context' +import { AI } from './ai' 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..3bfe5bad 100644 --- a/app/search/[id]/page.tsx +++ b/app/search/[id]/page.tsx @@ -1,61 +1,49 @@ 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 { ChatThreadManager } from '@/components/chat-thread-manager'; +import { getChat, getChatMessages } from '@/lib/actions/chat'; +import { AI } from '@/app/ai'; import { MapDataProvider } from '@/components/map/map-data-context'; -import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; // For server-side auth -import type { AIMessage } from '@/lib/types'; // For AIMessage type -import type { Message as DrizzleMessage } from '@/lib/actions/chat-db'; // For DrizzleMessage type +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; +import type { AIMessage } from '@/lib/types'; +import type { Message as DrizzleMessage } from '@/lib/actions/chat-db'; export const maxDuration = 60; export interface SearchPageProps { - params: Promise<{ id: string }>; // Keep as is for now + params: Promise<{ id: string }>; } export async function generateMetadata({ params }: SearchPageProps) { - const { id } = await params; // Keep as is for now - // TODO: Metadata generation might need authenticated user if chats are private - // For now, assuming getChat can be called or it handles anon access for metadata appropriately - const userId = await getCurrentUserIdOnServer(); // Attempt to get user for metadata - const chat = await getChat(id, userId || 'anonymous'); // Pass userId or 'anonymous' if none + const { id } = await params; + const userId = await getCurrentUserIdOnServer(); + const chat = await getChat(id, userId || 'anonymous'); return { title: chat?.title?.toString().slice(0, 50) || 'Search', }; } export default async function SearchPage({ params }: SearchPageProps) { - const { id } = await params; // Keep as is for now + const { id } = await params; const userId = await getCurrentUserIdOnServer(); if (!userId) { - // If no user, redirect to login or show appropriate page - // For now, redirecting to home, but a login page would be better. redirect('/'); } const chat = await getChat(id, userId); if (!chat) { - // If chat doesn't exist or user doesn't have access (handled by getChat) notFound(); } - // Fetch messages for the chat const dbMessages: DrizzleMessage[] = await getChatMessages(chat.id); - // Transform DrizzleMessages to AIMessages const initialMessages: AIMessage[] = dbMessages.map((dbMsg): AIMessage => { return { id: dbMsg.id, - role: dbMsg.role as AIMessage['role'], // Cast role, ensure AIMessage['role'] includes all dbMsg.role possibilities + role: dbMsg.role as AIMessage['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. }; }); @@ -63,14 +51,17 @@ export default async function SearchPage({ params }: SearchPageProps) { - + ); -} \ No newline at end of file +} diff --git a/components/calendar-notepad.tsx b/components/calendar-notepad.tsx index 0c439613..8f4a7f9c 100644 --- a/components/calendar-notepad.tsx +++ b/components/calendar-notepad.tsx @@ -1,7 +1,5 @@ "use client" -"use client" - import type React from "react" import { useState, useEffect } from "react" import { ChevronLeft, ChevronRight, MapPin } from "lucide-react" diff --git a/components/calendar-toggle-context.tsx b/components/calendar-toggle-context.tsx index 7cd2d5e1..abb4286d 100644 --- a/components/calendar-toggle-context.tsx +++ b/components/calendar-toggle-context.tsx @@ -18,13 +18,10 @@ export const useCalendarToggle = () => { } export const CalendarToggleProvider = ({ children }: { children: ReactNode }) => { - const [isPending, startTransition] = useTransition() const [isCalendarOpen, setIsCalendarOpen] = useState(false) const toggleCalendar = () => { - startTransition(() => { - setIsCalendarOpen(prevState => !prevState) - }) + setIsCalendarOpen(prevState => !prevState) } return ( diff --git a/components/chat-messages.tsx b/components/chat-messages.tsx index 6bfa3642..94be6a41 100644 --- a/components/chat-messages.tsx +++ b/components/chat-messages.tsx @@ -1,13 +1,24 @@ 'use client' -import { StreamableValue, useUIState } from 'ai/rsc' -import type { AI, UIState } from '@/app/actions' +import { StreamableValue, useUIState, useStreamableValue } from 'ai/rsc' +import type { AI, UIState } from '@/app/ai' import { CollapsibleMessage } from './collapsible-message' +import { Spinner } from './ui/spinner' interface ChatMessagesProps { messages: UIState } +function MessageSpinner({ isGenerating }: { isGenerating: StreamableValue }) { + const [generating] = useStreamableValue(isGenerating) + if (!generating) return null + return ( +
+ +
+ ) +} + export function ChatMessages({ messages }: ChatMessagesProps) { if (!messages.length) { return null @@ -20,7 +31,8 @@ export function ChatMessages({ messages }: ChatMessagesProps) { acc[message.id] = { id: message.id, components: [], - isCollapsed: message.isCollapsed + isCollapsed: message.isCollapsed, + isGenerating: message.isGenerating } } acc[message.id].components.push(message.component) @@ -37,34 +49,35 @@ export function ChatMessages({ messages }: ChatMessagesProps) { id: string components: React.ReactNode[] isCollapsed?: StreamableValue + isGenerating?: StreamableValue }[] return ( - <> +
{groupedMessagesArray.map( ( - groupedMessage: { - id: string - components: React.ReactNode[] - isCollapsed?: StreamableValue - }, + groupedMessage, index ) => ( - ( -
{component}
- )), - isCollapsed: groupedMessage.isCollapsed - }} - isLastMessage={ - groupedMessage.id === messages[messages.length - 1].id - } - /> +
+ ( +
{component}
+ )), + isCollapsed: groupedMessage.isCollapsed + }} + isLastMessage={ + groupedMessage.id === messages[messages.length - 1].id + } + /> + {groupedMessage.isGenerating && ( + + )} +
) )} - +
) } diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c45844d3..170dedd9 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState, useRef, ChangeEvent, forwardRef, useImperativeHandle, useCallback } from 'react' -import type { AI, UIState } from '@/app/actions' +import type { AI, UIState } from '@/app/ai' import { useUIState, useActions, readStreamableValue } from 'ai/rsc' // Removed import of useGeospatialToolMcp as it's no longer used/available import { cn } from '@/lib/utils' @@ -21,6 +21,8 @@ interface ChatPanelProps { input: string setInput: (value: string) => void onSuggestionsChange?: (suggestions: PartialRelated | null) => void + threadId?: string + onNewChat?: () => void } export interface ChatPanelRef { @@ -28,9 +30,9 @@ export interface ChatPanelRef { submitForm: () => void } -export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange }, ref) => { +export const ChatPanel = forwardRef(({ messages, input, setInput, onSuggestionsChange, threadId, onNewChat }, ref) => { const [, setMessages] = useUIState() - const { submit, clearChat } = useActions() + const { submit } = useActions() // Removed mcp instance as it's no longer passed to submit const { mapProvider } = useSettingsStore() const [isMobile, setIsMobile] = useState(false) @@ -108,11 +110,15 @@ export const ChatPanel = forwardRef(({ messages, i ...currentMessages, { id: nanoid(), + threadId, component: } ]) const formData = new FormData(e.currentTarget) + if (threadId) { + formData.append('threadId', threadId) + } if (selectedFile) { formData.append('file', selectedFile) } @@ -121,13 +127,7 @@ export const ChatPanel = forwardRef(({ messages, i clearAttachment() const responseMessage = await submit(formData) - setMessages(currentMessages => [...currentMessages, responseMessage as any]) - } - - const handleClear = async () => { - setMessages([]) - clearAttachment() - await clearChat() + setMessages(currentMessages => [...currentMessages, { ...responseMessage as any, threadId }]) } const debouncedGetSuggestions = useCallback( @@ -160,31 +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 ( -
- -
- ) - } - return (
(({ messages, i )} > + (({ messages, i )} +
+ {!isMobile && messages.length > 0 && ( + + )} +