From 3dff8a8c8e086c300e03683319114611c56c6bf4 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Wed, 17 Jun 2026 11:45:56 +0200 Subject: [PATCH] Add combine saved conversations feature (simple + AI-assisted merge) Lets users multi-select 2+ saved conversations and merge them into one, either by chronological concatenation or by having the default model synthesize a single coherent conversation from the source transcripts. Co-Authored-By: Claude Sonnet 4.6 --- oAI/Services/ConversationMergeService.swift | 178 ++++++++++++++++ .../Screens/CombineConversationsSheet.swift | 192 ++++++++++++++++++ oAI/Views/Screens/ConversationListView.swift | 23 +++ 3 files changed, 393 insertions(+) create mode 100644 oAI/Services/ConversationMergeService.swift create mode 100644 oAI/Views/Screens/CombineConversationsSheet.swift diff --git a/oAI/Services/ConversationMergeService.swift b/oAI/Services/ConversationMergeService.swift new file mode 100644 index 0000000..be26b29 --- /dev/null +++ b/oAI/Services/ConversationMergeService.swift @@ -0,0 +1,178 @@ +// +// ConversationMergeService.swift +// oAI +// +// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge) +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import Foundation +import os + +enum CombineMode: String, Sendable { + case simple + case ai +} + +enum MergeError: LocalizedError { + case tooFewConversations + case noDefaultModel + case noAPIKey + case invalidAIResponse(String) + + var errorDescription: String? { + switch self { + case .tooFewConversations: + return "Select at least two conversations to combine." + case .noDefaultModel: + return "No default model is configured. Set one in Settings → General → Default Model." + case .noAPIKey: + return "No API key configured for the default provider. Add one in Settings." + case .invalidAIResponse(let snippet): + return "The model's response could not be parsed into a conversation: \(snippet)" + } + } +} + +enum ConversationMergeService { + + static func merge( + conversationIds: [UUID], + name: String, + mode: CombineMode, + deleteOriginals: Bool + ) async throws -> Conversation { + guard conversationIds.count >= 2 else { + throw MergeError.tooFewConversations + } + + let sources: [(Conversation, [Message])] = try conversationIds.compactMap { id in + try DatabaseService.shared.loadConversation(id: id) + } + + let mergedMessages: [Message] + switch mode { + case .simple: + mergedMessages = simpleMerge(sources) + case .ai: + mergedMessages = try await aiMerge(sources) + } + + let newConversation = try DatabaseService.shared.saveConversation(name: name, messages: mergedMessages) + + if deleteOriginals { + for id in conversationIds { + _ = try? DatabaseService.shared.deleteConversation(id: id) + } + } + + Log.db.info("Combined \(conversationIds.count) conversations into '\(name)' (mode: \(mode.rawValue), deleteOriginals: \(deleteOriginals))") + + return newConversation + } + + private static func simpleMerge(_ sources: [(Conversation, [Message])]) -> [Message] { + sources.flatMap { $0.1 }.sorted { $0.timestamp < $1.timestamp } + } + + private struct MergedTurn: Codable { + let role: String + let content: String + } + + private static func aiMerge(_ sources: [(Conversation, [Message])]) async throws -> [Message] { + let settings = SettingsService.shared + guard let modelId = settings.defaultModel, !modelId.isEmpty else { + throw MergeError.noDefaultModel + } + guard let provider = ProviderRegistry.shared.getProvider(for: settings.defaultProvider) else { + throw MergeError.noAPIKey + } + + let transcript = sources.map { conversation, messages -> String in + let body = messages.map { msg -> String in + let label = msg.role == .user ? "**User:**" : "**Assistant:**" + return "\(label) \(msg.content)" + }.joined(separator: "\n\n") + return "### Conversation: \(conversation.name)\n\n\(body)" + }.joined(separator: "\n\n---\n\n") + + let mergePrompt = """ + Merge the following saved conversation transcripts into a single, coherent conversation. \ + Remove redundant or duplicate exchanges, keep the most informative answer when sources overlap, \ + preserve important details from each source, and do not invent facts that were not in the originals. + + Respond with ONLY a JSON array of message objects in logical order, each in the form \ + {"role": "user" or "assistant", "content": "..."}. Do not include any text outside the JSON array. + + \(transcript) + """ + + let request = ChatRequest( + messages: [Message(role: .user, content: mergePrompt)], + model: modelId, + stream: false, + maxTokens: 4000, + temperature: 0.3, + topP: nil, + systemPrompt: "You are a helpful assistant that merges chat conversation transcripts into one clean, coherent conversation.", + tools: nil, + onlineMode: false, + imageGeneration: false + ) + + let response: ChatResponse + do { + response = try await provider.chat(request: request) + } catch { + Log.api.error("Conversation merge AI call failed: \(error.localizedDescription)") + throw error + } + + let turns = try parseTurns(from: response.content) + + let base = Date() + return turns.enumerated().map { index, turn in + Message( + role: turn.role == "user" ? .user : .assistant, + content: turn.content, + timestamp: base.addingTimeInterval(TimeInterval(index)), + modelId: modelId + ) + } + } + + private static func parseTurns(from raw: String) throws -> [MergedTurn] { + var text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("```") { + text = text.components(separatedBy: "\n").dropFirst().joined(separator: "\n") + if text.hasSuffix("```") { + text = String(text.dropLast(3)) + } + text = text.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let data = text.data(using: .utf8), + let turns = try? JSONDecoder().decode([MergedTurn].self, from: data), + !turns.isEmpty else { + throw MergeError.invalidAIResponse(String(raw.prefix(200))) + } + return turns + } +} diff --git a/oAI/Views/Screens/CombineConversationsSheet.swift b/oAI/Views/Screens/CombineConversationsSheet.swift new file mode 100644 index 0000000..03a0857 --- /dev/null +++ b/oAI/Views/Screens/CombineConversationsSheet.swift @@ -0,0 +1,192 @@ +// +// CombineConversationsSheet.swift +// oAI +// +// Combine 2+ saved conversations into one, optionally using AI to merge content +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 Rune Olsen +// +// This file is part of oAI. +// +// oAI is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// oAI is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General +// Public License for more details. +// +// You should have received a copy of the GNU Affero General Public +// License along with oAI. If not, see . + + +import SwiftUI + +struct CombineConversationsSheet: View { + @Environment(\.dismiss) var dismiss + + let conversations: [Conversation] + var onCompleted: (Conversation) -> Void + + @State private var name: String + @State private var mode: CombineMode = .simple + @State private var deleteOriginals = false + @State private var isProcessing = false + @State private var errorMessage: String? + + private let settings = SettingsService.shared + + init(conversations: [Conversation], onCompleted: @escaping (Conversation) -> Void) { + self.conversations = conversations + self.onCompleted = onCompleted + let joined = conversations.map(\.name).joined(separator: " + ") + _name = State(initialValue: String(joined.prefix(80))) + } + + private var defaultModelLabel: String? { + guard let model = settings.defaultModel, !model.isEmpty else { return nil } + return "\(settings.defaultProvider.displayName) / \(model)" + } + + private var isValid: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + && conversations.count >= 2 + && (mode == .simple || defaultModelLabel != nil) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Combine Conversations") + .font(.system(size: 18, weight: .bold)) + Spacer() + Button { dismiss() } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2).foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.escape, modifiers: []) + .disabled(isProcessing) + } + .padding(.horizontal, 24).padding(.top, 20).padding(.bottom, 16) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Combining \(conversations.count) conversations").font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 4) { + ForEach(conversations) { conversation in + Label("\(conversation.name) (\(conversation.messageCount) messages)", systemImage: "bubble.left.and.bubble.right") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("New conversation name").font(.system(size: 13, weight: .semibold)) + TextField("Name", text: $name) + .textFieldStyle(.roundedBorder) + .disabled(isProcessing) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Merge method").font(.system(size: 13, weight: .semibold)) + Picker("", selection: $mode) { + Text("Simple Merge").tag(CombineMode.simple) + Text("AI-Assisted Merge").tag(CombineMode.ai) + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(isProcessing) + + if mode == .simple { + Text("Messages from all selected conversations are combined in chronological order.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text("A model reads all the source messages and rewrites them into one coherent, de-duplicated conversation.") + .font(.caption).foregroundStyle(.secondary) + if let label = defaultModelLabel { + Label("Uses your default model: \(label)", systemImage: "cpu") + .font(.caption).foregroundStyle(.secondary) + } else { + Label("No default model configured — set one in Settings → General.", systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + } + } + } + + Toggle("Delete original conversations after combining", isOn: $deleteOriginals) + .toggleStyle(.checkbox) + .disabled(isProcessing) + + if let errorMessage { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.octagon.fill").foregroundStyle(.red) + Text(errorMessage).font(.caption) + } + .padding(10) + .background(.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + } + } + .padding(.horizontal, 24).padding(.vertical, 16) + } + + Divider() + + HStack { + Button("Cancel") { dismiss() } + .buttonStyle(.bordered) + .disabled(isProcessing) + Spacer() + if isProcessing { + ProgressView().controlSize(.small) + Text("Combining…").font(.caption).foregroundStyle(.secondary) + } + Button("Combine") { + combine() + } + .buttonStyle(.borderedProminent) + .disabled(!isValid || isProcessing) + .keyboardShortcut(.return, modifiers: [.command]) + } + .padding(.horizontal, 24).padding(.vertical, 12) + } + .frame(minWidth: 520, idealWidth: 560, minHeight: 460, idealHeight: 520) + } + + private func combine() { + isProcessing = true + errorMessage = nil + let ids = conversations.map(\.id) + let trimmedName = name.trimmingCharacters(in: .whitespaces) + let selectedMode = mode + let shouldDeleteOriginals = deleteOriginals + + Task { + do { + let newConversation = try await ConversationMergeService.merge( + conversationIds: ids, + name: trimmedName, + mode: selectedMode, + deleteOriginals: shouldDeleteOriginals + ) + await MainActor.run { + isProcessing = false + onCompleted(newConversation) + dismiss() + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = error.localizedDescription + } + } + } + } +} diff --git a/oAI/Views/Screens/ConversationListView.swift b/oAI/Views/Screens/ConversationListView.swift index 5ba8fb2..c35707e 100644 --- a/oAI/Views/Screens/ConversationListView.swift +++ b/oAI/Views/Screens/ConversationListView.swift @@ -36,6 +36,7 @@ struct ConversationListView: View { @State private var semanticResults: [Conversation] = [] @State private var isSearching = false @State private var selectedIndex: Int = 0 + @State private var showCombineSheet = false @FocusState private var searchFocused: Bool private let settings = SettingsService.shared var onLoad: ((Conversation) -> Void)? @@ -70,6 +71,18 @@ struct ConversationListView: View { } .buttonStyle(.plain) + if selectedConversations.count >= 2 { + Button { + showCombineSheet = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.merge") + Text("Combine (\(selectedConversations.count))") + } + } + .buttonStyle(.plain) + } + if !selectedConversations.isEmpty { Button(role: .destructive) { deleteSelected() @@ -298,6 +311,16 @@ struct ConversationListView: View { searchFocused = true } .frame(minWidth: 700, idealWidth: 800, minHeight: 500, idealHeight: 600) + .sheet(isPresented: $showCombineSheet) { + CombineConversationsSheet( + conversations: conversations.filter { selectedConversations.contains($0.id) }, + onCompleted: { _ in + loadConversations() + selectedConversations.removeAll() + isSelecting = false + } + ) + } } private func loadConversations() {