diff --git a/apps/client/src/stylesheets/llm_chat.css b/apps/client/src/stylesheets/llm_chat.css index aacdf543f..9199d44c7 100644 --- a/apps/client/src/stylesheets/llm_chat.css +++ b/apps/client/src/stylesheets/llm_chat.css @@ -272,4 +272,179 @@ 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-color: var(--accented-background-color, var(--main-background-color)); + border: 1px solid var(--subtle-border-color, var(--main-border-color)); + border-radius: 0.75rem; + padding: 0.75rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + position: relative; + overflow: hidden; + transition: all 0.2s ease; +} + +.thinking-bubble:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.thinking-bubble::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, var(--hover-item-background-color, rgba(0, 0, 0, 0.03)), transparent); + animation: shimmer 2s infinite; + opacity: 0.5; +} + +.thinking-header { + cursor: pointer; + transition: all 0.2s ease; + border-radius: 0.375rem; +} + +.thinking-header:hover { + background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03)); + padding: 0.25rem; + margin: -0.25rem; +} + +.thinking-dots { + display: flex; + gap: 3px; + align-items: center; +} + +.thinking-dots span { + width: 6px; + height: 6px; + background-color: var(--link-color, var(--hover-item-text-color)); + 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: var(--link-color, var(--hover-item-text-color)) !important; +} + +.thinking-toggle { + color: var(--muted-text-color) !important; + transition: transform 0.2s ease; + background: transparent !important; + border: none !important; +} + +.thinking-toggle:hover { + color: var(--main-text-color) !important; +} + +.thinking-toggle.expanded { + transform: rotate(180deg); +} + +.thinking-content { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--subtle-border-color, var(--main-border-color)); + 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: var(--main-text-color); + white-space: pre-wrap; + word-wrap: break-word; + background-color: var(--input-background-color); + padding: 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--subtle-border-color, var(--main-border-color)); + max-height: 300px; + overflow-y: auto; + transition: border-color 0.2s ease; +} + +.thinking-text:hover { + border-color: var(--main-border-color); +} + +/* 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 5c6550346..32ffab50d 100644 --- a/apps/client/src/widgets/llm_chat/llm_chat_panel.ts +++ b/apps/client/src/widgets/llm_chat/llm_chat_panel.ts @@ -5,6 +5,7 @@ import BasicWidget from "../basic_widget.js"; import toastService from "../../services/toast.js"; import appContext from "../../components/app_context.js"; import server from "../../services/server.js"; +import noteAutocompleteService from "../../services/note_autocomplete.js"; import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; import { formatMarkdown } from "./utils.js"; @@ -13,13 +14,16 @@ import { extractInChatToolSteps } from "./message_processor.js"; import { validateEmbeddingProviders } from "./validation.js"; import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; import { formatCodeBlocks } from "../../services/syntax_highlight.js"; +import { ClassicEditor, type CKTextEditor, type MentionFeed } from "@triliumnext/ckeditor5"; +import type { Suggestion } from "../../services/note_autocomplete.js"; import "../../stylesheets/llm_chat.css"; export default class LlmChatPanel extends BasicWidget { private noteContextChatMessages!: HTMLElement; private noteContextChatForm!: HTMLFormElement; - private noteContextChatInput!: HTMLTextAreaElement; + private noteContextChatInput!: HTMLElement; + private noteContextChatInputEditor!: CKTextEditor; private noteContextChatSendButton!: HTMLButtonElement; private chatContainer!: HTMLElement; private loadingIndicator!: HTMLElement; @@ -29,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; @@ -104,7 +112,7 @@ export default class LlmChatPanel extends BasicWidget { const element = this.$widget[0]; this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; - this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLTextAreaElement; + this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLElement; this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement; this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement; @@ -114,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) => { @@ -124,15 +136,84 @@ export default class LlmChatPanel extends BasicWidget { } }); - this.initializeEventListeners(); + // Set up thinking toggle functionality + this.setupThinkingToggle(); + + // Initialize CKEditor with mention support (async) + this.initializeCKEditor().then(() => { + this.initializeEventListeners(); + }).catch(error => { + console.error('Failed to initialize CKEditor, falling back to basic event listeners:', error); + this.initializeBasicEventListeners(); + }); return this.$widget; } + private async initializeCKEditor() { + const mentionSetup: MentionFeed[] = [ + { + marker: "@", + feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), + itemRenderer: (item) => { + const suggestion = item as Suggestion; + const itemElement = document.createElement("button"); + itemElement.innerHTML = `${suggestion.highlightedNotePathTitle} `; + return itemElement; + }, + minimumCharacters: 0 + } + ]; + + this.noteContextChatInputEditor = await ClassicEditor.create(this.noteContextChatInput, { + toolbar: { + items: [] // No toolbar for chat input + }, + placeholder: this.noteContextChatInput.getAttribute('data-placeholder') || 'Enter your message...', + mention: { + feeds: mentionSetup + }, + licenseKey: "GPL" + }); + + // Set minimal height + const editorElement = this.noteContextChatInputEditor.ui.getEditableElement(); + if (editorElement) { + editorElement.style.minHeight = '60px'; + editorElement.style.maxHeight = '200px'; + editorElement.style.overflowY = 'auto'; + } + + // Set up keybindings after editor is ready + this.setupEditorKeyBindings(); + + console.log('CKEditor initialized successfully for LLM chat input'); + } + + private initializeBasicEventListeners() { + // Fallback event listeners for when CKEditor fails to initialize + this.noteContextChatForm.addEventListener('submit', (e) => { + e.preventDefault(); + // In fallback mode, the noteContextChatInput should contain a textarea + const textarea = this.noteContextChatInput.querySelector('textarea'); + if (textarea) { + const content = textarea.value; + this.sendMessage(content); + } + }); + } + cleanup() { console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); this._messageHandler = null; this._messageHandlerId = null; + + // Clean up CKEditor instance + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.destroy().catch(error => { + console.error('Error destroying CKEditor:', error); + }); + } } /** @@ -531,18 +612,31 @@ export default class LlmChatPanel extends BasicWidget { private async sendMessage(content: string) { if (!content.trim()) return; + // Extract mentions from the content if using CKEditor + let mentions: Array<{noteId: string; title: string; notePath: string}> = []; + let plainTextContent = content; + + if (this.noteContextChatInputEditor) { + const extracted = this.extractMentionsAndContent(content); + mentions = extracted.mentions; + plainTextContent = extracted.content; + } + // Add the user message to the UI and data model - this.addMessageToChat('user', content); + this.addMessageToChat('user', plainTextContent); this.messages.push({ role: 'user', - content: content + content: plainTextContent, + mentions: mentions.length > 0 ? mentions : undefined }); // Save the data immediately after a user message await this.saveCurrentData(); // Clear input and show loading state - this.noteContextChatInput.value = ''; + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.setData(''); + } showLoadingIndicator(this.loadingIndicator); this.hideSources(); @@ -555,9 +649,10 @@ export default class LlmChatPanel extends BasicWidget { // Create the message parameters const messageParams = { - content, + content: plainTextContent, useAdvancedContext, - showThinking + showThinking, + mentions: mentions.length > 0 ? mentions : undefined }; // Try websocket streaming (preferred method) @@ -621,7 +716,9 @@ export default class LlmChatPanel extends BasicWidget { } // Clear input and show loading state - this.noteContextChatInput.value = ''; + if (this.noteContextChatInputEditor) { + this.noteContextChatInputEditor.setData(''); + } showLoadingIndicator(this.loadingIndicator); this.hideSources(); @@ -898,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'); @@ -919,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'); @@ -934,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 }); } @@ -957,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 */ @@ -1203,32 +1326,308 @@ 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(); - const content = this.noteContextChatInput.value; - this.sendMessage(content); - }); - // Add auto-resize functionality to the textarea - this.noteContextChatInput.addEventListener('input', () => { - this.noteContextChatInput.style.height = 'auto'; - this.noteContextChatInput.style.height = `${this.noteContextChatInput.scrollHeight}px`; - }); + let content = ''; - // Handle Enter key (send on Enter, new line on Shift+Enter) - this.noteContextChatInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - this.noteContextChatForm.dispatchEvent(new Event('submit')); + if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.getData) { + // Use CKEditor content + content = this.noteContextChatInputEditor.getData(); + } else { + // Fallback: check if there's a textarea (fallback mode) + const textarea = this.noteContextChatInput.querySelector('textarea'); + if (textarea) { + content = textarea.value; + } else { + // Last resort: try to get text content from the div + content = this.noteContextChatInput.textContent || this.noteContextChatInput.innerText || ''; + } + } + + if (content.trim()) { + this.sendMessage(content); } }); + + // Handle Enter key (send on Enter, new line on Shift+Enter) via CKEditor + // We'll set this up after CKEditor is initialized + this.setupEditorKeyBindings(); + } + + private setupEditorKeyBindings() { + if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.keystrokes) { + try { + this.noteContextChatInputEditor.keystrokes.set('Enter', (key, stop) => { + if (!key.shiftKey) { + stop(); + this.noteContextChatForm.dispatchEvent(new Event('submit')); + } + }); + console.log('CKEditor keybindings set up successfully'); + } catch (error) { + console.warn('Failed to set up CKEditor keybindings:', error); + } + } + } + + /** + * Extract note mentions and content from CKEditor + */ + private extractMentionsAndContent(editorData: string): { content: string; mentions: Array<{noteId: string; title: string; notePath: string}> } { + const mentions: Array<{noteId: string; title: string; notePath: string}> = []; + + // Parse the HTML content to extract mentions + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = editorData; + + // Find all mention elements - CKEditor uses specific patterns for mentions + // Look for elements with data-mention attribute or specific mention classes + const mentionElements = tempDiv.querySelectorAll('[data-mention], .mention, span[data-id]'); + + mentionElements.forEach(mentionEl => { + try { + // Try different ways to extract mention data based on CKEditor's format + let mentionData: any = null; + + // Method 1: data-mention attribute (JSON format) + if (mentionEl.hasAttribute('data-mention')) { + mentionData = JSON.parse(mentionEl.getAttribute('data-mention') || '{}'); + } + // Method 2: data-id attribute (simple format) + else if (mentionEl.hasAttribute('data-id')) { + const dataId = mentionEl.getAttribute('data-id'); + const textContent = mentionEl.textContent || ''; + + // Parse the dataId to extract note information + if (dataId && dataId.startsWith('@')) { + const cleanId = dataId.substring(1); // Remove the @ + mentionData = { + id: cleanId, + name: textContent, + notePath: cleanId // Assume the ID contains the path + }; + } + } + // Method 3: Check if this is a reference link (href=#notePath) + else if (mentionEl.tagName === 'A' && mentionEl.hasAttribute('href')) { + const href = mentionEl.getAttribute('href'); + if (href && href.startsWith('#')) { + const notePath = href.substring(1); + mentionData = { + notePath: notePath, + noteTitle: mentionEl.textContent || 'Unknown Note' + }; + } + } + + if (mentionData && (mentionData.notePath || mentionData.link)) { + const notePath = mentionData.notePath || mentionData.link?.substring(1); // Remove # from link + const noteId = notePath ? notePath.split('/').pop() : null; + const title = mentionData.noteTitle || mentionData.name || mentionEl.textContent || 'Unknown Note'; + + if (noteId) { + mentions.push({ + noteId: noteId, + title: title, + notePath: notePath + }); + console.log(`Extracted mention: noteId=${noteId}, title=${title}, notePath=${notePath}`); + } + } + } catch (e) { + console.warn('Failed to parse mention data:', e, mentionEl); + } + }); + + // Convert to plain text for the LLM, but preserve the structure + const content = tempDiv.textContent || tempDiv.innerText || ''; + + 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/types.ts b/apps/client/src/widgets/llm_chat/types.ts index dc19f38d3..300a7856a 100644 --- a/apps/client/src/widgets/llm_chat/types.ts +++ b/apps/client/src/widgets/llm_chat/types.ts @@ -24,6 +24,11 @@ export interface MessageData { role: string; content: string; timestamp?: Date; + mentions?: Array<{ + noteId: string; + title: string; + notePath: string; + }>; } export interface ChatData { diff --git a/apps/client/src/widgets/llm_chat/ui.ts b/apps/client/src/widgets/llm_chat/ui.ts index b4c9c9208..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 = `
+ + + +