forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathChatDraftStore.swift
More file actions
234 lines (206 loc) · 8.14 KB
/
Copy pathChatDraftStore.swift
File metadata and controls
234 lines (206 loc) · 8.14 KB
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
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
import Foundation
/// Stable identity for unsent text in one conversational composer.
///
/// Drafts are deliberately separate from chat/session persistence: they are local UI
/// intent and never become conversation history until a send is accepted.
struct ChatDraftKey: Hashable, Sendable {
let scope: String
let contextID: String
static func mainChat(contextID: String = "default") -> Self {
Self(scope: "main_chat", contextID: contextID)
}
static let floatingMain = Self(scope: "floating_chat", contextID: "main")
static let onboardingMain = Self(scope: "onboarding_chat", contextID: "main")
static let onboardingFloating = Self(scope: "onboarding_chat", contextID: "floating")
static func floatingAgent(_ id: UUID) -> Self {
Self(scope: "floating_agent", contextID: id.uuidString.lowercased())
}
static func taskChat(_ taskID: String) -> Self {
Self(scope: "task_chat", contextID: taskID)
}
}
private struct ChatDraftRecord: Codable, Sendable {
let version: Int
let ownerID: String
let scope: String
let contextID: String
let text: String
let updatedAt: Date
}
/// Lightweight local persistence for conversational drafts.
///
/// Each draft is an independent, atomically replaced record under Application
/// Support. Writes are coalesced on a serial background queue, so the typing path
/// only updates in-memory UI state. A corrupt record cannot affect another draft.
@MainActor
final class ChatDraftStore {
static let shared = ChatDraftStore()
private struct StorageID: Hashable, Sendable {
let ownerID: String
let key: ChatDraftKey
}
private let rootURL: URL
private let fileManager: FileManager
private let writeDelay: TimeInterval
private let ownerIDProvider: () -> String?
private let persistenceQueue = DispatchQueue(label: "com.omi.desktop.chat-drafts", qos: .utility)
private var cache: [StorageID: String] = [:]
private var loaded: Set<StorageID> = []
private var pendingWrites: [StorageID: DispatchWorkItem] = [:]
private var writeGenerations: [StorageID: Int] = [:]
init(
rootURL: URL? = nil,
fileManager: FileManager = .default,
writeDelay: TimeInterval = 0.2,
ownerIDProvider: @escaping () -> String? = {
UserDefaults.standard.string(forKey: .authUserId)
}
) {
self.fileManager = fileManager
self.writeDelay = writeDelay
self.ownerIDProvider = ownerIDProvider
if let rootURL {
self.rootURL = rootURL
} else {
let applicationSupport =
fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
let bundleID = Bundle.main.bundleIdentifier ?? "com.omi.desktop"
self.rootURL =
applicationSupport
.appendingPathComponent(bundleID, isDirectory: true)
.appendingPathComponent("Drafts/v1", isDirectory: true)
}
}
func text(for key: ChatDraftKey, ownerID: String? = nil) -> String {
let id = storageID(for: key, ownerID: ownerID)
if loaded.contains(id) {
return cache[id] ?? ""
}
loaded.insert(id)
guard let data = try? Data(contentsOf: fileURL(for: id)),
let record = try? JSONDecoder().decode(ChatDraftRecord.self, from: data),
record.version == 1,
record.ownerID == id.ownerID,
record.scope == key.scope,
record.contextID == key.contextID
else {
cache[id] = ""
return ""
}
cache[id] = record.text
return record.text
}
func setText(_ text: String, for key: ChatDraftKey, ownerID: String? = nil) {
let id = storageID(for: key, ownerID: ownerID)
loaded.insert(id)
cache[id] = text
scheduleWrite(for: id, text: text)
}
func clear(_ key: ChatDraftKey, ownerID: String? = nil) {
setText("", for: key, ownerID: ownerID)
}
/// Synchronously persists the latest in-memory values. Used for orderly app
/// termination and tests; normal edits remain off the main thread.
func flush() {
let snapshots = pendingWrites.keys.map { id in (id, cache[id] ?? "") }
pendingWrites.values.forEach { $0.cancel() }
pendingWrites.removeAll()
let rootURL = rootURL
let flushWork: @Sendable () -> Void = {
for (id, text) in snapshots {
Self.persist(text: text, id: id, rootURL: rootURL)
}
}
persistenceQueue.sync(execute: flushWork)
}
/// Explicit sign-out is destructive for that account's drafts. Light auth
/// invalidation intentionally does not call this, so reauthentication retains text.
func clearAll(ownerID: String?) {
let normalizedOwnerID = Self.normalizedOwnerID(ownerID)
let matchingIDs = Set(cache.keys.filter { $0.ownerID == normalizedOwnerID })
for id in matchingIDs {
pendingWrites[id]?.cancel()
pendingWrites[id] = nil
cache[id] = nil
loaded.remove(id)
}
let ownerURL = rootURL.appendingPathComponent(Self.fileNameComponent(normalizedOwnerID), isDirectory: true)
let removeWork: @Sendable () -> Void = {
try? FileManager.default.removeItem(at: ownerURL)
}
persistenceQueue.sync(execute: removeWork)
}
private func storageID(for key: ChatDraftKey, ownerID: String?) -> StorageID {
StorageID(
ownerID: Self.normalizedOwnerID(ownerID ?? ownerIDProvider()),
key: key
)
}
private func fileURL(for id: StorageID) -> URL {
let ownerURL = rootURL.appendingPathComponent(Self.fileNameComponent(id.ownerID), isDirectory: true)
let key = "\(id.key.scope)\u{0}\(id.key.contextID)"
return ownerURL.appendingPathComponent(Self.fileNameComponent(key)).appendingPathExtension("json")
}
private func scheduleWrite(for id: StorageID, text: String) {
pendingWrites[id]?.cancel()
let generation = (writeGenerations[id] ?? 0) + 1
writeGenerations[id] = generation
let rootURL = rootURL
// The work item runs on `persistenceQueue` (not the main actor). Under Swift 6
// the runtime asserts executor assumptions, so the block must be a non-isolated
// `@Sendable` closure — an inferred `@MainActor` block dispatched off the main
// queue would trap (`dispatch_assert_queue_fail`). `persist` is a static call;
// the in-memory bookkeeping hops back to the main actor via a `Task`.
let block: @Sendable () -> Void = { [weak self] in
Self.persist(text: text, id: id, rootURL: rootURL)
Task { @MainActor [weak self] in
guard let self, self.writeGenerations[id] == generation else { return }
self.pendingWrites[id] = nil
}
}
let workItem = DispatchWorkItem(block: block)
pendingWrites[id] = workItem
persistenceQueue.asyncAfter(deadline: .now() + writeDelay, execute: workItem)
}
private nonisolated static func persist(
text: String,
id: StorageID,
rootURL: URL
) {
let fileManager = FileManager.default
let ownerURL = rootURL.appendingPathComponent(fileNameComponent(id.ownerID), isDirectory: true)
let key = "\(id.key.scope)\u{0}\(id.key.contextID)"
let url = ownerURL.appendingPathComponent(fileNameComponent(key)).appendingPathExtension("json")
if text.isEmpty {
try? fileManager.removeItem(at: url)
return
}
do {
try fileManager.createDirectory(at: ownerURL, withIntermediateDirectories: true)
let record = ChatDraftRecord(
version: 1,
ownerID: id.ownerID,
scope: id.key.scope,
contextID: id.key.contextID,
text: text,
updatedAt: Date()
)
let data = try JSONEncoder().encode(record)
try data.write(to: url, options: .atomic)
} catch {
// Draft contents are private user text, so never include them in logs.
logError("ChatDraftStore: failed to persist \(id.key.scope) draft", error: error)
}
}
private static func normalizedOwnerID(_ ownerID: String?) -> String {
let trimmed = ownerID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "local" : trimmed
}
private nonisolated static func fileNameComponent(_ value: String) -> String {
Data(value.utf8).base64EncodedString()
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "=", with: "")
}
}