diff --git a/app/actions.tsx b/app/actions.tsx index 673b921e..a30ac335 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -42,6 +42,7 @@ async function submit(formData?: FormData, skip?: boolean) { const action = formData?.get('action') as string; const drawnFeaturesString = formData?.get('drawnFeatures') as string; + const mapProvider = (formData?.get('mapProvider') as string) || 'mapbox'; let drawnFeatures: DrawnFeature[] = []; try { drawnFeatures = drawnFeaturesString ? JSON.parse(drawnFeaturesString) : []; @@ -70,7 +71,7 @@ async function submit(formData?: FormData, skip?: boolean) { ); const userInput = 'Analyze this map view.'; - const content: CoreMessage['content'] = [ + const content: any[] = [ { type: 'text', text: userInput }, { type: 'image', image: dataUrl, mimeType: file.type } ]; @@ -79,12 +80,12 @@ async function submit(formData?: FormData, skip?: boolean) { ...aiState.get(), messages: [ ...aiState.get().messages, - { id: nanoid(), role: 'user', content, type: 'input' } + { id: nanoid(), role: 'user', content: JSON.stringify(content), type: 'input' } ] }); - messages.push({ role: 'user', content }); + messages.push({ role: 'user', content: content as any }); - const summaryStream = createStreamableValue(''); + const summaryStream = createStreamableValue('Analyzing map view...'); const groupeId = nanoid(); async function processResolutionSearch() { @@ -130,8 +131,6 @@ async function submit(formData?: FormData, skip?: boolean) { ); - await new Promise(resolve => setTimeout(resolve, 500)); - aiState.done({ ...aiState.get(), messages: [ @@ -198,306 +197,121 @@ async function submit(formData?: FormData, skip?: boolean) { 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 relatedQueries = { items: [] }; + const input = formData?.get('input') as string + const file = formData?.get('file') as File + if (skip) { 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, + id: nanoid(), 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 + content: 'Analysis skipped.', + type: 'response' + } + ] + }) - if (!userInput && !file) { - isGenerating.done(false) return { id: nanoid(), isGenerating: isGenerating.value, - component: null, + component: ( +
+ +
+ ), isCollapsed: isCollapsed.value } } - const messageParts: { - type: 'text' | 'image' - text?: string - image?: string - mimeType?: string - }[] = [] - - if (userInput) { - messageParts.push({ type: 'text', text: userInput }) + const content: any[] = [] + if (input) { + content.push({ type: 'text', text: input }) } - if (file) { + let dataUrl: string | null = null + if (file && file.type.startsWith('image/')) { 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) + dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}` + content.push({ type: 'image', image: dataUrl, mimeType: file.type }) } - 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 + aiState.update({ + ...aiState.get(), + messages: [ + ...aiState.get().messages, + { + id: nanoid(), + role: 'user', + content: JSON.stringify(content), + type: 'input' } - } + ] + }) + messages.push({ role: 'user', content: content as any }) - 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 - } + const summaryStream = createStreamableValue('') + const groupId = nanoid() - 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, + async function processChat() { + try { + const { getCurrentUserIdOnServer } = await import( + '@/lib/auth/get-current-user' + ) + const actualUserId = await getCurrentUserIdOnServer() + const systemPrompt = await getSystemPrompt(actualUserId || '') || ''; + const { fullResponse } = await researcher( + systemPrompt, uiStream, - streamText, + summaryStream, messages, - mapProvider, - useSpecificAPI, + mapProvider as any, + false, 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() - } + summaryStream.done(fullResponse) - 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, + id: groupId, role: 'assistant', - content: answer, + content: fullResponse, type: 'response' }, { - id: groupeId, + id: groupId, role: 'assistant', content: JSON.stringify(relatedQueries), type: 'related' }, { - id: groupeId, + id: groupId, role: 'assistant', content: 'followup', type: 'followup' } ] }) + } catch (error) { + console.error('Error in chat processing:', error) + summaryStream.error(error) + } finally { + isGenerating.done(false) + uiStream.done() } - - isGenerating.done(false) - uiStream.done() } - processEvents() + processChat() return { id: nanoid(), @@ -507,118 +321,37 @@ async function submit(formData?: FormData, skip?: boolean) { } } -async function clearChat() { - 'use server' - - const aiState = getMutableAIState() - - aiState.done({ - chatId: nanoid(), - messages: [] - }) +export type Message = { + role: 'user' | 'assistant' | 'system' | 'tool' | 'function' | 'data' + content: string | any[] + id: string + type?: + | 'input' + | 'input_related' + | 'response' + | 'inquiry' + | 'related' + | 'followup' + | 'end' + | 'resolution_search_result' + | 'tool' + | 'skip' + | 'drawing_context' + name?: string } export type AIState = { - messages: AIMessage[] chatId: string + messages: Message[] isSharePage?: boolean } export type UIState = { id: string component: React.ReactNode - isGenerating?: StreamableValue - isCollapsed?: StreamableValue + isCollapsed?: boolean }[] -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 @@ -639,11 +372,14 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { switch (type) { case 'input': case 'input_related': - let messageContent: string | any[] + let messageContent: any try { - const json = JSON.parse(content as string) - messageContent = - type === 'input' ? json.input : json.related_query + const json = typeof content === 'string' ? JSON.parse(content) : content + if (type === 'input') { + messageContent = Array.isArray(json) ? json : (json.input || json); + } else { + messageContent = json.related_query || json; + } } catch (e) { messageContent = content } @@ -681,7 +417,11 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { const relatedQueries = createStreamableValue({ items: [] }) - relatedQueries.done(JSON.parse(content as string)) + try { + relatedQueries.done(JSON.parse(content as string)) + } catch (e) { + relatedQueries.done({ items: [] }) + } return { id, component: ( @@ -700,20 +440,24 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } case 'resolution_search_result': { - const analysisResult = JSON.parse(content as string); - const geoJson = analysisResult.geoJson as FeatureCollection; - const image = analysisResult.image as string; + try { + const analysisResult = JSON.parse(content as string); + const geoJson = analysisResult.geoJson as FeatureCollection; + const image = analysisResult.image as string; - return { - id, - component: ( - <> - {image && } - {geoJson && ( - - )} - - ) + return { + id, + component: ( + <> + {image && } + {geoJson && ( + + )} + + ) + } + } catch (e) { + return null; } } } @@ -748,55 +492,88 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { 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 + component: , + isCollapsed: isCollapsed.value } + } catch (e) { + return null } - break default: - return { - id, - component: null - } + return null } }) .filter(message => message !== null) as UIState } + +export const AI = createAI({ + actions: { + submit, + clearChat: async () => { + 'use server' + const aiState = getMutableAIState() + aiState.done({ ...aiState.get(), messages: [] }) + } + }, + initialUIState: [], + initialAIState: { chatId: nanoid(), messages: [] }, + onSetAIState: async ({ state, done }) => { + 'use server' + if (!done) 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) + if (Array.isArray(parsedContent)) { + const textPart = parsedContent.find(p => p.type === 'text'); + title = textPart?.text?.substring(0, 100) || 'Untitled Chat'; + } else { + title = parsedContent.input?.substring(0, 100) || firstMessageContent.substring(0, 100) + } + } catch (e) { + title = firstMessageContent.substring(0, 100) + } + } + } + + const updatedMessages: AIMessage[] = (messages as any[]).map(m => ({ + ...m, + role: m.role as any, + type: m.type as any + })) + updatedMessages.push({ + 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) + } +}) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index 7d877ccd..dbf29637 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -13,7 +13,7 @@ import { useSettingsStore } from '@/lib/store/settings' import { PartialRelated } from '@/lib/schema/related' import { getSuggestions } from '@/lib/actions/suggest' import { useMapData } from './map/map-data-context' -import SuggestionsDropdown from './suggestions-dropdown' +import { toast } from 'sonner' interface ChatPanelProps { messages: UIState @@ -56,7 +56,7 @@ export const ChatPanel = forwardRef(({ messages, i // Detect mobile layout useEffect(() => { const checkMobile = () => { - setIsMobile(window.innerWidth <= 1024) + setIsMobile(window.innerWidth < 768) } checkMobile() window.addEventListener('resize', checkMobile) @@ -67,7 +67,7 @@ export const ChatPanel = forwardRef(({ messages, i const file = e.target.files?.[0] if (file) { if (file.size > 10 * 1024 * 1024) { - alert('File size must be less than 10MB') + toast.error('File size must be less than 10MB') return } setSelectedFile(file) @@ -161,31 +161,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 : 'sticky bottom-0 bg-background z-10 w-full border-t border-border px-2 py-3 md:px-4' )} > + {/* New chat button (appears when there are messages on desktop) */} + {messages.length > 0 && !isMobile && ( +
+ +
+ )} +
(({ messages, i value={input} data-testid="chat-input" className={cn( - 'resize-none w-full min-h-12 rounded-fill border border-input pl-14 pr-12 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', + 'resize-none w-full min-h-12 rounded-fill border border-input pr-12 pt-3 pb-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', isMobile - ? 'mobile-chat-input input bg-background' - : 'bg-muted' + ? 'mobile-chat-input input bg-background px-4' + : 'bg-muted pl-14' )} onChange={e => { setInput(e.target.value) @@ -294,7 +291,7 @@ export const ChatPanel = forwardRef(({ messages, i {selectedFile && (
- + {selectedFile.name}