The merge model picker was reusing chatViewModel.availableModels, which only ever holds whichever provider the main chat window currently has active — fine for the main chat's own switcher (where provider and model change together via the header), wrong for an independent one-off picker like this. If your active chat was on Anthropic, that's all you could pick from here regardless of what other providers you have configured. Added its own provider menu (mirroring HeaderView's) and an independent model list fetched via ProviderRegistry for whichever provider is selected, so OpenRouter, Anthropic, OpenAI, etc. are all genuinely selectable regardless of what the main chat is doing.
273 lines
11 KiB
Swift
273 lines
11 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?
|
|
@State private var mergeModel: ModelInfo?
|
|
@State private var mergeProvider: Settings.Provider
|
|
@State private var mergeModels: [ModelInfo] = []
|
|
@State private var isLoadingMergeModels = false
|
|
@State private var showModelPicker = false
|
|
|
|
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)))
|
|
_mergeProvider = State(initialValue: SettingsService.shared.defaultProvider)
|
|
}
|
|
|
|
private var mergeModelLabel: String? {
|
|
guard let mergeModel else { return nil }
|
|
return "\(mergeProvider.displayName) / \(mergeModel.name)"
|
|
}
|
|
|
|
private var isValid: Bool {
|
|
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
|
&& conversations.count >= 2
|
|
&& (mode == .simple || mergeModelLabel != 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)
|
|
HStack(spacing: 8) {
|
|
if let label = mergeModelLabel {
|
|
Label(label, systemImage: "cpu")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
} else {
|
|
Label("No model selected", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.caption).foregroundStyle(.orange)
|
|
}
|
|
|
|
Menu {
|
|
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { p in
|
|
Button {
|
|
switchMergeProvider(to: p)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: p.iconName)
|
|
Text(p.displayName)
|
|
if p == mergeProvider { Image(systemName: "checkmark") }
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(mergeProvider.displayName)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
.font(.caption)
|
|
.disabled(isProcessing || isLoadingMergeModels)
|
|
|
|
Button("Change Model…") {
|
|
showModelPicker = true
|
|
}
|
|
.buttonStyle(.link)
|
|
.font(.caption)
|
|
.disabled(isProcessing || isLoadingMergeModels || mergeModels.isEmpty)
|
|
|
|
if isLoadingMergeModels {
|
|
ProgressView().controlSize(.mini)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
.task {
|
|
await loadMergeModels()
|
|
if let defaultModel = settings.defaultModel {
|
|
mergeModel = mergeModels.first(where: { $0.id == defaultModel })
|
|
}
|
|
}
|
|
.sheet(isPresented: $showModelPicker) {
|
|
ModelSelectorView(
|
|
models: mergeModels,
|
|
selectedModel: mergeModel,
|
|
onSelect: { model in
|
|
mergeModel = model
|
|
showModelPicker = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private func switchMergeProvider(to newProvider: Settings.Provider) {
|
|
guard newProvider != mergeProvider else { return }
|
|
mergeProvider = newProvider
|
|
mergeModel = nil
|
|
mergeModels = []
|
|
Task { await loadMergeModels() }
|
|
}
|
|
|
|
private func loadMergeModels() async {
|
|
guard let provider = ProviderRegistry.shared.getProvider(for: mergeProvider) else {
|
|
mergeModels = []
|
|
return
|
|
}
|
|
isLoadingMergeModels = true
|
|
defer { isLoadingMergeModels = false }
|
|
do {
|
|
mergeModels = try await provider.listModels()
|
|
} catch {
|
|
Log.api.error("Failed to load models for merge provider \(mergeProvider.rawValue): \(error.localizedDescription)")
|
|
mergeModels = []
|
|
}
|
|
}
|
|
|
|
private func combine() {
|
|
isProcessing = true
|
|
errorMessage = nil
|
|
let ids = conversations.map(\.id)
|
|
let trimmedName = name.trimmingCharacters(in: .whitespaces)
|
|
let selectedMode = mode
|
|
let shouldDeleteOriginals = deleteOriginals
|
|
let selectedModelId = mergeModel?.id
|
|
let selectedProvider = mergeModel != nil ? mergeProvider : nil
|
|
|
|
Task {
|
|
do {
|
|
let newConversation = try await ConversationMergeService.merge(
|
|
conversationIds: ids,
|
|
name: trimmedName,
|
|
mode: selectedMode,
|
|
mergeModelId: selectedModelId,
|
|
mergeProvider: selectedProvider,
|
|
deleteOriginals: shouldDeleteOriginals
|
|
)
|
|
await MainActor.run {
|
|
isProcessing = false
|
|
onCompleted(newConversation)
|
|
dismiss()
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isProcessing = false
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|