| 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 | } |