Skip to content

Commit 59efa6c

Browse files
authored
feat(DATAGO-118394): Implement automatic chat title generation using LLM (#586)
This introduces animated AI-generated session titles to the chat UI, enhancing user feedback and visibility when session titles are being generated or updated. The changes add animation effects to session titles, provide an "AI Rename" option in the session list, and refactor the handling of session naming to be more responsive and visually engaging.
1 parent 02fb250 commit 59efa6c

25 files changed

+1596
-305
lines changed

client/webui/frontend/src/lib/components/chat/ChatMessage.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,11 @@ const MessageContent = React.memo<{ message: MessageFE; isStreaming?: boolean }>
204204
// Sanitize the HTML to prevent XSS
205205
// Allow mention chips and their data attributes
206206
const cleanHtml = DOMPurify.sanitize(message.displayHtml, {
207-
ALLOWED_TAGS: ['span', 'br'],
208-
ALLOWED_ATTR: ['class', 'contenteditable', 'data-internal', 'data-person-id', 'data-person-name', 'data-display']
207+
ALLOWED_TAGS: ["span", "br"],
208+
ALLOWED_ATTR: ["class", "contenteditable", "data-internal", "data-person-id", "data-person-name", "data-display"],
209209
});
210210

211-
return (
212-
<div
213-
className="message-with-mentions whitespace-pre-wrap break-words"
214-
dangerouslySetInnerHTML={{ __html: cleanHtml }}
215-
/>
216-
);
211+
return <div className="message-with-mentions break-words whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
217212
}
218213

