Auto-generate conversation titles from first user message (#7390)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
Robert Brennan 2025-03-22 15:07:33 -07:00 committed by GitHub
parent fd7c2780f5
commit 3c43d3d154
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 188 additions and 6 deletions

View File

@ -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:

5
.openhands/setup.sh Normal file
View File

@ -0,0 +1,5 @@
#! /bin/bash
echo "Setting up the environment..."
python -m pip install pre-commit

View File

@ -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 (
<div className="flex items-center justify-between">

View File

@ -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,
]);
}

View File

@ -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<string | null>(null);
useEffect(() => {
if (conversation?.title) {
lastValidTitleRef.current = conversation.title;
document.title = `${conversation.title} - ${suffix}`;
} else {
document.title = suffix;
}
return () => {
document.title = suffix;
};
}, [conversation, suffix]);
}

View File

@ -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,