ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
4.04 kB
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
}
}