From 307279b849a23574645ccaa0effaad74e2689b31 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Wed, 17 Sep 2025 02:22:13 +0300 Subject: [PATCH] change view of input+button --- .gitignore | 3 + src/components/ChatPanel.ts | 181 ++++++++++++++++++++++++++++---- src/css/styles.css | 204 +++++++++++++++++++++++++++++++++--- src/views/ChatView.ts | 32 +++++- 4 files changed, 383 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 386ac2b..60a35da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store + +styles.css +main.js \ No newline at end of file diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index ea80a10..8534260 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -9,23 +9,31 @@ export class ChatPanel { container: HTMLElement; props: { chatHistory: ChatHistoryItem[]; - onSendMessage: (message: string) => void; + onSendMessage: (message: string, selectedModel?: string) => void; + availableModels?: string[]; + selectedModel?: string; }; chatHistory: ChatHistoryItem[]; - messageInput: HTMLInputElement; + messageInput: HTMLDivElement; sendButton: HTMLButtonElement; suggestionsContainer: HTMLDivElement; + modelSelectButton: HTMLButtonElement; + modelDropdown: HTMLDivElement; + selectedModel: string; constructor( container: HTMLElement, props: { chatHistory: ChatHistoryItem[]; - onSendMessage: (message: string) => void; + onSendMessage: (message: string, selectedModel?: string) => void; + availableModels?: string[]; + selectedModel?: string; } ) { this.container = container; this.props = props; this.chatHistory = props.chatHistory || []; + this.selectedModel = props.selectedModel || 'gemini-2.5-flash'; this.init(); } @@ -34,33 +42,60 @@ export class ChatPanel { this.container.innerHTML = `
-
-
- - +
+
+
- +
+
+
+ + +
+
+
+ +
+
+
`; - this.messageInput = this.container.querySelector('#message-input') as HTMLInputElement; + this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement; this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement; this.suggestionsContainer = this.container.querySelector('#suggestions') as HTMLDivElement; + this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement; + this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement; this.setupEventListeners(); this.renderHistory(); + this.updateModelDropdown(); + this.updatePlaceholder(); } setupEventListeners(): void { this.sendButton.addEventListener('click', () => this.handleSend()); this.messageInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !e.shiftKey) { if (this.suggestionsContainer.style.display === 'block') { const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item'); if (firstSuggestion) { - this.messageInput.value = firstSuggestion.textContent || ''; + this.messageInput.textContent = firstSuggestion.textContent || ''; this.hideSuggestions(); e.preventDefault(); return; @@ -71,12 +106,116 @@ export class ChatPanel { } }); - this.messageInput.addEventListener('input', (e) => { - this.handleInput((e.target as HTMLInputElement).value); + this.messageInput.addEventListener('input', () => { + this.handleInput(); + this.updatePlaceholder(); + }); + + this.messageInput.addEventListener('focus', () => { + this.updatePlaceholder(); + }); + + this.messageInput.addEventListener('blur', () => { + this.updatePlaceholder(); + }); + + // Model selection + this.modelSelectButton.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleModelDropdown(); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { + this.modelDropdown.style.display = 'none'; + } + if (!this.suggestionsContainer.contains(e.target as Node)) { + this.hideSuggestions(); + } }); } - handleInput(value: string): void { + updatePlaceholder(): void { + const isEmpty = this.messageInput.textContent?.trim() === ''; + const isFocused = document.activeElement === this.messageInput; + + if (isEmpty && !isFocused) { + this.messageInput.classList.add('is-empty'); + } else { + this.messageInput.classList.remove('is-empty'); + } + } + + toggleModelDropdown(): void { + const isVisible = this.modelDropdown.style.display === 'block'; + if (isVisible) { + this.modelDropdown.style.display = 'none'; + return; + } + + // Показываем дропдаун, чтобы получить его размеры и положение + this.modelDropdown.style.display = 'block'; + + const modelSelectButtonRect = this.modelSelectButton.getBoundingClientRect(); + const modelDropdownRect = this.modelDropdown.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + + // ----------------------------------------------- Секция логики позиционирования дропдауна ---------------------------------------------------- + // Определяем, достаточно ли места снизу + const spaceBelow = viewportHeight - modelSelectButtonRect.bottom; + // Определяем, достаточно ли места сверху + const spaceAbove = modelSelectButtonRect.top; + + if (spaceBelow >= modelDropdownRect.height || spaceBelow >= spaceAbove) { + // Открываем вниз, если есть достаточно места или места снизу больше + this.modelDropdown.style.top = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки + this.modelDropdown.style.bottom = 'auto'; + } else { + // Открываем вверх + this.modelDropdown.style.bottom = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки + this.modelDropdown.style.top = 'auto'; + } + this.modelDropdown.style.left = '0'; + this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно + // ----------------------------------------------- End of Секция логики позиционирования дропдауна --------------------------------------------- + } + + updateModelDropdown(): void { + if (!this.props.availableModels) return; + + this.modelDropdown.innerHTML = this.props.availableModels + .map( + (model) => ` +
+ ${model} +
+ ` + ) + .join(''); + + // Add event listeners to model options + this.modelDropdown.querySelectorAll('.model-option').forEach((option) => { + option.addEventListener('click', () => { + const model = (option as HTMLElement).dataset.model; + if (model) { + this.setSelectedModel(model); + } + }); + }); + } + + setSelectedModel(model: string): void { + this.selectedModel = model; + const modelNameElement = this.container.querySelector('#selected-model-name'); + if (modelNameElement) { + modelNameElement.textContent = model; + } + this.modelDropdown.style.display = 'none'; + } + + handleInput(): void { + const value = this.messageInput.textContent || ''; const commands = ['/help', '/imagine', '/analyze', '/subtitles_meet', '/subtitles_teams', '/summarize', '/chat']; if (value.startsWith('/')) { @@ -100,7 +239,7 @@ export class ChatPanel { // Добавляем обработчики кликов на предложения this.suggestionsContainer.querySelectorAll('.suggestion-item').forEach((item) => { item.addEventListener('click', () => { - this.messageInput.value = item.textContent || ''; + this.messageInput.textContent = item.textContent || ''; this.hideSuggestions(); this.messageInput.focus(); }); @@ -112,10 +251,11 @@ export class ChatPanel { } handleSend(): void { - const message = this.messageInput.value.trim(); + const message = this.messageInput.textContent?.trim() || ''; if (message && this.props.onSendMessage) { - this.props.onSendMessage(message); - this.messageInput.value = ''; + this.props.onSendMessage(message, this.selectedModel); + this.messageInput.textContent = ''; + this.updatePlaceholder(); this.hideSuggestions(); } } @@ -181,6 +321,11 @@ export class ChatPanel { this.renderHistory(); } + updateAvailableModels(models: string[]): void { + this.props.availableModels = models; + this.updateModelDropdown(); + } + destroy(): void { this.container.innerHTML = ''; } diff --git a/src/css/styles.css b/src/css/styles.css index 18024e0..ae4b24c 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -194,38 +194,210 @@ scroll-margin-bottom: 30px; /* Добавляет отступ в 20px снизу при прокрутке к этому элементу */ } -#send-button { - padding: 8px 15px; - background-color: #007bff; - color: white; - border: none; - border-radius: 4px; +.submit-button { + padding: 4px 8px; /* Восстанавливаем паддинг */ + border: none; /* Убираем рамку */ + border-radius: 8px; cursor: pointer; + font-size: 11px; + transition: all 0.2s; + /* Явно переопределяем стили, которые могли быть затронуты */ + width: auto; /* Убедимся, что ширина не ограничена */ + height: auto; /* Убедимся, что высота не ограничена */ + background-color: #0078d4 !important; /* Или ваш основной цвет кнопки */ + color: white !important; /* Цвет текста на кнопке */ + opacity: 1; /* Если .clickable-icon устанавливает opacity */ + display: inline-flex; /* Или flex, если нужно выравнивание элементов внутри */ + align-items: center; /* Для вертикального выравнивания контента, если display: flex */ + justify-content: center; /* Для горизонтального выравнивания контента, если display: flex */ } #send-button:hover { - background-color: #0056b3; + background-color: #0056b3 !important; } + +/* Добавляем стили для нового интерфейса ввода */ + +.chat-input-container { + border-top: 1px solid var(--background-modifier-border); + padding: 8px 10px 4px 10px; + position: relative; +} + +.scroll-container { + overflow-y: auto; + scrollbar-width: thin; + max-height: 70vh; + margin-bottom: 8px; +} + +.message-input-editor { + outline: none; + font-size: 14px; + line-height: 1.4; + min-height: 20px; + padding: 8px 0; + color: var(--text-normal); + position: relative; +} + +.message-input-editor.is-empty::before { + content: attr(data-placeholder); + color: var(--text-muted); + position: absolute; + pointer-events: none; +} + +.chat-controls { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--text-muted); +} + +.control-buttons-left { + display: flex; + align-items: center; + gap: 8px; +} + +.control-buttons-right { + display: flex; + align-items: center; + gap: 8px; +} + +.model-select-container { + position: relative; +} + +.model-select-button { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 8px; + background-color: var(--background-modifier-form-field); + color: var(--text-muted); + cursor: pointer; + font-size: 11px; + transition: all 0.2s; +} + +.model-select-button:hover { + background-color: var(--background-modifier-form-field-highlighted); + filter: brightness(1.1); +} + +.model-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-icon { + width: 12px; + height: 12px; + flex-shrink: 0; +} + +.model-dropdown { + position: absolute; + /* Убираем жесткое bottom: 100%; т.к. позиционирование будет задаваться в JS */ + /* bottom: 100%; */ + left: 0; + /* right: 0; */ /* Это может привести к растягиванию, лучше оставить auto или задать ширину */ + width: max-content; /* Чтобы дропдаун не растягивался на всю ширину родителя */ + min-width: 150px; /* Минимальная ширина */ + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + /* margin-bottom: 4px; */ /* Это будет задавать JS через top/bottom */ + max-height: 200px; + overflow-y: auto; +} + +.model-option { + padding: 8px 12px; + cursor: pointer; + font-size: 11px; + color: var(--text-normal); + transition: background-color 0.2s; +} + +.model-option:hover { + +} + +.model-option:first-child { + border-radius: 8px 8px 0 0; +} + +.model-option:last-child { + border-radius: 0 0 8px 8px; +} + +.submit-button { + padding: 4px 8px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; +} + +.submit-button:hover:enabled { + filter: brightness(1.25); +} + +.submit-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.submit-text { + font-family: system-ui, -apple-system, sans-serif; +} + +/* Обновляем стили для suggestions */ .suggestions { position: absolute; - bottom: 100%; /* Помещаем над полем ввода */ + bottom: 100%; left: 0; right: 0; - background-color: white; - border: 1px solid #ccc; - border-radius: 4px; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; max-height: 150px; overflow-y: auto; - z-index: 10; - margin-bottom: 5px; /* Отступ от поля ввода */ + z-index: 100; + margin-bottom: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .suggestion-item { - padding: 8px 10px; + padding: 8px 12px; cursor: pointer; + font-size: 12px; + color: var(--text-normal); + transition: background-color 0.2s; } .suggestion-item:hover { - background-color: #f0f0f0; -} \ No newline at end of file +} + +/* Удаляем старые стили для chat-input-form и message-input */ +.chat-input-form { + display: none; +} + +#message-input { + display: none; +} + diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts index 6e94607..8689a1f 100644 --- a/src/views/ChatView.ts +++ b/src/views/ChatView.ts @@ -18,6 +18,7 @@ export class ChatView extends ItemView { currentNode: string | null; chatContainer: HTMLDivElement; chatPanel: ChatPanel | null; + availableModels: string[]; constructor(leaf: WorkspaceLeaf, plugin: LLMAgentPlugin) { super(leaf); @@ -26,6 +27,7 @@ export class ChatView extends ItemView { this.selectedGraphId = null; this.currentNode = null; this.chatPanel = null; + this.availableModels = []; } getViewType(): string { @@ -48,10 +50,15 @@ export class ChatView extends ItemView { this.chatContainer = container.createDiv() as HTMLDivElement; this.chatContainer.addClass('llm-agent-chat-container'); + // Загружаем доступные модели + await this.fetchAvailableModels(); + // Инициализируем только чат панель this.chatPanel = new ChatPanel(this.chatContainer, { chatHistory: this.chatHistory, - onSendMessage: this.handleSendMessage.bind(this) + onSendMessage: this.handleSendMessage.bind(this), + availableModels: this.availableModels, + selectedModel: this.plugin.settings.defaultModel || 'gemini-2.5-flash' }); // Подписываемся на события @@ -113,9 +120,27 @@ export class ChatView extends ItemView { } } + /** + * Загружает список доступных моделей с бэкенда. + * @returns {Promise} + */ + async fetchAvailableModels(): Promise { + try { + const response = await fetch(`${this.plugin.settings.apiBaseUrl}/models`); + if (!response.ok) { + throw new Error(`Ошибка загрузки моделей: ${response.status}`); + } + this.availableModels = await response.json(); + } catch (error) { + console.error('Ошибка загрузки доступных моделей:', error); + // Fallback к базовым моделям + this.availableModels = ['gemini-2.5-flash', 'gpt-4o-mini', 'claude-3-5-sonnet-20241022']; + } + } + // ----------------------------------------------- Event Handlers --------------------------------------------------------------- - async handleSendMessage(message: string): Promise { + async handleSendMessage(message: string, selectedModel?: string): Promise { try { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat`, { method: 'POST', @@ -126,7 +151,8 @@ export class ChatView extends ItemView { message: message, graph_id: this.selectedGraphId, parent_node_id: this.currentNode, - system_prompt: this.plugin.settings.systemPrompt + system_prompt: this.plugin.settings.systemPrompt, + model: selectedModel || 'gemini-2.5-flash' }) });