Files
oai-swift/oAI/Models/SyncModels.swift

277 lines
9.8 KiB
Swift

import Foundation
//
// 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 <https://www.gnu.org/licenses/>.
enum SyncAuthMethod: String, CaseIterable, Codable {
case ssh = "ssh"
case password = "password"
case token = "token"
var displayName: String {
switch self {
case .ssh: return "SSH Key"
case .password: return "Username + Password"
case .token: return "Access Token"
}
}
}
enum SyncError: LocalizedError {
case notConfigured
case missingCredentials
case gitNotFound
case gitFailed(String)
case repoNotCloned
case secretsDetected([String])
case parseError(String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Git sync not configured. Check Settings > Sync."
case .missingCredentials:
return "Missing credentials. Check authentication method in Settings > Sync."
case .gitNotFound:
return "Git not found. Install Xcode Command Line Tools."
case .gitFailed(let message):
return "Git command failed: \(message)"
case .repoNotCloned:
return "Repository not cloned. Click 'Clone Repository' first."
case .secretsDetected(let secrets):
return "Secrets detected in conversations: \(secrets.joined(separator: ", ")). Remove before syncing."
case .parseError(let message):
return "Failed to parse conversation: \(message)"
}
}
}
struct SyncStatus: Equatable {
var lastSyncTime: Date?
var uncommittedChanges: Int = 0
var isCloned: Bool = false
var currentBranch: String?
var remoteStatus: String? // "up-to-date", "ahead 3", "behind 2", etc.
}
struct ConversationExport {
let id: String
let name: String
let createdAt: Date
let updatedAt: Date
let primaryModel: String? // Primary model used in conversation
let messages: [MessageExport]
struct MessageExport {
let role: String
let content: String
let timestamp: Date
let tokens: Int?
let cost: Double?
let modelId: String? // Model that generated this message
}
func toMarkdown() -> String {
var md = "# \(name)\n\n"
md += "**ID**: `\(id)`\n"
md += "**Created**: \(ISO8601DateFormatter().string(from: createdAt))\n"
md += "**Updated**: \(ISO8601DateFormatter().string(from: updatedAt))\n"
// Add primary model if available
if let primaryModel = primaryModel {
md += "**Primary Model**: \(primaryModel)\n"
}
// Calculate all unique models used
let uniqueModels = Set(messages.compactMap { $0.modelId })
if !uniqueModels.isEmpty {
md += "**Models Used**: \(uniqueModels.sorted().joined(separator: ", "))\n"
}
md += "\n---\n\n"
for message in messages {
md += "## \(message.role.capitalized)\n\n"
md += message.content + "\n\n"
var meta: [String] = []
if let modelId = message.modelId {
meta.append("Model: \(modelId)")
}
if let tokens = message.tokens {
meta.append("Tokens: \(tokens)")
}
if let cost = message.cost {
meta.append(String(format: "Cost: $%.4f", cost))
}
if !meta.isEmpty {
md += "*\(meta.joined(separator: " | "))*\n\n"
}
md += "---\n\n"
}
return md
}
/// Parse markdown back to ConversationExport
static func fromMarkdown(_ markdown: String) throws -> ConversationExport {
let lines = markdown.components(separatedBy: .newlines)
var lineIndex = 0
// Parse title (first line should be "# Title")
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("# ") else {
throw SyncError.parseError("Missing title")
}
let name = String(lines[lineIndex].dropFirst(2)).trimmingCharacters(in: .whitespaces)
lineIndex += 1
// Skip empty line
while lineIndex < lines.count && lines[lineIndex].trimmingCharacters(in: .whitespaces).isEmpty {
lineIndex += 1
}
// Parse ID
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**ID**: `") else {
throw SyncError.parseError("Missing ID")
}
let idLine = lines[lineIndex]
let idStart = idLine.index(idLine.startIndex, offsetBy: 9)
let idEnd = idLine.lastIndex(of: "`") ?? idLine.endIndex
let id = String(idLine[idStart..<idEnd])
lineIndex += 1
// Parse Created date
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**Created**: ") else {
throw SyncError.parseError("Missing Created date")
}
let createdStr = String(lines[lineIndex].dropFirst(13))
guard let createdAt = ISO8601DateFormatter().date(from: createdStr) else {
throw SyncError.parseError("Invalid Created date format")
}
lineIndex += 1
// Parse Updated date
guard lineIndex < lines.count, lines[lineIndex].hasPrefix("**Updated**: ") else {
throw SyncError.parseError("Missing Updated date")
}
let updatedStr = String(lines[lineIndex].dropFirst(13))
guard let updatedAt = ISO8601DateFormatter().date(from: updatedStr) else {
throw SyncError.parseError("Invalid Updated date format")
}
lineIndex += 1
// Parse optional Primary Model
var primaryModel: String? = nil
if lineIndex < lines.count && lines[lineIndex].hasPrefix("**Primary Model**: ") {
primaryModel = String(lines[lineIndex].dropFirst(19)).trimmingCharacters(in: .whitespaces)
lineIndex += 1
}
// Skip optional Models Used line
if lineIndex < lines.count && lines[lineIndex].hasPrefix("**Models Used**: ") {
lineIndex += 1
}
// Skip to first message (past the ---)
while lineIndex < lines.count && !lines[lineIndex].hasPrefix("## ") {
lineIndex += 1
}
// Parse messages
var messages: [MessageExport] = []
while lineIndex < lines.count {
// Parse role (## User or ## Assistant)
guard lines[lineIndex].hasPrefix("## ") else {
lineIndex += 1
continue
}
let role = String(lines[lineIndex].dropFirst(3)).lowercased().trimmingCharacters(in: .whitespaces)
lineIndex += 1
// Skip empty line
while lineIndex < lines.count && lines[lineIndex].trimmingCharacters(in: .whitespaces).isEmpty {
lineIndex += 1
}
// Parse content (until metadata line or ---)
var content = ""
while lineIndex < lines.count &&
!lines[lineIndex].hasPrefix("*Tokens:") &&
!lines[lineIndex].hasPrefix("---") &&
!lines[lineIndex].hasPrefix("## ") {
if !content.isEmpty {
content += "\n"
}
content += lines[lineIndex]
lineIndex += 1
}
content = content.trimmingCharacters(in: .whitespacesAndNewlines)
// Parse metadata if present
var modelId: String? = nil
var tokens: Int? = nil
var cost: Double? = nil
if lineIndex < lines.count && lines[lineIndex].hasPrefix("*") {
let metaLine = lines[lineIndex]
// Extract modelId: "Model: gpt-4"
if let modelMatch = metaLine.range(of: "Model: ([^|\\*]+)", options: .regularExpression) {
let modelStr = String(metaLine[modelMatch]).dropFirst(7)
modelId = modelStr.trimmingCharacters(in: .whitespaces)
}
// Extract tokens: "Tokens: 50"
if let tokensMatch = metaLine.range(of: "Tokens: (\\d+)", options: .regularExpression) {
let tokensStr = String(metaLine[tokensMatch]).dropFirst(8)
tokens = Int(tokensStr)
}
// Extract cost: "Cost: $0.0001"
if let costMatch = metaLine.range(of: "Cost: \\$([0-9.]+)", options: .regularExpression) {
let costStr = String(metaLine[costMatch]).dropFirst(7)
cost = Double(costStr)
}
lineIndex += 1
}
// Create message
messages.append(MessageExport(
role: role,
content: content,
timestamp: Date(), // Use current time as we don't store timestamps in markdown yet
tokens: tokens,
cost: cost,
modelId: modelId
))
// Skip to next message or end
while lineIndex < lines.count && !lines[lineIndex].hasPrefix("## ") {
lineIndex += 1
}
}
return ConversationExport(
id: id,
name: name,
createdAt: createdAt,
updatedAt: updatedAt,
primaryModel: primaryModel,
messages: messages
)
}
}