From d948ef5ed2811e5b35fde7f40be2f67614fbd82e Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 1 Jun 2025 03:21:48 +0000 Subject: [PATCH] feat(llm): show "thinking" area in the UI --- apps/client/src/stylesheets/llm_chat.css | 158 ++++++++++++ .../src/widgets/llm_chat/llm_chat_panel.ts | 229 +++++++++++++++++- apps/client/src/widgets/llm_chat/ui.ts | 21 ++ 3 files changed, 400 insertions(+), 8 deletions(-) diff --git a/apps/client/src/stylesheets/llm_chat.css b/apps/client/src/stylesheets/llm_chat.css index aacdf543f..1b4e0c49f 100644 --- a/apps/client/src/stylesheets/llm_chat.css +++ b/apps/client/src/stylesheets/llm_chat.css @@ -272,4 +272,162 @@ justify-content: center; padding: 1rem; color: var(--muted-text-color); +} + +/* Thinking display styles */ +.llm-thinking-container { + margin: 1rem 0; + animation: fadeInUp 0.3s ease-out; +} + +.thinking-bubble { + background: linear-gradient(135deg, #f8f9ff 0%, #e3e7ff 100%); + border: 1px solid #d1d9ff; + border-radius: 12px; + padding: 0.75rem; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.1); + position: relative; + overflow: hidden; +} + +.thinking-bubble::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent); + animation: shimmer 2s infinite; +} + +.thinking-header { + cursor: pointer; + transition: all 0.2s ease; +} + +.thinking-header:hover { + background: rgba(99, 102, 241, 0.05); + border-radius: 8px; + padding: 0.25rem; + margin: -0.25rem; +} + +.thinking-dots { + display: flex; + gap: 3px; + align-items: center; +} + +.thinking-dots span { + width: 6px; + height: 6px; + background: #6366f1; + border-radius: 50%; + animation: thinkingPulse 1.4s infinite ease-in-out; +} + +.thinking-dots span:nth-child(1) { + animation-delay: -0.32s; +} + +.thinking-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +.thinking-dots span:nth-child(3) { + animation-delay: 0s; +} + +.thinking-label { + font-weight: 500; + color: #6366f1 !important; +} + +.thinking-toggle { + color: #6366f1 !important; + transition: transform 0.2s ease; +} + +.thinking-toggle.expanded { + transform: rotate(180deg); +} + +.thinking-content { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid #e5e7eb; + animation: expandDown 0.3s ease-out; +} + +.thinking-text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.5; + color: #4b5563; + white-space: pre-wrap; + word-wrap: break-word; + background: rgba(255, 255, 255, 0.7); + padding: 0.75rem; + border-radius: 8px; + border: 1px solid rgba(99, 102, 241, 0.1); + max-height: 300px; + overflow-y: auto; +} + +/* Animations */ +@keyframes thinkingPulse { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.6; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes shimmer { + 0% { + left: -100%; + } + 100% { + left: 100%; + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes expandDown { + from { + opacity: 0; + max-height: 0; + } + to { + opacity: 1; + max-height: 300px; + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .thinking-bubble { + margin: 0.5rem 0; + padding: 0.5rem; + } + + .thinking-text { + font-size: 0.8rem; + padding: 0.5rem; + max-height: 200px; + } } \ No newline at end of file diff --git a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts index 53de9a2d7..32ffab50d 100644 --- a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts +++ b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts @@ -33,6 +33,10 @@ export default class LlmChatPanel extends BasicWidget { private useAdvancedContextCheckbox!: HTMLInputElement; private showThinkingCheckbox!: HTMLInputElement; private validationWarning!: HTMLElement; + private thinkingContainer!: HTMLElement; + private thinkingBubble!: HTMLElement; + private thinkingText!: HTMLElement; + private thinkingToggle!: HTMLElement; private chatNoteId: string | null = null; private noteId: string | null = null; // The actual noteId for the Chat Note private currentNoteId: string | null = null; @@ -118,6 +122,10 @@ export default class LlmChatPanel extends BasicWidget { this.useAdvancedContextCheckbox = element.querySelector('.use-advanced-context-checkbox') as HTMLInputElement; this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement; this.validationWarning = element.querySelector('.provider-validation-warning') as HTMLElement; + this.thinkingContainer = element.querySelector('.llm-thinking-container') as HTMLElement; + this.thinkingBubble = element.querySelector('.thinking-bubble') as HTMLElement; + this.thinkingText = element.querySelector('.thinking-text') as HTMLElement; + this.thinkingToggle = element.querySelector('.thinking-toggle') as HTMLElement; // Set up event delegation for the settings link this.validationWarning.addEventListener('click', (e) => { @@ -128,6 +136,9 @@ export default class LlmChatPanel extends BasicWidget { } }); + // Set up thinking toggle functionality + this.setupThinkingToggle(); + // Initialize CKEditor with mention support (async) this.initializeCKEditor().then(() => { this.initializeEventListeners(); @@ -984,6 +995,16 @@ export default class LlmChatPanel extends BasicWidget { * Update the UI with streaming content */ private updateStreamingUI(assistantResponse: string, isDone: boolean = false) { + // Parse and handle thinking content if present + if (!isDone) { + const thinkingContent = this.parseThinkingContent(assistantResponse); + if (thinkingContent) { + this.updateThinkingText(thinkingContent); + // Don't display the raw response with think tags in the chat + return; + } + } + // Get the existing assistant message or create a new one let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child'); @@ -1005,14 +1026,20 @@ export default class LlmChatPanel extends BasicWidget { assistantMessageEl.appendChild(messageContent); } + // Clean the response to remove thinking tags before displaying + const cleanedResponse = this.removeThinkingTags(assistantResponse); + // Update the content const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement; - messageContent.innerHTML = formatMarkdown(assistantResponse); + messageContent.innerHTML = formatMarkdown(cleanedResponse); // Apply syntax highlighting if this is the final update if (isDone) { formatCodeBlocks($(assistantMessageEl as HTMLElement)); + // Hide the thinking display when response is complete + this.hideThinkingDisplay(); + // Update message in the data model for storage // Find the last assistant message to update, or add a new one if none exists const assistantMessages = this.messages.filter(msg => msg.role === 'assistant'); @@ -1020,13 +1047,13 @@ export default class LlmChatPanel extends BasicWidget { this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1; if (lastAssistantMsgIndex >= 0) { - // Update existing message - this.messages[lastAssistantMsgIndex].content = assistantResponse; + // Update existing message with cleaned content + this.messages[lastAssistantMsgIndex].content = cleanedResponse; } else { - // Add new message + // Add new message with cleaned content this.messages.push({ role: 'assistant', - content: assistantResponse + content: cleanedResponse }); } @@ -1043,6 +1070,16 @@ export default class LlmChatPanel extends BasicWidget { this.chatContainer.scrollTop = this.chatContainer.scrollHeight; } + /** + * Remove thinking tags from response content + */ + private removeThinkingTags(content: string): string { + if (!content) return content; + + // Remove ... blocks from the content + return content.replace(/[\s\S]*?<\/think>/gi, '').trim(); + } + /** * Handle general errors in the send message flow */ @@ -1289,13 +1326,61 @@ export default class LlmChatPanel extends BasicWidget { * Show thinking state in the UI */ private showThinkingState(thinkingData: string) { - // Thinking state is now updated via the in-chat UI in updateStreamingUI - // This method is now just a hook for the WebSocket handlers + // Parse the thinking content to extract text between tags + const thinkingContent = this.parseThinkingContent(thinkingData); - // Show the loading indicator + if (thinkingContent) { + this.showThinkingDisplay(thinkingContent); + } else { + // Fallback: show raw thinking data + this.showThinkingDisplay(thinkingData); + } + + // Show the loading indicator as well this.loadingIndicator.style.display = 'flex'; } + /** + * Parse thinking content from LLM response + */ + private parseThinkingContent(content: string): string | null { + if (!content) return null; + + // Look for content between and tags + const thinkRegex = /([\s\S]*?)<\/think>/gi; + const matches: string[] = []; + let match: RegExpExecArray | null; + + while ((match = thinkRegex.exec(content)) !== null) { + matches.push(match[1].trim()); + } + + if (matches.length > 0) { + return matches.join('\n\n--- Next thought ---\n\n'); + } + + // Check for incomplete thinking blocks (streaming in progress) + const incompleteThinkRegex = /([\s\S]*?)$/i; + const incompleteMatch = content.match(incompleteThinkRegex); + + if (incompleteMatch && incompleteMatch[1]) { + return incompleteMatch[1].trim() + '\n\n[Thinking in progress...]'; + } + + // If no think tags found, check if the entire content might be thinking + if (content.toLowerCase().includes('thinking') || + content.toLowerCase().includes('reasoning') || + content.toLowerCase().includes('let me think') || + content.toLowerCase().includes('i need to') || + content.toLowerCase().includes('first, ') || + content.toLowerCase().includes('step 1') || + content.toLowerCase().includes('analysis:')) { + return content; + } + + return null; + } + private initializeEventListeners() { this.noteContextChatForm.addEventListener('submit', (e) => { e.preventDefault(); @@ -1417,4 +1502,132 @@ export default class LlmChatPanel extends BasicWidget { console.log(`Extracted ${mentions.length} mentions from editor content`); return { content, mentions }; } + + private setupThinkingToggle() { + if (this.thinkingToggle) { + this.thinkingToggle.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleThinkingDetails(); + }); + } + + // Also make the entire header clickable + const thinkingHeader = this.thinkingBubble?.querySelector('.thinking-header'); + if (thinkingHeader) { + thinkingHeader.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.closest('.thinking-toggle')) { + this.toggleThinkingDetails(); + } + }); + } + } + + private toggleThinkingDetails() { + const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; + const toggle = this.thinkingToggle?.querySelector('i'); + + if (content && toggle) { + const isVisible = content.style.display !== 'none'; + + if (isVisible) { + content.style.display = 'none'; + toggle.className = 'bx bx-chevron-down'; + this.thinkingToggle.classList.remove('expanded'); + } else { + content.style.display = 'block'; + toggle.className = 'bx bx-chevron-up'; + this.thinkingToggle.classList.add('expanded'); + } + } + } + + /** + * Show the thinking display with optional initial content + */ + private showThinkingDisplay(initialText: string = '') { + if (this.thinkingContainer) { + this.thinkingContainer.style.display = 'block'; + + if (initialText && this.thinkingText) { + this.updateThinkingText(initialText); + } + + // Scroll to show the thinking display + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + } + } + + /** + * Update the thinking text content + */ + private updateThinkingText(text: string) { + if (this.thinkingText) { + // Format the thinking text for better readability + const formattedText = this.formatThinkingText(text); + this.thinkingText.textContent = formattedText; + + // Auto-scroll if content is expanded + const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; + if (content && content.style.display !== 'none') { + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + } + } + } + + /** + * Format thinking text for better presentation + */ + private formatThinkingText(text: string): string { + if (!text) return text; + + // Clean up the text + let formatted = text.trim(); + + // Add some basic formatting + formatted = formatted + // Add spacing around section markers + .replace(/(\d+\.\s)/g, '\n$1') + // Clean up excessive whitespace + .replace(/\n\s*\n\s*\n/g, '\n\n') + // Trim again + .trim(); + + return formatted; + } + + /** + * Hide the thinking display + */ + private hideThinkingDisplay() { + if (this.thinkingContainer) { + this.thinkingContainer.style.display = 'none'; + + // Reset the toggle state + const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; + const toggle = this.thinkingToggle?.querySelector('i'); + + if (content && toggle) { + content.style.display = 'none'; + toggle.className = 'bx bx-chevron-down'; + this.thinkingToggle?.classList.remove('expanded'); + } + + // Clear the text content + if (this.thinkingText) { + this.thinkingText.textContent = ''; + } + } + } + + /** + * Append to existing thinking content (for streaming updates) + */ + private appendThinkingText(additionalText: string) { + if (this.thinkingText && additionalText) { + const currentText = this.thinkingText.textContent || ''; + const newText = currentText + additionalText; + this.updateThinkingText(newText); + } + } } diff --git a/apps/client/src/widgets/llm_chat/ui.ts b/apps/client/src/widgets/llm_chat/ui.ts index 15e427cb8..a17167840 100644 --- a/apps/client/src/widgets/llm_chat/ui.ts +++ b/apps/client/src/widgets/llm_chat/ui.ts @@ -13,6 +13,27 @@ export const TPL = `
+ + + +