Files
oai-swift/oAI/Views/Screens/CombineConversationsSheet.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

191 lines
7.6 KiB
Swift

//
// CombineConversationsSheet.swift
// oAI
//
// Combine 2+ saved conversations into one, optionally using AI to merge content
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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
}
}
}
}
}