diff --git a/blueprints/chat/routes.py b/blueprints/chat/routes.py index 8ceb9c8..a7171ed 100644 --- a/blueprints/chat/routes.py +++ b/blueprints/chat/routes.py @@ -279,14 +279,20 @@ def chat_list_conversations(): try: conversations = db.query(AIChatConversation).filter_by( user_id=current_user.id - ).order_by(AIChatConversation.updated_at.desc()).limit(50).all() + ).order_by( + AIChatConversation.is_pinned.desc().nullslast(), + AIChatConversation.updated_at.desc() + ).limit(50).all() return jsonify({ 'success': True, 'conversations': [ { 'id': c.id, - 'title': c.title, + 'title': c.custom_name or c.title, + 'original_title': c.title, + 'custom_name': c.custom_name, + 'is_pinned': c.is_pinned or False, 'created_at': c.started_at.isoformat() if c.started_at else None, 'updated_at': c.updated_at.isoformat() if c.updated_at else None, 'message_count': len(c.messages) if c.messages else 0 @@ -301,6 +307,70 @@ def chat_list_conversations(): db.close() +@bp.route('/api/chat//rename', methods=['PATCH']) +@login_required +@member_required +def chat_rename_conversation(conversation_id): + """Rename a conversation""" + db = SessionLocal() + try: + conversation = db.query(AIChatConversation).filter_by( + id=conversation_id, + user_id=current_user.id + ).first() + + if not conversation: + return jsonify({'success': False, 'error': 'Conversation not found'}), 404 + + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify({'success': False, 'error': 'Nazwa nie może być pusta'}), 400 + + if len(name) > 255: + name = name[:255] + + conversation.custom_name = name + db.commit() + + return jsonify({'success': True, 'name': name}) + except Exception as e: + logger.error(f"Error renaming conversation: {e}") + db.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + db.close() + + +@bp.route('/api/chat//pin', methods=['PATCH']) +@login_required +@member_required +def chat_pin_conversation(conversation_id): + """Pin or unpin a conversation""" + db = SessionLocal() + try: + conversation = db.query(AIChatConversation).filter_by( + id=conversation_id, + user_id=current_user.id + ).first() + + if not conversation: + return jsonify({'success': False, 'error': 'Conversation not found'}), 404 + + conversation.is_pinned = not conversation.is_pinned + conversation.pinned_at = datetime.now() if conversation.is_pinned else None + db.commit() + + return jsonify({'success': True, 'is_pinned': conversation.is_pinned}) + except Exception as e: + logger.error(f"Error pinning conversation: {e}") + db.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + db.close() + + @bp.route('/api/chat//delete', methods=['DELETE']) @login_required @member_required diff --git a/database.py b/database.py index 538ed01..d37a1b8 100644 --- a/database.py +++ b/database.py @@ -1518,6 +1518,11 @@ class AIChatConversation(Base): message_count = Column(Integer, default=0) model_name = Column(String(100)) + # Pin & custom name + is_pinned = Column(Boolean, default=False) + pinned_at = Column(DateTime) + custom_name = Column(String(255)) + # Relationships user = relationship('User', back_populates='conversations') messages = relationship('AIChatMessage', back_populates='conversation', cascade='all, delete-orphan', order_by='AIChatMessage.created_at') diff --git a/database/migrations/084_chat_pin_rename.sql b/database/migrations/084_chat_pin_rename.sql new file mode 100644 index 0000000..bc5af43 --- /dev/null +++ b/database/migrations/084_chat_pin_rename.sql @@ -0,0 +1,12 @@ +-- Migration 084: Add pinning and custom naming to chat conversations +-- Date: 2026-03-16 + +ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT FALSE; +ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP; +ALTER TABLE ai_chat_conversations ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255); + +-- Index for efficient pinned conversations query +CREATE INDEX IF NOT EXISTS idx_chat_conversations_pinned ON ai_chat_conversations(user_id, is_pinned, updated_at DESC); + +-- Grants +GRANT ALL ON TABLE ai_chat_conversations TO nordabiz_app; diff --git a/templates/chat.html b/templates/chat.html index 862b936..2562e65 100755 --- a/templates/chat.html +++ b/templates/chat.html @@ -145,8 +145,19 @@ text-overflow: ellipsis; } - .conversation-delete { + .conversation-actions { + display: flex; + gap: 2px; opacity: 0; + flex-shrink: 0; + transition: var(--transition); + } + + .conversation-item:hover .conversation-actions { + opacity: 1; + } + + .conversation-action-btn { background: none; border: none; color: #9ca3af; @@ -154,15 +165,77 @@ padding: 4px; border-radius: var(--radius-sm); transition: var(--transition); + display: flex; + align-items: center; } - .conversation-item:hover .conversation-delete { + .conversation-action-btn:hover { + color: #6b7280; + background: rgba(0, 0, 0, 0.05); + } + + .conversation-action-btn.pin-btn:hover { + color: #f59e0b; + background: rgba(245, 158, 11, 0.1); + } + + .conversation-action-btn.delete-btn:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); + } + + .conversation-pin-icon { + color: #f59e0b; + flex-shrink: 0; + width: 12px; + height: 12px; + margin-right: 2px; + } + + .conversations-section-title { + font-size: 11px; + font-weight: 600; + color: #9ca3af; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: var(--spacing-sm) var(--spacing-md) 4px; + } + + /* Rename inline input */ + .conversation-rename-input { + flex: 1; + font-size: var(--font-size-sm); + border: 1px solid #d1d5db; + border-radius: var(--radius-sm); + padding: 2px 6px; + outline: none; + background: white; + min-width: 0; + } + + .conversation-rename-input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15); + } + + /* Keep actions visible for pinned items */ + .conversation-item.pinned .conversation-actions .pin-btn { + opacity: 1; + color: #f59e0b; + } + + .conversation-item.pinned .conversation-actions { opacity: 1; } - .conversation-delete:hover { - color: #ef4444; - background: rgba(239, 68, 68, 0.1); + .conversation-item.pinned .conversation-actions .delete-btn, + .conversation-item.pinned .conversation-actions .rename-btn { + opacity: 0; + } + + .conversation-item.pinned:hover .conversation-actions .delete-btn, + .conversation-item.pinned:hover .conversation-actions .rename-btn { + opacity: 1; } .sidebar-empty { @@ -1735,22 +1808,145 @@ function renderConversationsList() { return; } - list.innerHTML = conversations.map(conv => ` -
c.is_pinned); + const unpinned = conversations.filter(c => !c.is_pinned); + + let html = ''; + + if (pinned.length > 0) { + html += '
Przypięte
'; + html += pinned.map(conv => renderConversationItem(conv, true)).join(''); + } + + if (pinned.length > 0 && unpinned.length > 0) { + html += '
Historia
'; + } + + html += unpinned.map(conv => renderConversationItem(conv, false)).join(''); + + list.innerHTML = html; +} + +function renderConversationItem(conv, isPinned) { + return ` +
- - - + ${isPinned ? '' : ''} ${escapeHtml(conv.title)} - +
+ + + +
- `).join(''); + `; +} + +// Toggle pin/unpin conversation +async function togglePin(conversationId) { + try { + const response = await fetch(`/api/chat/${conversationId}/pin`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken } + }); + const data = await response.json(); + + if (data.success) { + // Update local state + const conv = conversations.find(c => c.id === conversationId); + if (conv) conv.is_pinned = data.is_pinned; + // Re-sort: pinned first, then by updated_at + conversations.sort((a, b) => { + if (a.is_pinned !== b.is_pinned) return b.is_pinned ? 1 : -1; + return new Date(b.updated_at) - new Date(a.updated_at); + }); + renderConversationsList(); + } + } catch (error) { + console.error('Error toggling pin:', error); + } +} + +// Start inline rename +function startRename(conversationId) { + const item = document.querySelector(`.conversation-item[data-id="${conversationId}"]`); + if (!item) return; + + const titleSpan = item.querySelector('.conversation-title'); + const currentTitle = titleSpan.textContent; + + // Replace title with input + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'conversation-rename-input'; + input.value = currentTitle; + input.maxLength = 255; + + titleSpan.replaceWith(input); + input.focus(); + input.select(); + + // Prevent click from loading conversation + const originalOnclick = item.onclick; + item.onclick = null; + + function finishRename(save) { + const newName = input.value.trim(); + + if (save && newName && newName !== currentTitle) { + saveRename(conversationId, newName); + } + + // Restore title span + const span = document.createElement('span'); + span.className = 'conversation-title'; + span.textContent = save && newName ? newName : currentTitle; + input.replaceWith(span); + item.onclick = originalOnclick; + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finishRename(true); } + if (e.key === 'Escape') { finishRename(false); } + }); + + input.addEventListener('blur', () => finishRename(true)); +} + +// Save renamed conversation +async function saveRename(conversationId, name) { + try { + const response = await fetch(`/api/chat/${conversationId}/rename`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, + body: JSON.stringify({ name }) + }); + const data = await response.json(); + + if (data.success) { + // Update local state + const conv = conversations.find(c => c.id === conversationId); + if (conv) { + conv.custom_name = data.name; + conv.title = data.name; + } + } + } catch (error) { + console.error('Error renaming conversation:', error); + } } // Load a specific conversation