This commit is contained in:
Siddharth Ganesan
2026-02-12 12:18:05 -08:00
parent e7abcd34df
commit 311c4d38f3
3 changed files with 164 additions and 15 deletions

View File

@@ -28,13 +28,24 @@ import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
const logger = createLogger('CopilotChatAPI')
function truncateForLog(value: string, maxLength = 120): string {
if (!value || maxLength <= 0) return ''
return value.length <= maxLength ? value : `${value.slice(0, maxLength)}...`
}
async function requestChatTitleFromCopilot(params: {
message: string
model: string
provider?: string
}): Promise<string | null> {
const { message, model, provider } = params
if (!message || !model) return null
if (!message || !model) {
logger.warn('Skipping chat title request because message/model is missing', {
hasMessage: !!message,
hasModel: !!model,
})
return null
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
@@ -44,6 +55,13 @@ async function requestChatTitleFromCopilot(params: {
}
try {
logger.info('Requesting chat title from copilot backend', {
model,
provider: provider || null,
messageLength: message.length,
messagePreview: truncateForLog(message),
})
const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, {
method: 'POST',
headers,
@@ -63,10 +81,32 @@ async function requestChatTitleFromCopilot(params: {
return null
}
const title = typeof payload?.title === 'string' ? payload.title.trim() : ''
const rawTitle = typeof payload?.title === 'string' ? payload.title : ''
const title = rawTitle.trim()
logger.info('Received chat title response from copilot backend', {
status: response.status,
hasRawTitle: !!rawTitle,
rawTitle,
normalizedTitle: title,
messagePreview: truncateForLog(message),
})
if (!title) {
logger.warn('Copilot backend returned empty chat title', {
payload,
model,
provider: provider || null,
})
}
return title || null
} catch (error) {
logger.error('Error generating chat title:', error)
logger.error('Error generating chat title:', {
error,
model,
provider: provider || null,
messagePreview: truncateForLog(message),
})
return null
}
}
@@ -238,6 +278,7 @@ export async function POST(req: NextRequest) {
let currentChat: any = null
let conversationHistory: any[] = []
let actualChatId = chatId
let chatWasCreatedForRequest = false
const selectedModel = model || 'claude-opus-4-6'
if (chatId || createNewChat) {
@@ -249,6 +290,7 @@ export async function POST(req: NextRequest) {
})
currentChat = chatResult.chat
actualChatId = chatResult.chatId || chatId
chatWasCreatedForRequest = chatResult.isNew
const history = buildConversationHistory(
chatResult.conversationHistory,
(chatResult.chat?.conversationId as string | undefined) || conversationId
@@ -256,6 +298,18 @@ export async function POST(req: NextRequest) {
conversationHistory = history.history
}
const shouldGenerateTitleForRequest =
!!actualChatId &&
chatWasCreatedForRequest &&
!currentChat?.title &&
conversationHistory.length === 0
const titleGenerationParams = {
message,
model: selectedModel,
provider,
}
const effectiveMode = mode === 'agent' ? 'build' : mode
const effectiveConversationId =
(currentChat?.conversationId as string | undefined) || conversationId
@@ -348,10 +402,22 @@ export async function POST(req: NextRequest) {
await pushEvent({ type: 'chat_id', chatId: actualChatId })
}
if (actualChatId && !currentChat?.title && conversationHistory.length === 0) {
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
if (shouldGenerateTitleForRequest) {
logger.info(`[${tracker.requestId}] Starting title generation for streaming response`, {
chatId: actualChatId,
model: titleGenerationParams.model,
provider: provider || null,
messageLength: message.length,
messagePreview: truncateForLog(message),
chatWasCreatedForRequest,
})
requestChatTitleFromCopilot(titleGenerationParams)
.then(async (title) => {
if (title) {
logger.info(`[${tracker.requestId}] Generated title for streaming response`, {
chatId: actualChatId,
title,
})
await db
.update(copilotChats)
.set({
@@ -359,12 +425,30 @@ export async function POST(req: NextRequest) {
updatedAt: new Date(),
})
.where(eq(copilotChats.id, actualChatId!))
await pushEvent({ type: 'title_updated', title })
await pushEvent({ type: 'title_updated', title, chatId: actualChatId })
logger.info(`[${tracker.requestId}] Emitted title_updated SSE event`, {
chatId: actualChatId,
title,
})
} else {
logger.warn(`[${tracker.requestId}] No title returned for streaming response`, {
chatId: actualChatId,
model: selectedModel,
})
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
} else if (actualChatId && !chatWasCreatedForRequest) {
logger.info(
`[${tracker.requestId}] Skipping title generation because chat already exists`,
{
chatId: actualChatId,
model: titleGenerationParams.model,
provider: provider || null,
}
)
}
try {
@@ -479,9 +563,9 @@ export async function POST(req: NextRequest) {
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
// Start title generation in parallel if this is first message (non-streaming)
if (actualChatId && !currentChat.title && conversationHistory.length === 0) {
if (shouldGenerateTitleForRequest) {
logger.info(`[${tracker.requestId}] Starting title generation for non-streaming response`)
requestChatTitleFromCopilot({ message, model: selectedModel, provider })
requestChatTitleFromCopilot(titleGenerationParams)
.then(async (title) => {
if (title) {
await db
@@ -492,11 +576,22 @@ export async function POST(req: NextRequest) {
})
.where(eq(copilotChats.id, actualChatId!))
logger.info(`[${tracker.requestId}] Generated and saved title: ${title}`)
} else {
logger.warn(`[${tracker.requestId}] No title returned for non-streaming response`, {
chatId: actualChatId,
model: selectedModel,
})
}
})
.catch((error) => {
logger.error(`[${tracker.requestId}] Title generation failed:`, error)
})
} else if (actualChatId && !chatWasCreatedForRequest) {
logger.info(`[${tracker.requestId}] Skipping title generation because chat already exists`, {
chatId: actualChatId,
model: titleGenerationParams.model,
provider: provider || null,
})
}
// Update chat in database immediately (without blocking for title)

View File

@@ -151,6 +151,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
planTodos,
})
const renderedChatTitle = currentChat?.title || 'New Chat'
/** Gets markdown content for design document section (available in all modes once created) */
const designDocumentContent = useMemo(() => {
if (streamingPlanContent) {
@@ -163,6 +165,14 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
return ''
}, [streamingPlanContent])
useEffect(() => {
logger.info('[TitleRender] Copilot header title changed', {
currentChatId: currentChat?.id || null,
currentChatTitle: currentChat?.title || null,
renderedTitle: renderedChatTitle,
})
}, [currentChat?.id, currentChat?.title, renderedChatTitle])
/** Focuses the copilot input */
const focusInput = useCallback(() => {
userInputRef.current?.focus()
@@ -345,7 +355,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
{/* Header */}
<div className='mx-[-1px] flex flex-shrink-0 items-center justify-between gap-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] px-[12px] py-[6px]'>
<h2 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{currentChat?.title || 'New Chat'}
{renderedChatTitle}
</h2>
<div className='flex items-center gap-[8px]'>
<Button variant='ghost' className='p-0' onClick={handleStartNewChat}>

View File

@@ -219,15 +219,59 @@ export const sseHandlers: Record<string, SSEHandler> = {
}
},
title_updated: (_data, _context, get, set) => {
const title = _data.title
if (!title) return
const title = typeof _data.title === 'string' ? _data.title.trim() : ''
const eventChatId = typeof _data.chatId === 'string' ? _data.chatId : undefined
const { currentChat, chats } = get()
if (currentChat) {
set({
currentChat: { ...currentChat, title },
chats: chats.map((c) => (c.id === currentChat.id ? { ...c, title } : c)),
logger.info('[Title] Received title_updated SSE event', {
eventTitle: title,
eventChatId: eventChatId || null,
currentChatId: currentChat?.id || null,
currentChatTitle: currentChat?.title || null,
chatCount: chats.length,
})
if (!title) {
logger.warn('[Title] Ignoring title_updated event with empty title', {
payload: _data,
})
return
}
if (!currentChat) {
logger.warn('[Title] Received title_updated event without an active currentChat', {
eventChatId: eventChatId || null,
title,
})
return
}
const targetChatId = eventChatId || currentChat.id
if (eventChatId && eventChatId !== currentChat.id) {
logger.warn('[Title] title_updated event chatId does not match currentChat', {
eventChatId,
currentChatId: currentChat.id,
})
}
set({
currentChat:
currentChat.id === targetChatId
? {
...currentChat,
title,
}
: currentChat,
chats: chats.map((c) => (c.id === targetChatId ? { ...c, title } : c)),
})
const updatedState = get()
logger.info('[Title] Applied title_updated event to copilot store', {
targetChatId,
renderedCurrentChatId: updatedState.currentChat?.id || null,
renderedCurrentChatTitle: updatedState.currentChat?.title || null,
chatListTitle: updatedState.chats.find((c) => c.id === targetChatId)?.title || null,
})
},
tool_result: (data, context, get, set) => {
try {