From 3c43d3d154790b8ac3d8ed8739148696395a7570 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Sat, 22 Mar 2025 15:07:33 -0700 Subject: [PATCH] Auto-generate conversation titles from first user message (#7390) Co-authored-by: openhands Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- .openhands/microagents/repo.md | 1 + .openhands/setup.sh | 5 ++ .../components/features/controls/controls.tsx | 2 + frontend/src/hooks/use-auto-title.ts | 82 +++++++++++++++++++ .../hooks/use-document-title-from-state.ts | 30 +++++++ .../server/routes/manage_conversations.py | 74 +++++++++++++++-- 6 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 .openhands/setup.sh create mode 100644 frontend/src/hooks/use-auto-title.ts create mode 100644 frontend/src/hooks/use-document-title-from-state.ts diff --git a/.openhands/microagents/repo.md b/.openhands/microagents/repo.md index 9b6c76d3de..73dfbf0da4 100644 --- a/.openhands/microagents/repo.md +++ b/.openhands/microagents/repo.md @@ -33,6 +33,7 @@ Frontend: - Testing: - Run tests: `npm run test` - To run specific tests: `npm run test -- -t "TestName"` + - Our test framework is vitest - Building: - Build for production: `npm run build` - Environment Variables: diff --git a/.openhands/setup.sh b/.openhands/setup.sh new file mode 100644 index 0000000000..91df10179d --- /dev/null +++ b/.openhands/setup.sh @@ -0,0 +1,5 @@ +#! /bin/bash + +echo "Setting up the environment..." + +python -m pip install pre-commit diff --git a/frontend/src/components/features/controls/controls.tsx b/frontend/src/components/features/controls/controls.tsx index abf8143a96..eda0cfdb11 100644 --- a/frontend/src/components/features/controls/controls.tsx +++ b/frontend/src/components/features/controls/controls.tsx @@ -5,6 +5,7 @@ import { AgentStatusBar } from "./agent-status-bar"; import { SecurityLock } from "./security-lock"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { ConversationCard } from "../conversation-panel/conversation-card"; +import { useAutoTitle } from "#/hooks/use-auto-title"; interface ControlsProps { setSecurityOpen: (isOpen: boolean) => void; @@ -16,6 +17,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) { const { data: conversation } = useUserConversation( params.conversationId ?? null, ); + useAutoTitle(); return (
diff --git a/frontend/src/hooks/use-auto-title.ts b/frontend/src/hooks/use-auto-title.ts new file mode 100644 index 0000000000..ac5f048775 --- /dev/null +++ b/frontend/src/hooks/use-auto-title.ts @@ -0,0 +1,82 @@ +import { useEffect } from "react"; +import { useParams } from "react-router"; +import { useSelector, useDispatch } from "react-redux"; +import { useQueryClient } from "@tanstack/react-query"; +import { useUpdateConversation } from "./mutation/use-update-conversation"; +import { RootState } from "#/store"; +import OpenHands from "#/api/open-hands"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; + +const defaultTitlePattern = /^Conversation [a-f0-9]+$/; + +/** + * Hook that monitors for the first agent message and triggers title generation. + * This approach is more robust as it ensures the user message has been processed + * by the backend and the agent has responded before generating the title. + */ +export function useAutoTitle() { + const { conversationId } = useParams<{ conversationId: string }>(); + const { data: conversation } = useUserConversation(conversationId ?? null); + const queryClient = useQueryClient(); + const dispatch = useDispatch(); + const { mutate: updateConversation } = useUpdateConversation(); + + const messages = useSelector((state: RootState) => state.chat.messages); + + useEffect(() => { + if ( + !conversation || + !conversationId || + !messages || + messages.length === 0 + ) { + return; + } + + const hasAgentMessage = messages.some( + (message) => message.sender === "assistant", + ); + const hasUserMessage = messages.some( + (message) => message.sender === "user", + ); + + if (!hasAgentMessage || !hasUserMessage) { + return; + } + + if (conversation.title && !defaultTitlePattern.test(conversation.title)) { + return; + } + + updateConversation( + { + id: conversationId, + conversation: { title: "" }, + }, + { + onSuccess: async () => { + try { + const updatedConversation = + await OpenHands.getConversation(conversationId); + + queryClient.setQueryData( + ["user", "conversation", conversationId], + updatedConversation, + ); + } catch (error) { + queryClient.invalidateQueries({ + queryKey: ["user", "conversation", conversationId], + }); + } + }, + }, + ); + }, [ + messages, + conversationId, + conversation, + updateConversation, + queryClient, + dispatch, + ]); +} diff --git a/frontend/src/hooks/use-document-title-from-state.ts b/frontend/src/hooks/use-document-title-from-state.ts new file mode 100644 index 0000000000..92021a3234 --- /dev/null +++ b/frontend/src/hooks/use-document-title-from-state.ts @@ -0,0 +1,30 @@ +import { useParams } from "react-router"; +import { useEffect, useRef } from "react"; +import { useUserConversation } from "./query/use-user-conversation"; + +/** + * Hook that updates the document title based on the current conversation. + * This ensures that any changes to the conversation title are reflected in the document title. + * + * @param suffix Optional suffix to append to the title (default: "OpenHands") + */ +export function useDocumentTitleFromState(suffix = "OpenHands") { + const params = useParams(); + const { data: conversation } = useUserConversation( + params.conversationId ?? null, + ); + const lastValidTitleRef = useRef(null); + + useEffect(() => { + if (conversation?.title) { + lastValidTitleRef.current = conversation.title; + document.title = `${conversation.title} - ${suffix}`; + } else { + document.title = suffix; + } + + return () => { + document.title = suffix; + }; + }, [conversation, suffix]); +} diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 84597f27e5..a5f2f9aa12 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -7,6 +7,8 @@ from pydantic import BaseModel from openhands.core.logger import openhands_logger as logger from openhands.events.action.message import MessageAction +from openhands.events.event import EventSource +from openhands.events.stream import EventStream from openhands.integrations.provider import ( PROVIDER_TOKEN_TYPE, ) @@ -26,6 +28,7 @@ from openhands.server.shared import ( SettingsStoreImpl, config, conversation_manager, + file_store, ) from openhands.server.types import LLMAuthenticationError, MissingSettingsError from openhands.storage.data_models.conversation_metadata import ConversationMetadata @@ -95,10 +98,7 @@ async def _create_new_conversation( extra={'user_id': user_id, 'session_id': conversation_id}, ) - repository_title = ( - selected_repository.split('/')[-1] if selected_repository else None - ) - conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}' + conversation_title = get_default_conversation_title(conversation_id) logger.info(f'Saving metadata for conversation {conversation_id}') await conversation_store.save_metadata( @@ -244,16 +244,78 @@ async def get_conversation( return None +def get_default_conversation_title(conversation_id: str) -> str: + """ + Generate a default title for a conversation based on its ID. + + Args: + conversation_id: The ID of the conversation + + Returns: + A default title string + """ + return f'Conversation {conversation_id[:5]}' + + +async def auto_generate_title(conversation_id: str, user_id: str | None) -> str: + """ + Auto-generate a title for a conversation based on the first user message. + + Args: + conversation_id: The ID of the conversation + user_id: The ID of the user + + Returns: + A generated title string + """ + logger.info(f'Auto-generating title for conversation {conversation_id}') + + try: + # Create an event stream for the conversation + event_stream = EventStream(conversation_id, file_store, user_id) + + # Find the first user message + first_user_message = None + for event in event_stream.get_events(): + if ( + event.source == EventSource.USER + and isinstance(event, MessageAction) + and event.content + and event.content.strip() + ): + first_user_message = event.content + break + + if first_user_message: + first_user_message = first_user_message.strip() + title = first_user_message[:15] + if len(first_user_message) > 15: + title += '...' + logger.info(f'Generated title: {title}') + return title + except Exception as e: + logger.error(f'Error generating title: {str(e)}') + return '' + + @app.patch('/conversations/{conversation_id}') async def update_conversation( request: Request, conversation_id: str, title: str = Body(embed=True) ) -> bool: + user_id = get_user_id(request) conversation_store = await ConversationStoreImpl.get_instance( - config, get_user_id(request), get_github_user_id(request) + config, user_id, get_github_user_id(request) ) metadata = await conversation_store.get_metadata(conversation_id) if not metadata: return False + + # If title is empty or unspecified, auto-generate it from the first user message + if not title or title.isspace(): + title = await auto_generate_title(conversation_id, user_id) + if not title: + title = get_default_conversation_title(conversation_id) + metadata.title = title await conversation_store.save_metadata(metadata) return True @@ -287,7 +349,7 @@ async def _get_conversation_info( try: title = conversation.title if not title: - title = f'Conversation {conversation.conversation_id[:5]}' + title = get_default_conversation_title(conversation.conversation_id) return ConversationInfo( conversation_id=conversation.conversation_id, title=title,