mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
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:
parent
fd7c2780f5
commit
3c43d3d154
@ -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
5
.openhands/setup.sh
Normal file
@ -0,0 +1,5 @@
|
||||
#! /bin/bash
|
||||
|
||||
echo "Setting up the environment..."
|
||||
|
||||
python -m pip install pre-commit
|
||||
@ -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">
|
||||
|
||||
82
frontend/src/hooks/use-auto-title.ts
Normal file
82
frontend/src/hooks/use-auto-title.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
30
frontend/src/hooks/use-document-title-from-state.ts
Normal file
30
frontend/src/hooks/use-document-title-from-state.ts
Normal 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]);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user