feat(llm): show "thinking" area in the UI

This commit is contained in:
perf3ct 2025-06-01 03:21:48 +00:00
parent 2c48a70bfb
commit d948ef5ed2
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
3 changed files with 400 additions and 8 deletions

View File

@ -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;
}
}

View File

@ -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 <think>...</think> blocks from the content
return content.replace(/<think>[\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 <think> 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 <think> and </think> tags
const thinkRegex = /<think>([\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 = /<think>([\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);
}
}
}

View File

@ -13,6 +13,27 @@ export const TPL = `
<div class="note-context-chat-container flex-grow-1 overflow-auto p-3">
<div class="note-context-chat-messages"></div>
<!-- Thinking display area -->
<div class="llm-thinking-container" style="display: none;">
<div class="thinking-bubble">
<div class="thinking-header d-flex align-items-center">
<div class="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
<span class="thinking-label ms-2 text-muted small">AI is thinking...</span>
<button type="button" class="btn btn-sm btn-link p-0 ms-auto thinking-toggle" title="Toggle thinking details">
<i class="bx bx-chevron-down"></i>
</button>
</div>
<div class="thinking-content" style="display: none;">
<div class="thinking-text"></div>
</div>
</div>
</div>
<div class="loading-indicator" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>