277 lines
9.8 KiB
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
|
|
)
|
|
}
|
|
}
|