development
swift 269 lines 8.26 KB
Raw
1 import Combine
2 import Ed
3 import Foundation
4
5 #if os(tvOS)
6 import Darwin
7 #endif
8
9 enum PrivateGuideAvailability: Equatable {
10 case notInstalled
11 case downloaded
12 case preparing
13 case ready
14 case answering
15 case failed(String)
16 case unsupported(String)
17
18 var title: String {
19 switch self {
20 case .notInstalled:
21 return "Private guide not ready yet"
22 case .downloaded:
23 return "Private guide downloaded on this device"
24 case .preparing:
25 return "Preparing your private guide"
26 case .ready:
27 return "Private guide ready on this device"
28 case .answering:
29 return "Answering privately"
30 case .failed:
31 return "Private guide needs attention"
32 case .unsupported:
33 return "Private guide not available on this device"
34 }
35 }
36
37 var detail: String {
38 switch self {
39 case .notInstalled:
40 return
41 "You can browse everything first, then add a one-time private guide download whenever you want deeper follow-up answers."
42 case .downloaded:
43 return
44 "The private guide is already stored on this device. Finish preparing it when you want faster follow-up answers in the app."
45 case .preparing:
46 return
47 "The first run can take a while because Klepon needs to download and load the private guide locally on your device."
48 case .ready:
49 return
50 "Klepon can now answer follow-up questions privately without turning the app into a generic chat shell."
51 case .answering:
52 return
53 "Klepon is grounding your question against the local guide and composing a concise answer."
54 case .failed(let message):
55 return message
56 case .unsupported(let reason):
57 return reason
58 }
59 }
60
61 var actionTitle: String {
62 switch self {
63 case .ready:
64 return "Private guide ready"
65 case .failed:
66 return "Try again"
67 case .downloaded:
68 return "Finish preparing private guide"
69 case .unsupported:
70 return "Not available"
71 default:
72 return "Prepare private guide"
73 }
74 }
75
76 var isBusy: Bool {
77 switch self {
78 case .preparing, .answering:
79 return true
80 default:
81 return false
82 }
83 }
84
85 var isFailure: Bool {
86 if case .failed = self {
87 return true
88 }
89 return false
90 }
91
92 var isUnsupported: Bool {
93 if case .unsupported = self {
94 return true
95 }
96 return false
97 }
98 }
99
100 enum OndeGuideError: LocalizedError {
101 case unavailable
102
103 var errorDescription: String? {
104 switch self {
105 case .unavailable:
106 return "The private guide is not ready yet."
107 }
108 }
109 }
110
111 /// Abstraction over the on-device guide engine. Depending on this protocol
112 /// instead of the concrete `OndeGuideEngine` lets higher-level services such as
113 /// `GuideAnswerService` be unit-tested with a fake, without spinning up the
114 /// Rust/UniFFI inference runtime.
115 protocol GuideAnswering: AnyObject {
116 func answer(prompt: String) async throws -> String
117 }
118
119 #if os(tvOS)
120 /// Check if this Apple TV has the Metal capabilities needed for on-device inference.
121 /// AppleTV14,1 (Apple TV 4K 3rd gen, A15) is the minimum. Older chips (A10X, A12)
122 /// lack simdgroup_matrix support and crash at shader compilation.
123 func kleponDeviceSupportsInference() -> Bool {
124 var size = 0
125 sysctlbyname("hw.machine", nil, &size, nil, 0)
126 var machine = [CChar](repeating: 0, count: size)
127 sysctlbyname("hw.machine", &machine, &size, nil, 0)
128 let identifier = String(cString: machine)
129
130 guard identifier.hasPrefix("AppleTV") else { return false }
131 let numericPart = identifier.dropFirst("AppleTV".count)
132 guard let commaIndex = numericPart.firstIndex(of: ",") else { return false }
133 guard let generation = Int(numericPart[numericPart.startIndex..<commaIndex]) else {
134 return false
135 }
136 return generation >= 14
137 }
138 #endif
139
140 @MainActor
141 final class OndeGuideEngine: ObservableObject, GuideAnswering {
142 @Published private(set) var availability: PrivateGuideAvailability = .notInstalled
143 @Published private(set) var storageUsedDescription: String =
144 OndeEnvironmentBootstrap.storageUsageDescription()
145
146 // Lazy so the Rust/UniFFI runtime is not initialized during app-state
147 // construction before the app actually needs the private guide engine.
148 private var engine: EdAgent?
149
150 var estimatedDownloadDescription: String {
151 OndeEnvironmentBootstrap.estimatedDownloadDescription
152 }
153
154 private func getOrCreateEngine() -> EdAgent {
155 if let engine {
156 return engine
157 }
158
159 let newEngine = EdAgent()
160 engine = newEngine
161 return newEngine
162 }
163
164 func prepareIfNeeded(forceReload: Bool = false) async {
165 if forceReload {
166 _ = await engine?.unload()
167 availability = .notInstalled
168 }
169
170 switch availability {
171 case .ready, .preparing, .answering, .unsupported:
172 return
173 case .notInstalled, .downloaded, .failed:
174 break
175 }
176
177 #if os(tvOS)
178 if !kleponDeviceSupportsInference() {
179 availability = .unsupported(
180 "The private guide requires Apple TV 4K 3rd generation (2022) or newer. "
181 + "Older models don't have the hardware needed to run on-device inference."
182 )
183 return
184 }
185 #endif
186
187 availability = .preparing
188 OndeEnvironmentBootstrap.configureIfNeeded()
189
190 let engine = getOrCreateEngine()
191
192 do {
193 guard
194 let appId = Bundle.main.object(forInfoDictionaryKey: "OndeAppId") as? String,
195 let appSecret = Bundle.main.object(forInfoDictionaryKey: "OndeAppSecret")
196 as? String,
197 !appId.isEmpty,
198 !appSecret.isEmpty
199 else {
200 availability = .failed(
201 "Onde credentials not configured. See Configs/Secrets.xcconfig.")
202 return
203 }
204
205 _ = try await engine.loadAssignedModel(
206 environment: .production,
207 appID: appId,
208 appSecret: appSecret,
209 systemPrompt:
210 "You are Klepon, a warm and careful guide to Indonesian food. Stay grounded in the notes you are given, keep answers short, and say clearly when the guide does not have enough detail."
211 )
212 availability = .ready
213 refreshStorageUsage()
214 } catch {
215 availability = .failed(error.localizedDescription)
216 refreshStorageUsage()
217 }
218 }
219
220 func answer(prompt: String) async throws -> String {
221 await prepareIfNeeded()
222
223 guard case .ready = availability else {
224 throw OndeGuideError.unavailable
225 }
226
227 let engine = getOrCreateEngine()
228
229 availability = .answering
230 _ = await engine.clearHistory()
231
232 do {
233 let result = try await engine.send(prompt)
234 availability = .ready
235 refreshStorageUsage()
236 return result.text.trimmingCharacters(in: .whitespacesAndNewlines)
237 } catch {
238 availability = .failed(error.localizedDescription)
239 throw error
240 }
241 }
242
243 func removePrivateGuide() async {
244 _ = await engine?.unload()
245 engine = nil
246 OndeEnvironmentBootstrap.clearPrivateGuideFiles()
247 availability = .notInstalled
248 refreshStorageUsage()
249 }
250
251 func refreshStorageUsage() {
252 storageUsedDescription = OndeEnvironmentBootstrap.storageUsageDescription()
253
254 if storageUsedDescription == "Not downloaded yet" {
255 if case .downloaded = availability {
256 availability = .notInstalled
257 }
258 } else if case .notInstalled = availability {
259 availability = .downloaded
260 }
261 }
262
263 func reset() {
264 if case .failed = availability {
265 availability = .notInstalled
266 }
267 refreshStorageUsage()
268 }
269 }