File size: 4,041 Bytes
7b2dfc5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 | 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
}
}
|