Files
oai-swift/oAI/Services/ConversationMergeService.swift
T
runeandClaude Sonnet 5 cf3f4ebfe4 Relicense oAI from AGPL-3.0-or-later to PolyForm Noncommercial 1.0.0
Switches the project from AGPL to a source-available license that
restricts commercial use — selling oAI or any part of it, standalone
or bundled into another product/service, now requires a separate
commercial license from the copyright holder. Noncommercial use,
study, modification, and sharing remain fully permitted.

Updates: LICENSE (canonical PolyForm Noncommercial 1.0.0 text +
commercial licensing contact note), SPDX headers and file-header
boilerplate across all Swift source files, the in-app About dialog's
license link (+ its localization catalog entry), README.md and
DEVELOPMENT.md license sections.

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

192 lines
6.9 KiB
Swift

//
// ConversationMergeService.swift
// oAI
//
// Combine multiple saved conversations into one (simple concatenation or AI-assisted merge)
//
// 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://mac.oai.pm>.
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)
}
// The model used in the merged conversation should reflect the most recently used
// model across the *source* conversations — never the model that performed the merge.
let latestModelId = sources
.flatMap { $0.1 }
.filter { $0.modelId != nil }
.max { $0.timestamp < $1.timestamp }?
.modelId
let mergedMessages: [Message]
switch mode {
case .simple:
mergedMessages = simpleMerge(sources)
case .ai:
mergedMessages = try await aiMerge(sources)
}
let newConversation = try DatabaseService.shared.saveConversation(
id: UUID(),
name: name,
messages: mergedMessages,
primaryModel: latestModelId
)
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)
// modelId intentionally left nil here: these messages are a synthesized composite,
// not output from a single source model. The conversation's primaryModel (set by the
// caller from the source conversations) is what drives the model shown in the list.
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))
)
}
}
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
}
}