Dolphin3.0-CoreML / examples /ios /DolphinCoreMLTestApp /Sources /Persistence /AssistantPersistence.swift
| import Foundation | |
| enum AssistantPersistenceError: LocalizedError, Sendable { | |
| case applicationSupportDirectoryUnavailable | |
| case unsupportedSchemaVersion(Int) | |
| var errorDescription: String? { | |
| switch self { | |
| case .applicationSupportDirectoryUnavailable: | |
| "The app's Application Support directory is unavailable." | |
| case .unsupportedSchemaVersion(let version): | |
| "The saved assistant workspace uses unsupported schema version \(version)." | |
| } | |
| } | |
| } | |
| /// Owns the durable assistant workspace. All disk access is serialized by the | |
| /// actor and writes use an atomic replacement so an interrupted save cannot | |
| /// leave a partially written JSON document. | |
| actor AssistantPersistence { | |
| private struct Envelope: Codable, Sendable { | |
| let schemaVersion: Int | |
| let workspace: AssistantWorkspace | |
| } | |
| private static let currentSchemaVersion = 5 | |
| nonisolated let fileURL: URL | |
| init(fileURL: URL? = nil, fileManager: FileManager = .default) throws { | |
| if let fileURL { | |
| self.fileURL = fileURL | |
| return | |
| } | |
| guard | |
| let applicationSupport = fileManager.urls( | |
| for: .applicationSupportDirectory, | |
| in: .userDomainMask | |
| ).first | |
| else { | |
| throw AssistantPersistenceError.applicationSupportDirectoryUnavailable | |
| } | |
| self.fileURL = applicationSupport | |
| .appendingPathComponent("DolphinAssistant", isDirectory: true) | |
| .appendingPathComponent("workspace.json", isDirectory: false) | |
| } | |
| /// Returns an empty workspace on first launch. Decode and filesystem errors | |
| /// are surfaced to the caller so corrupt state is never silently discarded. | |
| func load() throws -> AssistantWorkspace { | |
| let fileManager = FileManager.default | |
| guard fileManager.fileExists(atPath: fileURL.path) else { | |
| return .empty | |
| } | |
| let data = try Data(contentsOf: fileURL, options: [.mappedIfSafe]) | |
| let envelope = try Self.makeDecoder().decode(Envelope.self, from: data) | |
| switch envelope.schemaVersion { | |
| case Self.currentSchemaVersion: | |
| return envelope.workspace | |
| case 4, 3, 2: | |
| return envelope.workspace | |
| case 1: | |
| return Self.migrateSchemaOne(envelope.workspace) | |
| default: | |
| throw AssistantPersistenceError.unsupportedSchemaVersion( | |
| envelope.schemaVersion | |
| ) | |
| } | |
| } | |
| func save(_ workspace: AssistantWorkspace) throws { | |
| let fileManager = FileManager.default | |
| let directoryURL = fileURL.deletingLastPathComponent() | |
| try fileManager.createDirectory( | |
| at: directoryURL, | |
| withIntermediateDirectories: true, | |
| attributes: nil | |
| ) | |
| let envelope = Envelope( | |
| schemaVersion: Self.currentSchemaVersion, | |
| workspace: workspace | |
| ) | |
| let data = try Self.makeEncoder().encode(envelope) | |
| try data.write( | |
| to: fileURL, | |
| options: [.atomic, .completeFileProtectionUnlessOpen] | |
| ) | |
| } | |
| private static func makeEncoder() -> JSONEncoder { | |
| let encoder = JSONEncoder() | |
| // Numeric seconds preserve Date's full Codable precision for exact | |
| // workspace round trips (ISO-8601's default strategy drops fractions). | |
| encoder.dateEncodingStrategy = .secondsSince1970 | |
| encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] | |
| return encoder | |
| } | |
| private static func makeDecoder() -> JSONDecoder { | |
| let decoder = JSONDecoder() | |
| decoder.dateDecodingStrategy = .secondsSince1970 | |
| return decoder | |
| } | |
| private static func migrateSchemaOne( | |
| _ legacyWorkspace: AssistantWorkspace | |
| ) -> AssistantWorkspace { | |
| var workspace = legacyWorkspace | |
| if workspace.settings.maxNewTokens | |
| == AgentSettings.legacyDefaultMaxNewTokens | |
| { | |
| workspace.settings.maxNewTokens = AgentSettings.defaultMaxNewTokens | |
| } | |
| workspace.messages = workspace.messages.compactMap { message in | |
| guard message.role == .assistant else { return message } | |
| var cleaned = message | |
| cleaned.content = AssistantChatText.cleaned(message.content) | |
| return cleaned.content.isEmpty ? nil : cleaned | |
| } | |
| return workspace | |
| } | |
| } | |