From 42c201ba8cc7f94bd55bbfdc399ee3c6a9d47741 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 13:27:28 +0000 Subject: [PATCH 01/11] feat: add message editing and individual deletion functionality - Implement `updateMessage`, `deleteMessage`, and `deleteTrailingMessages` in database layer. - Refactor AI generation logic in `app/actions.tsx` into a reusable `processChatWorkflow` function. - Add `resubmit` and `deleteMessageAction` server actions. - Enhance `UserMessage` component with inline editing and deletion UI. - Ensure proper authentication using `getCurrentUserIdOnServer` and state management for trailing message deletion. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com> --- app/actions.tsx | 406 +++++++++++++++++++++++++----------- components/user-message.tsx | 121 ++++++++++- lib/actions/chat-db.ts | 78 ++++++- lib/actions/chat.ts | 29 +++ server.log | 11 - 5 files changed, 499 insertions(+), 146 deletions(-) delete mode 100644 server.log diff --git a/app/actions.tsx b/app/actions.tsx index 9e0ee20a..b1c921cf 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' @@ -359,147 +365,274 @@ 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() { - let action: any = { object: { next: 'proceed' } } - if (!skip) { - const taskManagerResult = await taskManager(messages) - if (taskManagerResult) { - action.object = taskManagerResult.object - } - } + processChatWorkflow({ + aiState, + uiStream, + isGenerating, + isCollapsed, + messages, + groupeId, + currentSystemPrompt, + mapProvider, + useSpecificAPI, + maxMessages, + skipTaskManager: skip + }) - 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 + 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 +}) { + let action: any = { object: { next: 'proceed' } } + if (!skipTaskManager) { + const taskManagerResult = await taskManager(messages) + if (taskManagerResult) { + action.object = taskManagerResult.object } + } - isCollapsed.done(true) - let answer = '' - let toolOutputs: ToolResultPart[] = [] - let errorOccurred = false - const streamText = createStreamableValue() - uiStream.update() + 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 + } - while ( + 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.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' - } - ] - }) + ) + 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 (useSpecificAPI && answer.length === 0) { + 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[] + 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( -
- -
- ) + 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' - } - ] - }) - } + 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() +} + +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)) + } + await updateMessage(messageId, content) + + const truncatedMessages = messages.slice(0, index + 1) + truncatedMessages[index].content = 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 +642,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' @@ -543,6 +700,8 @@ const initialUIState: UIState = [] export const AI = createAI({ actions: { submit, + resubmit, + deleteMessageAction, clearChat }, initialUIState, @@ -655,6 +814,7 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { id, component: ( = ({ + 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,8 +57,42 @@ export const UserMessage: React.FC = ({ (part): part is { type: 'image'; image: string } => part.type === 'image' )?.image + const handleEdit = () => { + setEditContent(textPart || '') + setIsEditing(true) + } + + 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 && (
@@ -45,9 +105,62 @@ export const UserMessage: React.FC = ({ />
)} - {textPart &&
{textPart}
} + {isEditing ? ( +
+