219214
const renderContent = () => {

client/webui/frontend/src/lib/components/chat/MessageHoverButtons.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const MessageHoverButtons: React.FC<MessageHoverButtonsProps> = ({ messag
4646
// For user messages with displayHtml, copy both HTML and plain text (only if mentions enabled)
4747
if (message.isUser && message.displayHtml && mentionsEnabled) {
4848
const clipboardItem = new ClipboardItem({
49-
'text/html': new Blob([message.displayHtml], { type: 'text/html' }),
50-
'text/plain': new Blob([text.trim()], { type: 'text/plain' })
49+
"text/html": new Blob([message.displayHtml], { type: "text/html" }),
50+
"text/plain": new Blob([text.trim()], { type: "text/plain" }),
5151
});
5252

5353
navigator.clipboard

client/webui/frontend/src/lib/components/chat/SessionList.tsx

Lines changed: 153 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,62 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from "react"
22
import { useInView } from "react-intersection-observer";
33
import { useNavigate } from "react-router-dom";
44

5-
import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Loader2 } from "lucide-react";
5+
import { Trash2, Check, X, Pencil, MessageCircle, FolderInput, MoreHorizontal, PanelsTopLeft, Sparkles, Loader2 } from "lucide-react";
66

77
import { api } from "@/lib/api";
8-
import { useChatContext, useConfigContext } from "@/lib/hooks";
8+
import { useChatContext, useConfigContext, useTitleGeneration, useTitleAnimation } from "@/lib/hooks";
99
import type { Project, Session } from "@/lib/types";
10+
11+
interface SessionNameProps {
12+
session: Session;
13+
isCurrentSession: boolean;
14+
isResponding: boolean;
15+
}
16+
17+
const SessionName: React.FC<SessionNameProps> = ({ session, isCurrentSession, isResponding }) => {
18+
const { autoTitleGenerationEnabled } = useConfigContext();
19+
20+
const displayName = useMemo(() => {
21+
if (session.name && session.name.trim()) {
22+
return session.name;
23+
}
24+
// Fallback to "New Chat" if no name
25+
return "New Chat";
26+
}, [session.name]);
27+
28+
// Pass session ID to useTitleAnimation so it can listen for title generation events
29+
const { text: animatedName, isAnimating, isGenerating } = useTitleAnimation(displayName, session.id);
30+
31+
const isWaitingForTitle = useMemo(() => {
32+
// Always show pulse when isGenerating (manual "Rename with AI")
33+
if (isGenerating) {
34+
return true;
35+
}
36+
37+
if (!autoTitleGenerationEnabled) {
38+
return false; // No pulse when auto title generation is disabled
39+
}
40+
const isNewChat = !session.name || session.name === "New Chat";
41+
return isCurrentSession && isNewChat && isResponding;
42+
}, [session.name, isCurrentSession, isResponding, isGenerating, autoTitleGenerationEnabled]);
43+
44+
// Show slow pulse while waiting for title, faster pulse during transition animation
45+
const animationClass = useMemo(() => {
46+
if (isGenerating || isAnimating) {
47+
if (isWaitingForTitle) {
48+
return "animate-pulse-slow";
49+
}
50+
return "animate-pulse opacity-50";
51+
}
52+
// For automatic title generation waiting state
53+
if (isWaitingForTitle) {
54+
return "animate-pulse-slow";
55+
}
56+
return "opacity-100";
57+
}, [isWaitingForTitle, isAnimating, isGenerating]);
58+
59+
return <span className={`truncate font-semibold transition-opacity duration-300 ${animationClass}`}>{animatedName}</span>;
60+
};
1061
import { formatTimestamp, getErrorMessage } from "@/lib/utils";
1162
import { MoveSessionDialog, ProjectBadge, SessionSearch } from "@/lib/components/chat";
1263
import {
@@ -46,8 +97,9 @@ interface SessionListProps {
4697

4798
export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
4899
const navigate = useNavigate();
49-
const { sessionId, handleSwitchSession, updateSessionName, openSessionDeleteModal, addNotification, displayError } = useChatContext();
100+
const { sessionId, handleSwitchSession, updateSessionName, openSessionDeleteModal, addNotification, displayError, isResponding } = useChatContext();
50101
const { persistenceEnabled } = useConfigContext();
102+
const { generateTitle } = useTitleGeneration();
51103
const inputRef = useRef<HTMLInputElement>(null);
52104

53105
const [sessions, setSessions] = useState<Session[]>([]);
@@ -59,6 +111,7 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
59111
const [selectedProject, setSelectedProject] = useState<string>("all");
60112
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
61113
const [sessionToMove, setSessionToMove] = useState<Session | null>(null);
114+
const [regeneratingTitleForSession, setRegeneratingTitleForSession] = useState<string | null>(null);
62115

63116
const { ref: loadMoreRef, inView } = useInView({
64117
threshold: 0,
@@ -103,16 +156,38 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
103156
return prevSessions;
104157
});
105158
};
159+
const handleTitleUpdated = async (event: Event) => {
160+
const customEvent = event as CustomEvent;
161+
const { sessionId: updatedSessionId } = customEvent.detail;
162+
163+
// Fetch the updated session from backend to get the new title
164+
try {
165+
const sessionData = await api.webui.get(`/api/v1/sessions/${updatedSessionId}`);
166+
const updatedSession = sessionData?.data;
167+
168+
if (updatedSession) {
169+
setSessions(prevSessions => {
170+
return prevSessions.map(s => (s.id === updatedSessionId ? { ...s, name: updatedSession.name } : s));
171+
});
172+
}
173+
} catch (error) {
174+
console.error("[SessionList] Error fetching updated session:", error);
175+
// Fallback: just refresh the entire list
176+
fetchSessions(1, false);
177+
}
178+
};
106179
const handleBackgroundTaskCompleted = () => {
107180
// Refresh session list when background task completes to update indicators
108181
fetchSessions(1, false);
109182
};
110183
window.addEventListener("new-chat-session", handleNewSession);
111184
window.addEventListener("session-updated", handleSessionUpdated as EventListener);
185+
window.addEventListener("session-title-updated", handleTitleUpdated);
112186
window.addEventListener("background-task-completed", handleBackgroundTaskCompleted);
113187
return () => {
114188
window.removeEventListener("new-chat-session", handleNewSession);
115189
window.removeEventListener("session-updated", handleSessionUpdated as EventListener);
190+
window.removeEventListener("session-title-updated", handleTitleUpdated);
116191
window.removeEventListener("background-task-completed", handleBackgroundTaskCompleted);
117192
};
118193
}, [fetchSessions]);
@@ -186,6 +261,70 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
186261
navigate(`/projects/${session.projectId}`);
187262
};
188263

264+
const handleRenameWithAI = useCallback(
265+
async (session: Session) => {
266+
if (regeneratingTitleForSession) {
267+
addNotification?.("AI rename already in progress", "info");
268+
return;
269+
}
270+
271+
setRegeneratingTitleForSession(session.id);
272+
addNotification?.("Generating AI name...", "info");
273+
274+
try {
275+
// Fetch all tasks/messages for this session
276+
const data = await api.webui.get(`/api/v1/sessions/${session.id}/chat-tasks`);
277+
const tasks = data.tasks || [];
278+
279+
if (tasks.length === 0) {
280+
addNotification?.("No messages found in this session", "warning");
281+
setRegeneratingTitleForSession(null);
282+
return;
283+
}
284+
285+
// Parse and extract all messages from all tasks
286+
const allMessages: string[] = [];
287+
288+
for (const task of tasks) {
289+
const messageBubbles = JSON.parse(task.messageBubbles);
290+
for (const bubble of messageBubbles) {
291+
const text = bubble.text || "";
292+
if (text.trim()) {
293+
allMessages.push(text.trim());
294+
}
295+
}
296+
}
297+
298+
if (allMessages.length === 0) {
299+
addNotification?.("No text content found in session", "warning");
300+
setRegeneratingTitleForSession(null);
301+
return;
302+
}
303+
304+
// Create a summary of the conversation for better context
305+
// Use LAST 3 messages of each type to capture recent conversation
306+
const userMessages = allMessages.filter((_, idx) => idx % 2 === 0); // Assuming alternating user/agent
307+
const agentMessages = allMessages.filter((_, idx) => idx % 2 === 1);
308+
309+
const userSummary = userMessages.slice(-3).join(" | ");
310+
const agentSummary = agentMessages.slice(-3).join(" | ");
311+
312+
// Call the title generation service with the full context
313+
// Pass current title so polling can detect the change
314+
// Pass force=true to bypass the "already has title" check
315+
await generateTitle(session.id, userSummary, agentSummary, session.name || "New Chat", true);
316+
317+
addNotification?.("Title regenerated successfully", "success");
318+
} catch (error) {
319+
console.error("Error regenerating title:", error);
320+
addNotification?.(`Failed to regenerate title: ${error instanceof Error ? error.message : "Unknown error"}`, "warning");
321+
} finally {
322+
setRegeneratingTitleForSession(null);
323+
}
324+
},
325+
[generateTitle, addNotification, regeneratingTitleForSession]
326+
);
327+
189328
const handleMoveConfirm = async (targetProjectId: string | null) => {
190329
if (!sessionToMove) return;
191330

@@ -229,22 +368,6 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
229368
return formatTimestamp(dateString);
230369
};
231370

232-
const getSessionDisplayName = (session: Session) => {
233-
if (session.name && session.name.trim()) {
234-
return session.name;
235-
}
236-
// Generate a short, readable identifier from the session ID
237-
const sessionId = session.id;
238-
if (sessionId.startsWith("web-session-")) {
239-
// Extract the UUID part and create a short identifier
240-
const uuid = sessionId.replace("web-session-", "");
241-
const shortId = uuid.substring(0, 8);
242-
return `Chat ${shortId}`;
243-
}
244-
// Fallback for other ID formats
245-
return `Session ${sessionId.substring(0, 8)}`;
246-
};
247-
248371
// Get unique project names from sessions, sorted alphabetically
249372
const projectNames = useMemo(() => {
250373
const uniqueProjectNames = new Set<string>();
@@ -339,7 +462,7 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
339462
<div className="flex items-center gap-2">
340463
<div className="flex min-w-0 flex-1 flex-col gap-1">
341464
<div className="flex items-center gap-2">
342-
<span className="truncate font-semibold">{getSessionDisplayName(session)}</span>
465+
<SessionName session={session} isCurrentSession={session.id === sessionId} isResponding={isResponding} />
343466
{session.hasRunningBackgroundTask && (
344467
<Tooltip>
345468
<TooltipTrigger asChild>
@@ -396,6 +519,16 @@ export const SessionList: React.FC<SessionListProps> = ({ projects = [] }) => {
396519
<Pencil size={16} className="mr-2" />
397520
Rename
398521
</DropdownMenuItem>
522+
<DropdownMenuItem
523+
onClick={e => {
524+
e.stopPropagation();
525+
handleRenameWithAI(session);
526+
}}
527+
disabled={regeneratingTitleForSession === session.id}
528+
>
529+
<Sparkles size={16} className={`mr-2 ${regeneratingTitleForSession === session.id ? "animate-pulse" : ""}`} />
530+
Rename with AI
531+
</DropdownMenuItem>
399532
<DropdownMenuItem
400533
onClick={e => {
401534
e.stopPropagation();

client/webui/frontend/src/lib/components/pages/ChatPage.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { PanelLeftIcon } from "lucide-react";
44
import type { ImperativePanelHandle } from "react-resizable-panels";
55

66
import { Header } from "@/lib/components/header";
7-
import { useChatContext, useTaskContext, useThemeContext } from "@/lib/hooks";
7+
import { useChatContext, useTaskContext, useThemeContext, useTitleAnimation, useConfigContext } from "@/lib/hooks";
88
import { useProjectContext } from "@/lib/providers";
99
import type { TextPart } from "@/lib/types";
1010
import { ChatInputArea, ChatMessage, ChatSessionDialog, ChatSessionDeleteDialog, ChatSidePanel, LoadingMessageRow, ProjectBadge, SessionSidePanel } from "@/lib/components/chat";
@@ -33,8 +33,10 @@ const PANEL_SIZES_OPEN = {
3333
export function ChatPage() {
3434
const { activeProject } = useProjectContext();
3535
const { currentTheme } = useThemeContext();
36+
const { autoTitleGenerationEnabled } = useConfigContext();
3637
const {
3738
agents,
39+
sessionId,
3840
sessionName,
3941
messages,
4042
isSidePanelCollapsed,
@@ -99,11 +101,35 @@ export function ChatPage() {
99101

100102
const breadcrumbs = undefined;
101103

102-
// Determine the page title
103-
const pageTitle = useMemo(() => {
104+
// Determine the page title with pulse/fade effect
105+
const rawPageTitle = useMemo(() => {
104106
return sessionName || "New Chat";
105107
}, [sessionName]);
106108

109+
const { text: pageTitle, isAnimating: isTitleAnimating, isGenerating: isTitleGenerating } = useTitleAnimation(rawPageTitle, sessionId);
110+
111+
const isWaitingForTitle = useMemo(() => {
112+
if (!autoTitleGenerationEnabled) {
113+
return false;
114+
}
115+
const isNewChat = !sessionName || sessionName === "New Chat";
116+
return (isNewChat && isResponding) || isTitleGenerating;
117+
}, [sessionName, isResponding, isTitleGenerating, autoTitleGenerationEnabled]);
118+
119+
// Determine the appropriate animation class
120+
const titleAnimationClass = useMemo(() => {
121+
if (!autoTitleGenerationEnabled) {
122+
return "opacity-100"; // No animation when disabled
123+
}
124+
if (isWaitingForTitle) {
125+
return "animate-pulse-slow";
126+
}
127+
if (isTitleAnimating) {
128+
return "animate-pulse opacity-50";
129+
}
130+
return "opacity-100";
131+
}, [isWaitingForTitle, isTitleAnimating, autoTitleGenerationEnabled]);
132+
107133
useEffect(() => {
108134
if (chatSidePanelRef.current && isSidePanelCollapsed) {
109135
chatSidePanelRef.current.resize(COLLAPSED_SIZE);
@@ -188,7 +214,9 @@ export function ChatPage() {
188214
title={
189215
<div className="flex items-center gap-3">
190216
<Tooltip delayDuration={300}>
191-
<TooltipTrigger className="font-inherit max-w-[400px] cursor-default truncate border-0 bg-transparent p-0 text-left text-inherit hover:bg-transparent">{pageTitle}</TooltipTrigger>
217+
<TooltipTrigger className={`font-inherit max-w-[400px] cursor-default truncate border-0 bg-transparent p-0 text-left text-inherit transition-opacity duration-300 hover:bg-transparent ${titleAnimationClass}`}>
218+
{pageTitle}
219+
</TooltipTrigger>
192220
<TooltipContent side="bottom">
193221
<p>{pageTitle}</p>
194222
</TooltipContent>

0 commit comments

Comments
 (0)