mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 18:12:29 +08:00
feat(llm): show "thinking" area in the UI
This commit is contained in:
parent
2c48a70bfb
commit
d948ef5ed2
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user