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 = `