mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-30 03:32:26 +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;
|
justify-content: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: var(--muted-text-color);
|
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 useAdvancedContextCheckbox!: HTMLInputElement;
|
||||||
private showThinkingCheckbox!: HTMLInputElement;
|
private showThinkingCheckbox!: HTMLInputElement;
|
||||||
private validationWarning!: HTMLElement;
|
private validationWarning!: HTMLElement;
|
||||||
|
private thinkingContainer!: HTMLElement;
|
||||||
|
private thinkingBubble!: HTMLElement;
|
||||||
|
private thinkingText!: HTMLElement;
|
||||||
|
private thinkingToggle!: HTMLElement;
|
||||||
private chatNoteId: string | null = null;
|
private chatNoteId: string | null = null;
|
||||||
private noteId: string | null = null; // The actual noteId for the Chat Note
|
private noteId: string | null = null; // The actual noteId for the Chat Note
|
||||||
private currentNoteId: string | null = null;
|
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.useAdvancedContextCheckbox = element.querySelector('.use-advanced-context-checkbox') as HTMLInputElement;
|
||||||
this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement;
|
this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement;
|
||||||
this.validationWarning = element.querySelector('.provider-validation-warning') as HTMLElement;
|
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
|
// Set up event delegation for the settings link
|
||||||
this.validationWarning.addEventListener('click', (e) => {
|
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)
|
// Initialize CKEditor with mention support (async)
|
||||||
this.initializeCKEditor().then(() => {
|
this.initializeCKEditor().then(() => {
|
||||||
this.initializeEventListeners();
|
this.initializeEventListeners();
|
||||||
@ -984,6 +995,16 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
* Update the UI with streaming content
|
* Update the UI with streaming content
|
||||||
*/
|
*/
|
||||||
private updateStreamingUI(assistantResponse: string, isDone: boolean = false) {
|
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
|
// Get the existing assistant message or create a new one
|
||||||
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
|
let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child');
|
||||||
|
|
||||||
@ -1005,14 +1026,20 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
assistantMessageEl.appendChild(messageContent);
|
assistantMessageEl.appendChild(messageContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean the response to remove thinking tags before displaying
|
||||||
|
const cleanedResponse = this.removeThinkingTags(assistantResponse);
|
||||||
|
|
||||||
// Update the content
|
// Update the content
|
||||||
const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement;
|
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
|
// Apply syntax highlighting if this is the final update
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
formatCodeBlocks($(assistantMessageEl as HTMLElement));
|
||||||
|
|
||||||
|
// Hide the thinking display when response is complete
|
||||||
|
this.hideThinkingDisplay();
|
||||||
|
|
||||||
// Update message in the data model for storage
|
// Update message in the data model for storage
|
||||||
// Find the last assistant message to update, or add a new one if none exists
|
// Find the last assistant message to update, or add a new one if none exists
|
||||||
const assistantMessages = this.messages.filter(msg => msg.role === 'assistant');
|
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;
|
this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1;
|
||||||
|
|
||||||
if (lastAssistantMsgIndex >= 0) {
|
if (lastAssistantMsgIndex >= 0) {
|
||||||
// Update existing message
|
// Update existing message with cleaned content
|
||||||
this.messages[lastAssistantMsgIndex].content = assistantResponse;
|
this.messages[lastAssistantMsgIndex].content = cleanedResponse;
|
||||||
} else {
|
} else {
|
||||||
// Add new message
|
// Add new message with cleaned content
|
||||||
this.messages.push({
|
this.messages.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: assistantResponse
|
content: cleanedResponse
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1043,6 +1070,16 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
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
|
* Handle general errors in the send message flow
|
||||||
*/
|
*/
|
||||||
@ -1289,13 +1326,61 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
* Show thinking state in the UI
|
* Show thinking state in the UI
|
||||||
*/
|
*/
|
||||||
private showThinkingState(thinkingData: string) {
|
private showThinkingState(thinkingData: string) {
|
||||||
// Thinking state is now updated via the in-chat UI in updateStreamingUI
|
// Parse the thinking content to extract text between <think> tags
|
||||||
// This method is now just a hook for the WebSocket handlers
|
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';
|
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() {
|
private initializeEventListeners() {
|
||||||
this.noteContextChatForm.addEventListener('submit', (e) => {
|
this.noteContextChatForm.addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -1417,4 +1502,132 @@ export default class LlmChatPanel extends BasicWidget {
|
|||||||
console.log(`Extracted ${mentions.length} mentions from editor content`);
|
console.log(`Extracted ${mentions.length} mentions from editor content`);
|
||||||
return { content, mentions };
|
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-container flex-grow-1 overflow-auto p-3">
|
||||||
<div class="note-context-chat-messages"></div>
|
<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="loading-indicator" style="display: none;">
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user