diff --git a/.env b/.env index b454ca74..4a90bba0 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ DATABASE_URL="postgresql://user:password@host:port/db" +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoiZHVtbXkiLCJhIjoiY2p6YTM0eHlyMDAwMDNia3BkY3R6ZzhpbyJ9.dummy diff --git a/app/actions.tsx b/app/actions.tsx index 9e0ee20a..71689cd3 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -16,7 +16,13 @@ import { inquire, researcher, taskManager, querySuggestor, resolutionSearch, typ // 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 { + saveChat, + getSystemPrompt, + updateMessage, + deleteMessage, + deleteTrailingMessages +} from '@/lib/actions/chat' // Added getSystemPrompt import { Chat, AIMessage } from '@/lib/types' import { UserMessage } from '@/components/user-message' import { BotMessage } from '@/components/message' @@ -80,12 +86,19 @@ async function submit(formData?: FormData, skip?: boolean) { { type: 'image', image: dataUrl, mimeType: file.type } ]; + const messageId = (formData?.get('id') as string) || nanoid(); // Add the new user message to the AI state. aiState.update({ ...aiState.get(), messages: [ ...aiState.get().messages, - { id: nanoid(), role: 'user', content, type: 'input' } + { + id: messageId, + role: 'user', + content, + type: 'input', + createdAt: new Date() + } ] }); messages.push({ role: 'user', content }); @@ -142,25 +155,29 @@ async function submit(formData?: FormData, skip?: boolean) { id: groupeId, role: 'assistant', content: analysisResult.summary || 'Analysis complete.', - type: 'response' + type: 'response', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: JSON.stringify(analysisResult), - type: 'resolution_search_result' + type: 'resolution_search_result', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: JSON.stringify(relatedQueries), - type: 'related' + type: 'related', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: 'followup', - type: 'followup' + type: 'followup', + createdAt: new Date() } ] }); @@ -228,23 +245,24 @@ async function submit(formData?: FormData, skip?: boolean) { role: 'user', content, type, - }, - ], - }); + createdAt: new Date() + } + ] + }) - const definitionStream = createStreamableValue(); - definitionStream.done(definition); + const definitionStream = createStreamableValue() + definitionStream.done(definition) const answerSection = (
- ); + ) - uiStream.append(answerSection); + uiStream.append(answerSection) - const groupeId = nanoid(); - const relatedQueries = { items: [] }; + const groupeId = nanoid() + const relatedQueries = { items: [] } aiState.done({ ...aiState.get(), @@ -255,21 +273,24 @@ async function submit(formData?: FormData, skip?: boolean) { role: 'assistant', content: definition, type: 'response', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: JSON.stringify(relatedQueries), type: 'related', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: 'followup', type: 'followup', - }, - ], - }); + createdAt: new Date() + } + ] + }) isGenerating.done(false); uiStream.done(); @@ -340,16 +361,19 @@ async function submit(formData?: FormData, skip?: boolean) { ? 'input_related' : 'inquiry' + const messageId = (formData?.get('id') as string) || nanoid() + if (content) { aiState.update({ ...aiState.get(), messages: [ ...aiState.get().messages, { - id: nanoid(), + id: messageId, role: 'user', content, - type + type, + createdAt: new Date() } ] }) @@ -359,14 +383,64 @@ async function submit(formData?: FormData, skip?: boolean) { } as CoreMessage) } - const userId = 'anonymous' + const { getCurrentUserIdOnServer } = await import( + '@/lib/auth/get-current-user' + ) + const userId = (await getCurrentUserIdOnServer()) || 'anonymous' const currentSystemPrompt = (await getSystemPrompt(userId)) || '' const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' - async function processEvents() { + processChatWorkflow({ + aiState, + uiStream, + isGenerating, + isCollapsed, + messages, + groupeId, + currentSystemPrompt, + mapProvider, + useSpecificAPI, + maxMessages, + skipTaskManager: skip + }) + + return { + id: nanoid(), + isGenerating: isGenerating.value, + component: uiStream.value, + isCollapsed: isCollapsed.value + } +} + +async function processChatWorkflow({ + aiState, + uiStream, + isGenerating, + isCollapsed, + messages, + groupeId, + currentSystemPrompt, + mapProvider, + useSpecificAPI, + maxMessages, + skipTaskManager = false +}: { + aiState: any + uiStream: any + isGenerating: any + isCollapsed: any + messages: CoreMessage[] + groupeId: string + currentSystemPrompt: string + mapProvider: any + useSpecificAPI: boolean + maxMessages: number + skipTaskManager?: boolean +}) { + try { let action: any = { object: { next: 'proceed' } } - if (!skip) { + if (!skipTaskManager) { const taskManagerResult = await taskManager(messages) if (taskManagerResult) { action.object = taskManagerResult.object @@ -375,9 +449,6 @@ async function submit(formData?: FormData, skip?: boolean) { if (action.object.next === 'inquire') { const inquiry = await inquire(uiStream, messages) - uiStream.done() - isGenerating.done() - isCollapsed.done(false) aiState.done({ ...aiState.get(), messages: [ @@ -385,10 +456,14 @@ async function submit(formData?: FormData, skip?: boolean) { { id: nanoid(), role: 'assistant', - content: `inquiry: ${inquiry?.question}` + content: `inquiry: ${inquiry?.question}`, + createdAt: new Date() } ] }) + isGenerating.done(false) + isCollapsed.done(false) + uiStream.done() return } @@ -396,15 +471,26 @@ async function submit(formData?: FormData, skip?: boolean) { let answer = '' let toolOutputs: ToolResultPart[] = [] let errorOccurred = false - const streamText = createStreamableValue() - uiStream.update() + const streamText = createStreamableValue('') + + const answerSection = ( +
+ +
+ ) + + uiStream.update(answerSection) while ( useSpecificAPI ? answer.length === 0 : answer.length === 0 && !errorOccurred ) { - const { fullResponse, hasError, toolResponses } = await researcher( + const { + fullResponse, + hasError, + toolResponses + } = await researcher( currentSystemPrompt, uiStream, streamText, @@ -432,22 +518,24 @@ async function submit(formData?: FormData, skip?: boolean) { ] }) }) + } else { + // If no tool calls and researcher finished, break to possibly call writer or end + break } } if (useSpecificAPI && answer.length === 0) { - const modifiedMessages = aiState - .get() - .messages.map(msg => + const modifiedMessages = (aiState.get().messages as AIMessage[]).map( + (msg: AIMessage) => msg.role === 'tool' - ? { + ? ({ ...msg, role: 'assistant', content: JSON.stringify(msg.content), type: 'tool' - } + } as AIMessage) : msg - ) as CoreMessage[] + ) as CoreMessage[] const latestMessages = modifiedMessages.slice(maxMessages * -1) answer = await writer( currentSystemPrompt, @@ -459,7 +547,12 @@ async function submit(formData?: FormData, skip?: boolean) { streamText.done() } - if (!errorOccurred) { + if (answer.length === 0 && !errorOccurred) { + answer = "I'm sorry, I couldn't generate a response. Please try again." + streamText.done(answer) + } + + if (!errorOccurred || answer.length > 0) { const relatedQueries = await querySuggestor(uiStream, messages) uiStream.append(
@@ -477,29 +570,138 @@ async function submit(formData?: FormData, skip?: boolean) { id: groupeId, role: 'assistant', content: answer, - type: 'response' + type: 'response', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: JSON.stringify(relatedQueries), - type: 'related' + type: 'related', + createdAt: new Date() }, { id: groupeId, role: 'assistant', content: 'followup', - type: 'followup' + type: 'followup', + createdAt: new Date() } ] }) } + } catch (error) { + console.error('Error in processChatWorkflow:', error) + uiStream.append( +
+ An error occurred while generating the response. Please try again. +
+ ) + aiState.done(aiState.get()) + } finally { + isGenerating.done(false) + uiStream.done() + } +} +async function resubmit( + messageId: string, + content: string, + mapProvider: 'mapbox' | 'google' = 'mapbox' +) { + 'use server' + + const aiState = getMutableAIState() + const uiStream = createStreamableUI() + const isGenerating = createStreamableValue(true) + const isCollapsed = createStreamableValue(false) + + const messages = aiState.get().messages + const index = messages.findIndex(m => m.id === messageId) + + if (index === -1) { isGenerating.done(false) uiStream.done() + return { + id: nanoid(), + isGenerating: isGenerating.value, + component: null, + isCollapsed: isCollapsed.value + } } - processEvents() + const editedMessage = messages[index] + const chatId = aiState.get().chatId + + if (editedMessage.createdAt) { + await deleteTrailingMessages(chatId, new Date(editedMessage.createdAt)) + } + const truncatedMessages = messages.slice(0, index + 1) + const editedMessageInState = truncatedMessages[index] + + if (Array.isArray(editedMessageInState.content)) { + const textPart = editedMessageInState.content.find(p => p.type === 'text') as + | { type: 'text'; text: string } + | undefined + if (textPart) { + textPart.text = content + } + } else { + editedMessageInState.content = content + } + + await updateMessage( + messageId, + typeof editedMessageInState.content === 'object' + ? JSON.stringify(editedMessageInState.content) + : editedMessageInState.content + ) + + aiState.update({ + ...aiState.get(), + messages: truncatedMessages + }) + + const coreMessages: CoreMessage[] = truncatedMessages + .filter( + message => + message.role !== 'tool' && + message.type !== 'followup' && + message.type !== 'related' && + message.type !== 'end' && + message.type !== 'resolution_search_result' + ) + .map(m => { + return { + role: m.role as 'user' | 'assistant' | 'system' | 'tool', + content: m.content + } as CoreMessage + }) + + const groupeId = nanoid() + const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true' + const maxMessages = useSpecificAPI ? 5 : 10 + coreMessages.splice(0, Math.max(coreMessages.length - maxMessages, 0)) + + const { getCurrentUserIdOnServer } = await import( + '@/lib/auth/get-current-user' + ) + const userId = (await getCurrentUserIdOnServer()) || 'anonymous' + const currentSystemPrompt = (await getSystemPrompt(userId)) || '' + + processChatWorkflow({ + aiState, + uiStream, + isGenerating, + isCollapsed, + messages: coreMessages, + groupeId, + currentSystemPrompt, + mapProvider, + useSpecificAPI, + maxMessages, + skipTaskManager: true // Usually we want to skip task manager on resubmit + }) return { id: nanoid(), @@ -509,6 +711,30 @@ async function submit(formData?: FormData, skip?: boolean) { } } +async function deleteMessageAction(messageId: string) { + 'use server' + + const aiState = getMutableAIState() + const messages = aiState.get().messages + const index = messages.findIndex(m => m.id === messageId) + + if (index !== -1) { + const messageToDelete = messages[index] + const chatId = aiState.get().chatId + + if (messageToDelete.createdAt) { + await deleteTrailingMessages(chatId, new Date(messageToDelete.createdAt)) + } + await deleteMessage(messageId) + + const truncatedMessages = messages.slice(0, index) + aiState.done({ + ...aiState.get(), + messages: truncatedMessages + }) + } +} + async function clearChat() { 'use server' @@ -540,88 +766,7 @@ const initialAIState: AIState = { 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 getUIStateFromAIState = (aiState: AIState): UIState => { const chatId = aiState.chatId const isSharePage = aiState.isSharePage return aiState.messages @@ -655,6 +800,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { id, component: ( { }) .filter(message => message !== null) as UIState } + +export const AI = createAI({ + actions: { + submit, + resubmit, + deleteMessageAction, + 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) + } +}) diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c45844d3..b34697f5 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -36,10 +36,13 @@ export const ChatPanel = forwardRef(({ messages, i const [isMobile, setIsMobile] = useState(false) const [selectedFile, setSelectedFile] = useState(null) const [suggestions, setSuggestionsState] = useState(null) - const setSuggestions = useCallback((s: PartialRelated | null) => { - setSuggestionsState(s) - onSuggestionsChange?.(s) - }, [onSuggestionsChange, setSuggestionsState]) + const setSuggestions = useCallback( + (s: PartialRelated | null) => { + setSuggestionsState(s) + onSuggestionsChange?.(s) + }, + [onSuggestionsChange, setSuggestionsState] + ) const { mapData } = useMapData() const debounceTimeoutRef = useRef(null) const inputRef = useRef(null) @@ -104,15 +107,17 @@ export const ChatPanel = forwardRef(({ messages, i }) } + const id = nanoid() setMessages(currentMessages => [ ...currentMessages, { - id: nanoid(), - component: + id, + component: } ]) const formData = new FormData(e.currentTarget) + formData.append('id', id) if (selectedFile) { formData.append('file', selectedFile) } @@ -153,7 +158,7 @@ export const ChatPanel = forwardRef(({ messages, i } }, 500) // 500ms debounce delay }, - [mapData] + [mapData, setSuggestions] ) useEffect(() => { diff --git a/components/followup-panel.tsx b/components/followup-panel.tsx index 08642530..b5b776e2 100644 --- a/components/followup-panel.tsx +++ b/components/followup-panel.tsx @@ -19,10 +19,11 @@ export function FollowupPanel() { event.preventDefault() const formData = new FormData(event.currentTarget as HTMLFormElement) + const id = Date.now().toString() const userMessage = { - id: Date.now(), + id, isGenerating: false, - component: + component: } // Removed mcp argument from submit call diff --git a/components/header-search-button.tsx b/components/header-search-button.tsx index 92cb1c65..460e9189 100644 --- a/components/header-search-button.tsx +++ b/components/header-search-button.tsx @@ -48,11 +48,12 @@ export function HeaderSearchButton() { setIsAnalyzing(true) try { + const id = nanoid() setMessages(currentMessages => [ ...currentMessages, { - id: nanoid(), - component: + id, + component: } ]) diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index 9437b3ea..f1be4caa 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -454,7 +454,16 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number geolocationWatchIdRef.current = null } } - }, [setMap, setIsMapLoaded, captureMapCenter, handleUserInteraction, stopRotation]) + }, [ + setMap, + setIsMapLoaded, + captureMapCenter, + handleUserInteraction, + stopRotation, + mapData.cameraState, + position?.latitude, + position?.longitude + ]) // Handle map mode changes useEffect(() => { diff --git a/components/search-related.tsx b/components/search-related.tsx index d4a58b08..e1339ef7 100644 --- a/components/search-related.tsx +++ b/components/search-related.tsx @@ -41,9 +41,10 @@ export const SearchRelated: React.FC = ({ query = submitter.value } + const id = Date.now().toString() const userMessage = { - id: Date.now(), - component: + id, + component: } // Removed mcp argument from submit call diff --git a/components/user-message.tsx b/components/user-message.tsx index 03b8ea8d..5d0aff23 100644 --- a/components/user-message.tsx +++ b/components/user-message.tsx @@ -1,23 +1,50 @@ -import React from 'react' +'use client' + +import React, { useState } from 'react' import Image from 'next/image' import { ChatShare } from './chat-share' +import { useActions, useUIState } from 'ai/rsc' +import { AI } from '@/app/actions' +import { useSettingsStore } from '@/lib/store/settings' +import { Button } from './ui/button' +import { Pencil, Copy, Trash2, Check, X } from 'lucide-react' +import { toast } from 'sonner' +import Textarea from 'react-textarea-autosize' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from './ui/alert-dialog' type UserMessageContentPart = | { type: 'text'; text: string } | { type: 'image'; image: string } // data URL type UserMessageProps = { + id?: string content: string | UserMessageContentPart[] chatId?: string showShare?: boolean } export const UserMessage: React.FC = ({ + id, content, chatId, showShare = false }) => { const enableShare = process.env.ENABLE_SHARE === 'true' + const { resubmit, deleteMessageAction } = useActions() + const [, setMessages] = useUIState() + + const [isEditing, setIsEditing] = useState(false) + const [editContent, setEditContent] = useState('') // Normalize content to an array const contentArray = @@ -31,23 +58,142 @@ export const UserMessage: React.FC = ({ (part): part is { type: 'image'; image: string } => part.type === 'image' )?.image + const handleEdit = () => { + setEditContent(textPart || '') + setIsEditing(true) + } + + const copyToClipboard = () => { + if (textPart) { + navigator.clipboard.writeText(textPart) + toast.success('Copied to clipboard') + } + } + + const { mapProvider } = useSettingsStore() + + const handleSave = async () => { + if (!id || !editContent.trim()) return + + setIsEditing(false) + + // Truncate UI state + setMessages(currentMessages => { + const index = currentMessages.findIndex(m => m.id === id) + return currentMessages.slice(0, index + 1) + }) + + const response = await resubmit(id, editContent, mapProvider) + setMessages(currentMessages => [...currentMessages, response]) + } + + const handleDelete = async () => { + if (!id) return + + // Truncate UI state + setMessages(currentMessages => { + const index = currentMessages.findIndex(m => m.id === id) + return currentMessages.slice(0, index) + }) + + await deleteMessageAction(id) + } + return ( -
-
- {imagePart && ( -
- attachment -
- )} - {textPart &&
{textPart}
} +
+
+
+ {imagePart && ( +
+ attachment +
+ )} + {isEditing ? ( +
+