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:
|
- Testing:
|
||||||
- Run tests: `npm run test`
|
- Run tests: `npm run test`
|
||||||
- To run specific tests: `npm run test -- -t "TestName"`
|
- To run specific tests: `npm run test -- -t "TestName"`
|
||||||
|
- Our test framework is vitest
|
||||||
- Building:
|
- Building:
|
||||||
- Build for production: `npm run build`
|
- Build for production: `npm run build`
|
||||||
- Environment Variables:
|
- 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 { SecurityLock } from "./security-lock";
|
||||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||||
import { ConversationCard } from "../conversation-panel/conversation-card";
|
import { ConversationCard } from "../conversation-panel/conversation-card";
|
||||||
|
import { useAutoTitle } from "#/hooks/use-auto-title";
|
||||||
|
|
||||||
interface ControlsProps {
|
interface ControlsProps {
|
||||||
setSecurityOpen: (isOpen: boolean) => void;
|
setSecurityOpen: (isOpen: boolean) => void;
|
||||||
@ -16,6 +17,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
|||||||
const { data: conversation } = useUserConversation(
|
const { data: conversation } = useUserConversation(
|
||||||
params.conversationId ?? null,
|
params.conversationId ?? null,
|
||||||
);
|
);
|
||||||
|
useAutoTitle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<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.core.logger import openhands_logger as logger
|
||||||
from openhands.events.action.message import MessageAction
|
from openhands.events.action.message import MessageAction
|
||||||
|
from openhands.events.event import EventSource
|
||||||
|
from openhands.events.stream import EventStream
|
||||||
from openhands.integrations.provider import (
|
from openhands.integrations.provider import (
|
||||||
PROVIDER_TOKEN_TYPE,
|
PROVIDER_TOKEN_TYPE,
|
||||||
)
|
)
|
||||||
@ -26,6 +28,7 @@ from openhands.server.shared import (
|
|||||||
SettingsStoreImpl,
|
SettingsStoreImpl,
|
||||||
config,
|
config,
|
||||||
conversation_manager,
|
conversation_manager,
|
||||||
|
file_store,
|
||||||
)
|
)
|
||||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
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},
|
extra={'user_id': user_id, 'session_id': conversation_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
repository_title = (
|
conversation_title = get_default_conversation_title(conversation_id)
|
||||||
selected_repository.split('/')[-1] if selected_repository else None
|
|
||||||
)
|
|
||||||
conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}'
|
|
||||||
|
|
||||||
logger.info(f'Saving metadata for conversation {conversation_id}')
|
logger.info(f'Saving metadata for conversation {conversation_id}')
|
||||||
await conversation_store.save_metadata(
|
await conversation_store.save_metadata(
|
||||||
@ -244,16 +244,78 @@ async def get_conversation(
|
|||||||
return None
|
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}')
|
@app.patch('/conversations/{conversation_id}')
|
||||||
async def update_conversation(
|
async def update_conversation(
|
||||||
request: Request, conversation_id: str, title: str = Body(embed=True)
|
request: Request, conversation_id: str, title: str = Body(embed=True)
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
user_id = get_user_id(request)
|
||||||
conversation_store = await ConversationStoreImpl.get_instance(
|
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)
|
metadata = await conversation_store.get_metadata(conversation_id)
|
||||||
if not metadata:
|
if not metadata:
|
||||||
return False
|
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
|
metadata.title = title
|
||||||
await conversation_store.save_metadata(metadata)
|
await conversation_store.save_metadata(metadata)
|
||||||
return True
|
return True
|
||||||
@ -287,7 +349,7 @@ async def _get_conversation_info(
|
|||||||
try:
|
try:
|
||||||
title = conversation.title
|
title = conversation.title
|
||||||
if not title:
|
if not title:
|
||||||
title = f'Conversation {conversation.conversation_id[:5]}'
|
title = get_default_conversation_title(conversation.conversation_id)
|
||||||
return ConversationInfo(
|
return ConversationInfo(
|
||||||
conversation_id=conversation.conversation_id,
|
conversation_id=conversation.conversation_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user