diff --git a/app/lib/backend/http/api/conversations.dart b/app/lib/backend/http/api/conversations.dart index 55ea0dc052..c8116ceb29 100644 --- a/app/lib/backend/http/api/conversations.dart +++ b/app/lib/backend/http/api/conversations.dart @@ -451,3 +451,25 @@ Future updateActionItemStateByMetadata( ) async { return await setConversationActionItemState(conversationId, [itemIndex], [newState]); } + +Future mergeConversations(List conversationIds) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v1/conversations/merge', + headers: {}, + method: 'POST', + body: jsonEncode({ + 'conversation_ids': conversationIds, + }), + ); + + if (response == null) return null; + debugPrint('mergeConversations: ${response.statusCode}'); + + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + return ServerConversation.fromJson(jsonDecode(body)); + } else { + debugPrint('mergeConversations error: ${response.body}'); + return null; + } +} diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index b7b0f9ed1f..c4a47df52a 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -137,8 +137,10 @@ class _ConversationsPageState extends State with AutomaticKee debugPrint('building conversations page'); super.build(context); return Consumer(builder: (context, convoProvider, child) { - return RefreshIndicator( - onRefresh: () async { + return Stack( + children: [ + RefreshIndicator( + onRefresh: () async { HapticFeedback.mediumImpact(); Provider.of(context, listen: false).refreshInProgressConversations(); await convoProvider.getInitialConversations(); @@ -222,6 +224,95 @@ class _ConversationsPageState extends State with AutomaticKee ), ], ), + ), + // Merge toolbar + if (convoProvider.isSelectionMode) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Colors.black87, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: Row( + children: [ + // Cancel button + TextButton( + onPressed: () { + convoProvider.disableSelectionMode(); + }, + child: const Text( + 'Cancel', + style: TextStyle(color: Colors.white, fontSize: 16), + ), + ), + const Spacer(), + // Selection count + Text( + '${convoProvider.selectedConversationIds.length} selected', + style: const TextStyle(color: Colors.white70, fontSize: 14), + ), + const SizedBox(width: 16), + // Merge button + ElevatedButton( + onPressed: convoProvider.canMergeSelectedConversations() && !convoProvider.isMerging + ? () async { + HapticFeedback.mediumImpact(); + bool success = await convoProvider.mergeSelectedConversations(); + if (success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Conversations merged successfully'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } else if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Failed to merge conversations. They must be adjacent (within 1 hour of each other).'), + backgroundColor: Colors.red, + duration: Duration(seconds: 4), + ), + ); + } + } + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: convoProvider.isMerging + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Merge'), + ), + ], + ), + ), + ), + ), + ], ); }); } diff --git a/app/lib/pages/conversations/widgets/conversation_list_item.dart b/app/lib/pages/conversations/widgets/conversation_list_item.dart index 2d50510997..3d55d3a2ac 100644 --- a/app/lib/pages/conversations/widgets/conversation_list_item.dart +++ b/app/lib/pages/conversations/widgets/conversation_list_item.dart @@ -66,8 +66,18 @@ class _ConversationListItemState extends State { Structured structured = widget.conversation.structured; return Consumer(builder: (context, provider, child) { + bool isSelected = provider.isConversationSelected(widget.conversation.id); + return GestureDetector( onTap: () async { + // If in selection mode, toggle selection instead of opening detail + if (provider.isSelectionMode) { + // Don't allow selecting locked or discarded conversations + if (!widget.conversation.isLocked && !widget.conversation.discarded) { + provider.toggleConversationSelection(widget.conversation.id); + } + return; + } if (widget.conversation.isLocked) { MixpanelManager().paywallOpened('Conversation List Item'); routeToPage(context, const UsagePage(showUpgradeDialog: true)); @@ -111,10 +121,26 @@ class _ConversationListItemState extends State { } } }, + onLongPress: () { + // Don't allow selecting locked or discarded conversations + if (widget.conversation.isLocked || widget.conversation.discarded) { + return; + } + // Enable selection mode and select this conversation + HapticFeedback.mediumImpact(); + provider.enableSelectionMode(); + provider.toggleConversationSelection(widget.conversation.id); + }, child: Padding( padding: EdgeInsets.only(top: 12, left: widget.isFromOnboarding ? 0 : 16, right: widget.isFromOnboarding ? 0 : 16), - child: Container( + child: Opacity( + opacity: (provider.isSelectionMode && (widget.conversation.isLocked || widget.conversation.discarded)) + ? 0.5 + : 1.0, + child: Stack( + children: [ + Container( width: double.maxFinite, decoration: BoxDecoration( color: const Color(0xFF1F1F25), @@ -124,7 +150,7 @@ class _ConversationListItemState extends State { borderRadius: BorderRadius.circular(16.0), child: Dismissible( key: UniqueKey(), - direction: DismissDirection.endToStart, + direction: provider.isSelectionMode ? DismissDirection.none : DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20.0), @@ -177,6 +203,43 @@ class _ConversationListItemState extends State { ), ), ), + ), + // Checkbox overlay for selection mode + if (provider.isSelectionMode) + Positioned( + top: 8, + right: 8, + child: Container( + decoration: BoxDecoration( + color: isSelected ? Colors.blue : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? Colors.blue : Colors.grey, + width: 2, + ), + ), + child: Icon( + Icons.check, + color: isSelected ? Colors.white : Colors.transparent, + size: 20, + ), + ), + ), + // Selection mode highlight + if (provider.isSelectionMode && isSelected) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + border: Border.all( + color: Colors.blue, + width: 2, + ), + ), + ), + ), + ], + ), ), ), ); diff --git a/app/lib/providers/conversation_provider.dart b/app/lib/providers/conversation_provider.dart index 8d57190829..07f3fd8c07 100644 --- a/app/lib/providers/conversation_provider.dart +++ b/app/lib/providers/conversation_provider.dart @@ -19,6 +19,11 @@ class ConversationProvider extends ChangeNotifier { bool showDiscardedConversations = false; DateTime? selectedDate; + // Selection mode for merging conversations + bool isSelectionMode = false; + Set selectedConversationIds = {}; + bool isMerging = false; + String previousQuery = ''; int totalSearchPages = 1; int currentSearchPage = 1; @@ -71,6 +76,11 @@ class ConversationProvider extends ChangeNotifier { return; } + // Exit selection mode when searching + if (isSelectionMode) { + disableSelectionMode(); + } + if (showShimmer) { setLoadingConversations(true); } else { @@ -148,6 +158,11 @@ class ConversationProvider extends ChangeNotifier { void toggleDiscardConversations() { showDiscardedConversations = !showDiscardedConversations; + // Exit selection mode when switching views + if (isSelectionMode) { + disableSelectionMode(); + } + // Clear grouped conversations to show shimmer effect while loading groupedConversations = {}; notifyListeners(); @@ -665,4 +680,129 @@ class ConversationProvider extends ChangeNotifier { updateConversationInSortedList(conversation); notifyListeners(); } + + // Selection mode methods for conversation merging + void enableSelectionMode() { + isSelectionMode = true; + selectedConversationIds.clear(); + notifyListeners(); + } + + void disableSelectionMode() { + isSelectionMode = false; + selectedConversationIds.clear(); + notifyListeners(); + } + + void toggleConversationSelection(String conversationId) { + if (selectedConversationIds.contains(conversationId)) { + selectedConversationIds.remove(conversationId); + } else { + selectedConversationIds.add(conversationId); + } + notifyListeners(); + } + + bool isConversationSelected(String conversationId) { + return selectedConversationIds.contains(conversationId); + } + + bool canMergeSelectedConversations() { + if (selectedConversationIds.length < 2) return false; + + // Get selected conversations in chronological order + List selectedConvos = conversations + .where((c) => selectedConversationIds.contains(c.id)) + .toList(); + + // Check if any are locked or discarded + if (selectedConvos.any((c) => c.isLocked || c.discarded)) { + return false; + } + + selectedConvos.sort((a, b) => (a.startedAt ?? a.createdAt).compareTo(b.startedAt ?? b.createdAt)); + + // Check if they are adjacent in the conversation list + for (int i = 0; i < selectedConvos.length - 1; i++) { + DateTime currentEnd = selectedConvos[i].finishedAt ?? selectedConvos[i].createdAt; + DateTime nextStart = selectedConvos[i + 1].startedAt ?? selectedConvos[i + 1].createdAt; + + // Find the index of these conversations in the full list + int currentIndex = conversations.indexOf(selectedConvos[i]); + int nextIndex = conversations.indexOf(selectedConvos[i + 1]); + + // Check if they are adjacent (allowing for some conversations in between, but within 1 hour) + Duration gap = nextStart.difference(currentEnd); + if (gap.inHours > 1) { + return false; + } + } + + return true; + } + + Future mergeSelectedConversations() async { + if (!canMergeSelectedConversations()) return false; + + isMerging = true; + notifyListeners(); + + try { + // Sort conversation IDs by timestamp + List selectedConvos = conversations + .where((c) => selectedConversationIds.contains(c.id)) + .toList(); + selectedConvos.sort((a, b) => (a.startedAt ?? a.createdAt).compareTo(b.startedAt ?? b.createdAt)); + List sortedIds = selectedConvos.map((c) => c.id).toList(); + + // Track merge attempt + MixpanelManager().track('Conversations Merge Initiated', properties: { + 'conversation_count': sortedIds.length, + 'conversation_ids': sortedIds, + }); + + // Call merge API + ServerConversation? mergedConversation = await mergeConversations(sortedIds); + + if (mergedConversation != null) { + // Remove merged conversations from local list + conversations.removeWhere((c) => selectedConversationIds.contains(c.id)); + + // Add merged conversation + conversations.insert(0, mergedConversation); + conversations.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + // Update grouped conversations + _groupConversationsByDateWithoutNotify(); + + // Track success + MixpanelManager().track('Conversations Merge Successful', properties: { + 'merged_conversation_id': mergedConversation.id, + 'source_conversation_count': selectedConversationIds.length, + }); + + // Clear selection + disableSelectionMode(); + + isMerging = false; + notifyListeners(); + return true; + } else { + MixpanelManager().track('Conversations Merge Failed', properties: { + 'error': 'Null response from API', + }); + isMerging = false; + notifyListeners(); + return false; + } + } catch (e) { + debugPrint('Error merging conversations: $e'); + MixpanelManager().track('Conversations Merge Failed', properties: { + 'error': e.toString(), + }); + isMerging = false; + notifyListeners(); + return false; + } + } } diff --git a/backend/database/conversations.py b/backend/database/conversations.py index 88c73c644e..5e66c06749 100644 --- a/backend/database/conversations.py +++ b/backend/database/conversations.py @@ -867,3 +867,166 @@ def get_last_completed_conversation(uid: str) -> Optional[dict]: conversations = [doc.to_dict() for doc in query.stream()] conversation = conversations[0] if conversations else None return conversation + + +# ******************************** +# ********** MERGING ************* +# ******************************** + + +def merge_conversations(uid: str, conversation_ids: List[str]) -> Optional[dict]: + """ + Merge multiple conversations into a single conversation. + + Args: + uid: User ID + conversation_ids: List of conversation IDs to merge (must be adjacent by time) + + Returns: + The merged conversation dict + """ + if len(conversation_ids) < 2: + raise ValueError("At least 2 conversations are required to merge") + + # Fetch all conversations using the existing get_conversation function + conversations = [] + for conv_id in conversation_ids: + conv_data = get_conversation(uid, conv_id) + if not conv_data: + raise ValueError(f"Conversation {conv_id} not found") + + # Check if conversation is discarded or locked + if conv_data.get('discarded', False): + raise ValueError(f"Cannot merge discarded conversation {conv_id}") + if conv_data.get('is_locked', False): + raise ValueError(f"Cannot merge locked conversation {conv_id}") + + conversations.append(conv_data) + + # Sort by started_at or created_at + conversations.sort(key=lambda c: c.get('started_at') or c.get('created_at')) + + # Validate adjacency (no gaps > 1 hour between conversations) + for i in range(len(conversations) - 1): + current_end = conversations[i].get('finished_at') or conversations[i].get('created_at') + next_start = conversations[i + 1].get('started_at') or conversations[i + 1].get('created_at') + + if current_end and next_start: + current_end = _ensure_timezone_aware(current_end) + next_start = _ensure_timezone_aware(next_start) + gap = (next_start - current_end).total_seconds() + # Allow up to 1 hour gap between adjacent conversations + if gap > 3600: + raise ValueError(f"Conversations are not adjacent (gap: {gap / 60:.1f} minutes)") + + # Create merged conversation + first_conv = conversations[0] + last_conv = conversations[-1] + + merged_id = str(uuid.uuid4()) + merged_data = { + 'id': merged_id, + 'created_at': first_conv.get('created_at'), + 'started_at': first_conv.get('started_at') or first_conv.get('created_at'), + 'finished_at': last_conv.get('finished_at') or last_conv.get('created_at'), + 'source': first_conv.get('source'), + 'language': first_conv.get('language'), + 'structured': { + 'title': '', # Will be generated + 'overview': '', # Will be generated + 'emoji': '🔗', # Merged conversation emoji + 'category': first_conv.get('structured', {}).get('category', 'other'), + 'action_items': [], + 'events': [], + }, + 'transcript_segments': [], + 'transcript_segments_compressed': False, + 'geolocation': first_conv.get('geolocation'), + 'photos': [], + 'audio_files': [], + 'apps_results': [], + 'suggested_summarization_apps': [], + 'plugins_results': [], + 'external_data': None, + 'app_id': None, + 'discarded': False, + 'visibility': 'private', + 'processing_conversation_id': None, + 'processing_memory_id': None, + 'status': ConversationStatus.completed, + 'is_locked': False, + 'data_protection_level': first_conv.get('data_protection_level', 'standard'), + 'private_cloud_sync_enabled': first_conv.get('private_cloud_sync_enabled', False), + } + + # Merge transcript segments + all_segments = [] + for conv in conversations: + segments = conv.get('transcript_segments', []) + all_segments.extend(segments) + # Sort segments by start time + all_segments.sort(key=lambda s: s.get('start', 0)) + merged_data['transcript_segments'] = all_segments + + # Merge photos + all_photos = [] + for conv in conversations: + photos = conv.get('photos', []) + all_photos.extend(photos) + # Sort photos by created_at + all_photos.sort(key=lambda p: p.get('created_at') if isinstance(p.get('created_at'), datetime) else datetime.now(timezone.utc)) + merged_data['photos'] = all_photos + + # Merge action items + all_action_items = [] + for conv in conversations: + items = conv.get('structured', {}).get('action_items', []) + for item in items: + # Add conversation_id reference if not present + if not item.get('conversation_id'): + item['conversation_id'] = conv['id'] + all_action_items.extend(items) + merged_data['structured']['action_items'] = all_action_items + + # Merge events + all_events = [] + for conv in conversations: + events = conv.get('structured', {}).get('events', []) + all_events.extend(events) + merged_data['structured']['events'] = all_events + + # Merge audio files + all_audio_files = [] + for conv in conversations: + audio_files = conv.get('audio_files', []) + # Update conversation_id in audio files + for audio_file in audio_files: + audio_file['conversation_id'] = merged_id + all_audio_files.extend(audio_files) + merged_data['audio_files'] = all_audio_files + + # Create the merged conversation in database + upsert_conversation(uid, merged_data) + + # Write photos to subcollection + if all_photos: + user_ref = db.collection('users').document(uid) + conversation_ref = user_ref.collection(conversations_collection).document(merged_id) + photos_ref = conversation_ref.collection('photos') + level = merged_data.get('data_protection_level', 'standard') + batch = db.batch() + for photo in all_photos: + photo_id = photo.get('id') or str(uuid.uuid4()) + photo_ref = photos_ref.document(photo_id) + photo_data = dict(photo) + photo_data['id'] = photo_id + prepared_photo = _prepare_photo_for_write(photo_data, uid, level) + batch.set(photo_ref, prepared_photo) + batch.commit() + + # Delete the source conversations + for conv_id in conversation_ids: + delete_conversation(uid, conv_id) + + # Return the merged conversation + return get_conversation(uid, merged_id) diff --git a/backend/models/conversation.py b/backend/models/conversation.py index b287e7df8a..ff61052d1c 100644 --- a/backend/models/conversation.py +++ b/backend/models/conversation.py @@ -507,3 +507,7 @@ class SearchRequest(BaseModel): class TestPromptRequest(BaseModel): prompt: str + + +class MergeConversationsRequest(BaseModel): + conversation_ids: List[str] = Field(description="List of conversation IDs to merge (must be adjacent by time)") diff --git a/backend/routers/conversations.py b/backend/routers/conversations.py index 8fbc70c473..1ef8bd66f1 100644 --- a/backend/routers/conversations.py +++ b/backend/routers/conversations.py @@ -604,3 +604,60 @@ def test_prompt(conversation_id: str, request: TestPromptRequest, uid: str = Dep summary = generate_summary_with_prompt(full_transcript, request.prompt) return {"summary": summary} + + +@router.post("/v1/conversations/merge", response_model=Conversation, tags=['conversations']) +def merge_conversations_endpoint(request: MergeConversationsRequest, uid: str = Depends(auth.get_current_user_uid)): + """ + Merge multiple adjacent conversations into a single conversation. + + This endpoint: + 1. Validates that all conversations exist and belong to the user + 2. Validates that conversations are adjacent (no gaps > 1 hour) + 3. Combines transcript segments, photos, action items, and events + 4. Generates a new title and overview for the merged conversation + 5. Deletes the source conversations + 6. Returns the new merged conversation + """ + print('merge_conversations', uid, request.conversation_ids) + + if len(request.conversation_ids) < 2: + raise HTTPException(status_code=400, detail="At least 2 conversations are required to merge") + + # Validate all conversations exist and belong to user + for conv_id in request.conversation_ids: + _get_valid_conversation_by_id(uid, conv_id) + + try: + # Merge conversations + merged_conversation_data = conversations_db.merge_conversations(uid, request.conversation_ids) + + if not merged_conversation_data: + raise HTTPException(status_code=500, detail="Failed to merge conversations") + + merged_conversation = Conversation(**merged_conversation_data) + + # Generate title and overview for merged conversation + full_transcript = "\n".join([seg.text for seg in merged_conversation.transcript_segments if seg.text]) + + if full_transcript: + # Reprocess the merged conversation to generate structured data + processed_conversation = process_conversation( + uid, merged_conversation.language or 'en', merged_conversation, force_process=True + ) + return processed_conversation + else: + # No transcript, return as-is with basic title + merged_conversation.structured.title = f"Merged Conversation" + merged_conversation.structured.overview = f"Combined {len(request.conversation_ids)} conversations" + conversations_db.update_conversation(uid, merged_conversation.id, { + 'structured.title': merged_conversation.structured.title, + 'structured.overview': merged_conversation.structured.overview, + }) + return merged_conversation + + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + print(f"Error merging conversations: {e}") + raise HTTPException(status_code=500, detail="Failed to merge conversations")