From 8ddbbc0b6a438ec778b0ee67b370a883cf194804 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Tue, 16 Jun 2026 19:57:55 -0400 Subject: [PATCH] Remove built-in LLM module Deletes LLMModule, LLMConfiguration, LLMSettingsViewController, and LLMModuleTests end-to-end. Strips config.llm parsing, the LLM status row and tool button from the UI, and all doc/sample references. AI over iMessage belongs in webhook command mode + external orchestration (n8n, custom server), not in-process OpenAI calls. Closes #59 Co-Authored-By: Claude Sonnet 4.6 --- DESIGN.md | 8 +- Documentation/config-sample.json | 7 - Documentation/llm.md | 50 ------- Documentation/readme.md | 1 - Jared.xcodeproj/project.pbxproj | 20 --- Jared/Configuration.swift | 8 +- Jared/JaredConstants.swift | 1 - Jared/LLMConfiguration.swift | 26 ---- Jared/LLMModule.swift | 123 --------------- Jared/PluginManager.swift | 6 - JaredTests/LLMModuleTests.swift | 86 ----------- JaredUI/LLMSettingsViewController.swift | 190 ------------------------ JaredUI/ViewController.swift | 30 +--- readme.md | 6 - 14 files changed, 7 insertions(+), 555 deletions(-) delete mode 100644 Documentation/llm.md delete mode 100644 Jared/LLMConfiguration.swift delete mode 100644 Jared/LLMModule.swift delete mode 100644 JaredTests/LLMModuleTests.swift delete mode 100644 JaredUI/LLMSettingsViewController.swift diff --git a/DESIGN.md b/DESIGN.md index 3ce0ba5..ff06605 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -55,7 +55,6 @@ These are decorative — never use them to communicate status. | Jared | `bubble.left.fill` | `systemGreen` | | Full disk access | `internaldrive.fill` | `systemBlue` | | REST API | `network` | `systemIndigo` | -| LLM | `brain` | `systemTeal` | | Webhooks | `bolt.horizontal.fill` | `systemPink` | | Contacts | `person.fill` | `systemOrange` | | Messages automation | `envelope.fill` | `systemPurple` | @@ -80,13 +79,12 @@ All text uses the system font via `NSFont.systemFont(ofSize:weight:)`. | --- | --- | --- | --- | | App title | 18 | `.semibold` | "Jared" in header. `ViewController.swift:111` | | Section header | 10 | `.semibold`, uppercased, `tertiaryLabelColor` | "STATUS", "SERVICES". `ViewController.swift:256` | -| Row title | 13 | `.medium` | "REST API", "LLM". `ViewController.swift:563` | +| Row title | 13 | `.medium` | "REST API", "Webhooks". `ViewController.swift:563` | | Row status / header subtitle / tool button | 12 | `.regular` (or `.medium` for the header CTA) | "Running", "Authorized". `ViewController.swift:114`, `:119`, `:280`, `:568` | | Row action button | 11 | `.regular` | "Enable", "Manage". `ViewController.swift:574` | -Sheet/secondary screens (e.g. `LLMSettingsViewController`) reuse the 12pt -regular and 11pt button conventions. A new screen should pick from this scale — -don't introduce a new size. +Sheet/secondary screens reuse the 12pt regular and 11pt button conventions. +A new screen should pick from this scale — don't introduce a new size. ## Spacing & layout diff --git a/Documentation/config-sample.json b/Documentation/config-sample.json index e9b6a9d..ef6170b 100644 --- a/Documentation/config-sample.json +++ b/Documentation/config-sample.json @@ -37,12 +37,5 @@ "enabled": false, "provider": "cloudflared" } - }, - "llm": { - "provider": "openai", - "apiKey": "sk-...", - "model": "gpt-4o", - "systemPrompt": "You are a helpful assistant.", - "rateLimitSeconds": 10.0 } } \ No newline at end of file diff --git a/Documentation/llm.md b/Documentation/llm.md deleted file mode 100644 index 4e2f099..0000000 --- a/Documentation/llm.md +++ /dev/null @@ -1,50 +0,0 @@ -# LLM Integration - -Jared can forward unrecognised messages to a large language model and reply with the response. This turns any iMessage conversation into an AI chat — any message that isn't a `/command` and isn't rate-limited is automatically sent to the configured LLM. - -## How it works - -`LLMModule` registers a **catch-all route** that matches any message containing an empty string (i.e. every message). Because Jared evaluates routes in registration order and exact-command routes are registered first, the LLM route only fires when no other route has already handled the message. - -The module: -1. Checks the per-sender rate limit (default 10 s). Ignores the message silently if within the limit. -2. Sends the message text to the OpenAI Chat Completions endpoint (`https://api.openai.com/v1/chat/completions`). -3. Replies with the model's response text. - -If `apiKey` is empty or the `"llm"` key is missing from `config.json`, the module loads but does nothing. - -## Configuration - -Add an `"llm"` object to `~/Library/Application Support/Jared/config.json`: - -```json -{ - "llm": { - "provider": "openai", - "apiKey": "sk-...", - "model": "gpt-4o", - "systemPrompt": "You are a helpful assistant.", - "rateLimitSeconds": 10.0 - } -} -``` - -| Field | Type | Default | Description | -|---|---|---|---| -| `provider` | string | `"openai"` | LLM provider identifier. Currently decoded but only OpenAI is supported. | -| `apiKey` | string | `""` | OpenAI API key. Leave empty to disable LLM replies. | -| `model` | string | `"gpt-4o"` | Model name passed directly to the API (e.g. `gpt-4o`, `gpt-4-turbo`, `gpt-3.5-turbo`). | -| `systemPrompt` | string | `"You are a helpful assistant."` | System message prepended to every conversation. | -| `rateLimitSeconds` | number | `10.0` | Minimum seconds between LLM replies per sender handle. | - -See [config-sample.json](config-sample.json) for a full configuration example. - -## Rate limiting - -Each sender (identified by their iMessage handle) has an independent rate-limit bucket. If a second message arrives from the same sender before `rateLimitSeconds` has elapsed, it is silently ignored. This prevents runaway API usage in active group chats. - -## Current limitations - -- Only the OpenAI Chat Completions API is supported. The `provider` field is decoded for future use. -- Conversation history is not persisted — each message is sent as a single-turn prompt with only the system prompt prepended. -- There is no per-conversation context; all senders share the same `systemPrompt`. diff --git a/Documentation/readme.md b/Documentation/readme.md index 564e71d..0ba0272 100644 --- a/Documentation/readme.md +++ b/Documentation/readme.md @@ -7,6 +7,5 @@ Welcome to the Jared documentation hub. Here you'll find a glossary of Jared's A + [Sample Plugin](SampleModule) + [Webhooks](webhooks.md) + [n8n Integration](n8n.md) -+ [LLM Integration](llm.md) + [REST API](restapi.md) + [Research: Retiring legacy concepts](research/retiring-legacy-concepts.md) diff --git a/Jared.xcodeproj/project.pbxproj b/Jared.xcodeproj/project.pbxproj index b929bb2..ac1b05e 100644 --- a/Jared.xcodeproj/project.pbxproj +++ b/Jared.xcodeproj/project.pbxproj @@ -30,11 +30,6 @@ B33278811CB2CB1300BBDD3A /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B332787F1CB2CB1300BBDD3A /* Main.storyboard */; }; B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC32206607B009DD882 /* ContactHelper.swift */; }; B3327DC622066427009DD882 /* WebHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3327DC522066427009DD882 /* WebHookManager.swift */; }; - EC000001000000000000AA01 /* LLMConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA00 /* LLMConfiguration.swift */; }; - EC000001000000000000AA03 /* LLMModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA02 /* LLMModule.swift */; }; - EC000001000000000000AA05 /* LLMModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA04 /* LLMModuleTests.swift */; }; - EC000001000000000000AA06 /* LLMConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA00 /* LLMConfiguration.swift */; }; - EC000001000000000000AA07 /* LLMModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA02 /* LLMModule.swift */; }; EC000001000000000000AA40 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; }; EC000001000000000000AA41 /* ScheduleModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA43 /* ScheduleModule.swift */; }; EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AA45 /* ScheduleModuleTests.swift */; }; @@ -76,7 +71,6 @@ B369509524DF224D0027CB1E /* SendImageSingleBuddy.scpt in Resources */ = {isa = PBXBuildFile; fileRef = B37E82801FA85B170069E15D /* SendImageSingleBuddy.scpt */; }; B36C8D7F24EA655900C00A99 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36C8D7E24EA655900C00A99 /* Configuration.swift */; }; B375A65921EB14470073F09F /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B375A65821EB14470073F09F /* AboutViewController.swift */; }; - EC000001000000000000AC01 /* LLMSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AC02 /* LLMSettingsViewController.swift */; }; EC000001000000000000AC03 /* WebhookManagementWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AC04 /* WebhookManagementWindowController.swift */; }; EC000001000000000000AC05 /* TunnelManagementWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC000001000000000000AC06 /* TunnelManagementWindowController.swift */; }; B37E82821FA85B210069E15D /* SendTextSingleBuddy.scpt in Resources */ = {isa = PBXBuildFile; fileRef = B37E82811FA85B210069E15D /* SendTextSingleBuddy.scpt */; }; @@ -205,9 +199,6 @@ B33278821CB2CB1300BBDD3A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; B3327DC32206607B009DD882 /* ContactHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactHelper.swift; sourceTree = ""; }; B3327DC522066427009DD882 /* WebHookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebHookManager.swift; sourceTree = ""; }; - EC000001000000000000AA00 /* LLMConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMConfiguration.swift; sourceTree = ""; }; - EC000001000000000000AA02 /* LLMModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMModule.swift; sourceTree = ""; }; - EC000001000000000000AA04 /* LLMModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMModuleTests.swift; sourceTree = ""; }; EC000001000000000000AA43 /* ScheduleModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModule.swift; sourceTree = ""; }; EC000001000000000000AA45 /* ScheduleModuleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleModuleTests.swift; sourceTree = ""; }; EC000001000000000000AA53 /* MessageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSource.swift; sourceTree = ""; }; @@ -236,7 +227,6 @@ B369509124DE91760027CB1E /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; B36C8D7E24EA655900C00A99 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; B375A65821EB14470073F09F /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; - EC000001000000000000AC02 /* LLMSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMSettingsViewController.swift; sourceTree = ""; }; EC000001000000000000AC04 /* WebhookManagementWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebhookManagementWindowController.swift; sourceTree = ""; }; EC000001000000000000AC06 /* TunnelManagementWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagementWindowController.swift; sourceTree = ""; }; B3774D4E2424AFEB00F02F62 /* JaredDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = JaredDebug.entitlements; sourceTree = ""; }; @@ -366,7 +356,6 @@ EC000001000000000000AA0C /* MenuBarManager.swift */, EC000001000000000000AD02 /* SystemSleepPreventer.swift */, B375A65821EB14470073F09F /* AboutViewController.swift */, - EC000001000000000000AC02 /* LLMSettingsViewController.swift */, EC000001000000000000AC04 /* WebhookManagementWindowController.swift */, EC000001000000000000AC06 /* TunnelManagementWindowController.swift */, B36563EE1F3D68980064B114 /* TouchBarTextFieldExtension.swift */, @@ -383,7 +372,6 @@ children = ( B3327DC722068147009DD882 /* WebhookTests.swift */, FA7B3C91D05E4A8B67120456 /* WebhookDeliveryTests.swift */, - EC000001000000000000AA04 /* LLMModuleTests.swift */, EC000001000000000000AA11 /* ConfigurationWatcherTests.swift */, EC000001000000000000AA45 /* ScheduleModuleTests.swift */, EC000001000000000000AA24 /* MessageMatcherTests.swift */, @@ -471,8 +459,6 @@ B3BF17C1244EBE9A00CC44C5 /* Router.swift */, B39A65DF1CC1FB1B003E26B0 /* PluginManager.swift */, B3327DC522066427009DD882 /* WebHookManager.swift */, - EC000001000000000000AA00 /* LLMConfiguration.swift */, - EC000001000000000000AA02 /* LLMModule.swift */, EC000001000000000000AA43 /* ScheduleModule.swift */, EC000001000000000000AA53 /* MessageSource.swift */, EC000001000000000000AA57 /* InMemoryMessageSource.swift */, @@ -791,8 +777,6 @@ B3327DC42206607B009DD882 /* ContactHelper.swift in Sources */, B332787C1CB2CB1300BBDD3A /* ViewController.swift in Sources */, B3327DC622066427009DD882 /* WebHookManager.swift in Sources */, - EC000001000000000000AA01 /* LLMConfiguration.swift in Sources */, - EC000001000000000000AA03 /* LLMModule.swift in Sources */, EC000001000000000000AA40 /* ScheduleModule.swift in Sources */, EC000001000000000000AA50 /* MessageSource.swift in Sources */, EC000001000000000000AA54 /* InMemoryMessageSource.swift in Sources */, @@ -807,7 +791,6 @@ B3BF17C4244EBF4900CC44C5 /* InternalModule.swift in Sources */, B3BF17C2244EBE9A00CC44C5 /* Router.swift in Sources */, B375A65921EB14470073F09F /* AboutViewController.swift in Sources */, - EC000001000000000000AC01 /* LLMSettingsViewController.swift in Sources */, EC000001000000000000AC03 /* WebhookManagementWindowController.swift in Sources */, EC000001000000000000AC05 /* TunnelManagementWindowController.swift in Sources */, 7F0E931D1D02FC250096BABE /* Global.swift in Sources */, @@ -834,8 +817,6 @@ B3B844CC24DA671E00DC162A /* DiskAccessDelegate.swift in Sources */, B319235724DD311B001904BA /* InternalModuleTest.swift in Sources */, B38BB6AF24EBB4D8000700F7 /* Configuration.swift in Sources */, - EC000001000000000000AA06 /* LLMConfiguration.swift in Sources */, - EC000001000000000000AA07 /* LLMModule.swift in Sources */, EC000001000000000000AA41 /* ScheduleModule.swift in Sources */, EC000001000000000000AA51 /* MessageSource.swift in Sources */, EC000001000000000000AA55 /* InMemoryMessageSource.swift in Sources */, @@ -863,7 +844,6 @@ B3B844BA24D77A4900DC162A /* SendStyleTest.swift in Sources */, B3327DD8220682ED009DD882 /* WebhookTests.swift in Sources */, FA7B3C91D05E4A8B67120457 /* WebhookDeliveryTests.swift in Sources */, - EC000001000000000000AA05 /* LLMModuleTests.swift in Sources */, EC000001000000000000AA09 /* ConfigurationWatcherTests.swift in Sources */, EC000001000000000000AA21 /* MessageMatcherTests.swift in Sources */, EC000001000000000000AA44 /* ScheduleModuleTests.swift in Sources */, diff --git a/Jared/Configuration.swift b/Jared/Configuration.swift index 45c4cac..ad2f4d8 100644 --- a/Jared/Configuration.swift +++ b/Jared/Configuration.swift @@ -12,16 +12,13 @@ struct ConfigurationFile: Decodable { let routes: [String: RouteConfiguration] let webhooks: [RichWebhook] let webServer: WebserverConfiguration - let llm: LLMConfiguration? init(routes: [String: RouteConfiguration] = [:], webhooks: [RichWebhook] = [], - webServer: WebserverConfiguration = WebserverConfiguration(port: 3000), - llm: LLMConfiguration? = nil) { + webServer: WebserverConfiguration = WebserverConfiguration(port: 3000)) { self.routes = routes self.webhooks = webhooks self.webServer = webServer - self.llm = llm } init(from decoder: Decoder) throws { @@ -29,11 +26,10 @@ struct ConfigurationFile: Decodable { routes = (try? c.decodeIfPresent([String: RouteConfiguration].self, forKey: .routes)) ?? [:] webhooks = (try? c.decodeIfPresent([RichWebhook].self, forKey: .webhooks)) ?? [] webServer = (try? c.decodeIfPresent(WebserverConfiguration.self, forKey: .webServer)) ?? WebserverConfiguration(port: 3000) - llm = try? c.decodeIfPresent(LLMConfiguration.self, forKey: .llm) } private enum CodingKeys: String, CodingKey { - case routes, webhooks, webServer, llm + case routes, webhooks, webServer } } diff --git a/Jared/JaredConstants.swift b/Jared/JaredConstants.swift index 201f524..1a73a29 100644 --- a/Jared/JaredConstants.swift +++ b/Jared/JaredConstants.swift @@ -10,7 +10,6 @@ import Foundation struct JaredConstants { static let restApiIsDisabled = "RestApiIsDisabled" - static let llmIsDisabled = "LLMIsDisabled" static let jaredIsDisabled = "JaredIsDisabled" static let contactsAccess = "ContactsAccess" static let sendMessageAccess = "SendMessageAccess" diff --git a/Jared/LLMConfiguration.swift b/Jared/LLMConfiguration.swift deleted file mode 100644 index 4e9fb87..0000000 --- a/Jared/LLMConfiguration.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// LLMConfiguration.swift -// Jared -// - -import Foundation - -struct LLMConfiguration: Decodable { - let provider: String - let apiKey: String - let model: String - let systemPrompt: String - let rateLimitSeconds: Double - - init(provider: String = "openai", - apiKey: String, - model: String = "gpt-4o", - systemPrompt: String = "You are a helpful assistant.", - rateLimitSeconds: Double = 10.0) { - self.provider = provider - self.apiKey = apiKey - self.model = model - self.systemPrompt = systemPrompt - self.rateLimitSeconds = rateLimitSeconds - } -} diff --git a/Jared/LLMModule.swift b/Jared/LLMModule.swift deleted file mode 100644 index aa9df0a..0000000 --- a/Jared/LLMModule.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// LLMModule.swift -// Jared -// - -import Foundation -import JaredFramework - -class LLMModule: RoutingModule { - var description: String = "Routes unmatched messages to an LLM API and replies with the response." - var routes: [Route] = [] - var sender: MessageSender - - private let config: LLMConfiguration - private let session: URLSession - private var lastRequestTime: [String: Date] = [:] - private let lock = NSLock() - - required convenience init(sender: MessageSender) { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - let configURL = appSupport.appendingPathComponent("Jared/config.json") - var llmConfig: LLMConfiguration? = nil - if let data = try? Data(contentsOf: configURL), - let top = try? JSONDecoder().decode(TopLevelConfig.self, from: data) { - llmConfig = top.llm - } - self.init(sender: sender, config: llmConfig ?? LLMConfiguration(apiKey: ""), session: .shared) - } - - init(sender: MessageSender, config: LLMConfiguration, session: URLSession) { - self.sender = sender - self.config = config - self.session = session - - let fallback = Route( - name: "llm-fallback", - comparisons: [.contains: [""]], - call: { [weak self] in self?.handle($0) }, - description: "Send unmatched messages to LLM" - ) - routes = [fallback] - } - - func handle(_ message: Message) { - guard !UserDefaults.standard.bool(forKey: "LLMIsDisabled") else { return } - guard let text = message.getTextBody() else { NSLog("LLM: no text body"); return } - guard !text.hasPrefix("/") else { NSLog("LLM: skipping command"); return } - guard !config.apiKey.isEmpty else { NSLog("LLM: empty apiKey"); return } - - let senderHandle = message.sender.handle - NSLog("LLM: handling message from %@: %@", senderHandle, text) - - lock.lock() - let last = lastRequestTime[senderHandle] - let now = Date() - if let last = last, now.timeIntervalSince(last) < config.rateLimitSeconds { - NSLog("LLM: rate limited for %@", senderHandle) - lock.unlock() - return - } - lastRequestTime[senderHandle] = now - lock.unlock() - - guard let recipient = message.RespondTo() else { return } - - callLLM(userText: text) { [weak self] reply in - guard let reply = reply else { return } - self?.sender.send(reply, to: recipient) - } - } - - private func callLLM(userText: String, completion: @escaping (String?) -> Void) { - guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else { - completion(nil) - return - } - - let body: [String: Any] = [ - "model": config.model, - "messages": [ - ["role": "system", "content": config.systemPrompt], - ["role": "user", "content": userText] - ] - ] - - guard let bodyData = try? JSONSerialization.data(withJSONObject: body) else { - completion(nil) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.httpBody = bodyData - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") - - session.dataTask(with: request) { data, response, error in - if let error = error { - NSLog("LLM request error: %@", error.localizedDescription) - completion(nil) - return - } - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - guard let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let first = choices.first, - let msg = first["message"] as? [String: Any], - let content = msg["content"] as? String else { - let body = data.flatMap { String(data: $0, encoding: .utf8) } ?? "nil" - NSLog("LLM bad response: status=%d body=%@", status, body) - completion(nil) - return - } - completion(content) - }.resume() - } -} - -// Minimal top-level wrapper to decode just the llm key from config.json -private struct TopLevelConfig: Decodable { - let llm: LLMConfiguration? -} diff --git a/Jared/PluginManager.swift b/Jared/PluginManager.swift index 201c1f3..e488ce1 100644 --- a/Jared/PluginManager.swift +++ b/Jared/PluginManager.swift @@ -54,12 +54,6 @@ class PluginManager: RouteProvider, PluginController, ConfigurationApplier { modules.append(ScheduleModule(sender: sender)) modules.append(InternalModule(sender: sender, pluginManager: self)) modules.append(webHookManager) - if let llmConfig = config.llm { - NSLog("LLM module loaded: provider=%@ model=%@", llmConfig.provider, llmConfig.model) - modules.append(LLMModule(sender: sender, config: llmConfig, session: .shared)) - } else { - NSLog("LLM module NOT loaded: config.llm is nil") - } } func loadPlugins() { diff --git a/JaredTests/LLMModuleTests.swift b/JaredTests/LLMModuleTests.swift deleted file mode 100644 index d27182e..0000000 --- a/JaredTests/LLMModuleTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// LLMModuleTests.swift -// JaredTests -// - -import XCTest -import JaredFramework -@testable import Jared - -class LLMModuleTests: XCTestCase { - let sender = JaredMock() - let testConfig = LLMConfiguration( - provider: "openai", - apiKey: "test-key", - model: "gpt-4o", - systemPrompt: "You are a helpful assistant.", - rateLimitSeconds: 1.0 - ) - let me = Person(givenName: "zeke", handle: "zeke@email.com", isMe: true) - let other = Person(givenName: "taylor", handle: "taylor@swift.org", isMe: false) - - var sessionConfig: URLSessionConfiguration! - - override func setUp() { - sessionConfig = URLSessionConfiguration.ephemeral - sessionConfig.protocolClasses = [URLProtocolMock.self] - URLProtocolMock.testURLs = [:] - URLProtocolMock.matchedDataURLs = [] - sender.calls = [] - } - - func testSendsReplyFromLLMResponse() { - let apiURL = URL(string: "https://api.openai.com/v1/chat/completions")! - let responseJSON = """ - {"choices":[{"message":{"content":"Hello from LLM!"}}]} - """.data(using: .utf8)! - URLProtocolMock.testURLs[apiURL] = responseJSON - - let module = LLMModule(sender: sender, config: testConfig, session: URLSession(configuration: sessionConfig)) - let message = Message(body: TextBody("Hi there"), date: Date(), sender: other, recipient: me) - - module.handle(message) - sleep(2) - - XCTAssertEqual((sender.calls.first?.body as? TextBody)?.message, "Hello from LLM!") - } - - func testSkipsSlashCommands() { - let module = LLMModule(sender: sender, config: testConfig, session: URLSession(configuration: sessionConfig)) - let message = Message(body: TextBody("/ping"), date: Date(), sender: other, recipient: me) - - module.handle(message) - sleep(1) - - XCTAssertTrue(sender.calls.isEmpty, "Should not call LLM for slash commands") - } - - func testRateLimiting() { - let apiURL = URL(string: "https://api.openai.com/v1/chat/completions")! - let responseJSON = """ - {"choices":[{"message":{"content":"Reply"}}]} - """.data(using: .utf8)! - URLProtocolMock.testURLs[apiURL] = responseJSON - - let module = LLMModule(sender: sender, config: testConfig, session: URLSession(configuration: sessionConfig)) - let message = Message(body: TextBody("First"), date: Date(), sender: other, recipient: me) - let messageTwo = Message(body: TextBody("Second"), date: Date(), sender: other, recipient: me) - - module.handle(message) - module.handle(messageTwo) - sleep(2) - - XCTAssertEqual(sender.calls.count, 1, "Second message should be rate-limited") - } - - func testGracefulAPIFailure() { - // No URL mock registered = network failure path - let module = LLMModule(sender: sender, config: testConfig, session: URLSession(configuration: sessionConfig)) - let message = Message(body: TextBody("Hello"), date: Date(), sender: other, recipient: me) - - module.handle(message) - sleep(2) - - XCTAssertTrue(sender.calls.isEmpty, "Should not send anything on API failure") - } -} diff --git a/JaredUI/LLMSettingsViewController.swift b/JaredUI/LLMSettingsViewController.swift deleted file mode 100644 index 566dfdc..0000000 --- a/JaredUI/LLMSettingsViewController.swift +++ /dev/null @@ -1,190 +0,0 @@ -// -// LLMSettingsViewController.swift -// JaredUI -// - -import Cocoa - -final class LLMSettingsViewController: NSViewController { - - // MARK: - Fields - - private let apiKeyField = NSSecureTextField() - private let modelField = NSTextField() - private let systemField = NSTextField() - private let rateLimitField = NSTextField() - - private let configURL: URL = { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - return support.appendingPathComponent("Jared/config.json") - }() - - // MARK: - Lifecycle - - override func loadView() { - view = NSView(frame: NSRect(x: 0, y: 0, width: 460, height: 300)) - } - - override func viewDidLoad() { - super.viewDidLoad() - buildUI() - loadValues() - } - - // MARK: - Build UI - - private func buildUI() { - let titleLabel = makeLabel("LLM Settings", size: 14, weight: .semibold) - let subtitleLabel = makeLabel("Jared will forward non-command messages to this LLM.", size: 12, weight: .regular, color: .secondaryLabelColor) - - let apiKeyLabel = makeLabel("API Key", size: 12, weight: .medium) - let modelLabel = makeLabel("Model", size: 12, weight: .medium) - let systemLabel = makeLabel("System Prompt", size: 12, weight: .medium) - let rateLimitLabel = makeLabel("Rate Limit (s)", size: 12, weight: .medium) - - for field in [apiKeyField, modelField, systemField, rateLimitField] as [NSTextField] { - field.translatesAutoresizingMaskIntoConstraints = false - field.bezelStyle = .roundedBezel - } - modelField.placeholderString = "gpt-4o" - systemField.placeholderString = "You are a helpful assistant." - rateLimitField.placeholderString = "10" - - let saveBtn = NSButton(title: "Save", target: self, action: #selector(save(_:))) - saveBtn.bezelStyle = .rounded - saveBtn.keyEquivalent = "\r" - saveBtn.translatesAutoresizingMaskIntoConstraints = false - - let cancelBtn = NSButton(title: "Cancel", target: self, action: #selector(cancel(_:))) - cancelBtn.bezelStyle = .rounded - cancelBtn.keyEquivalent = "\u{1b}" - cancelBtn.translatesAutoresizingMaskIntoConstraints = false - - let docsBtn = NSButton(title: "View docs", target: self, action: #selector(openDocs(_:))) - docsBtn.bezelStyle = .inline - docsBtn.font = .systemFont(ofSize: 11) - docsBtn.contentTintColor = .controlAccentColor - docsBtn.translatesAutoresizingMaskIntoConstraints = false - - let rows: [(NSView, NSView)] = [ - (apiKeyLabel, apiKeyField), - (modelLabel, modelField), - (systemLabel, systemField), - (rateLimitLabel, rateLimitField), - ] - - let labelWidth: CGFloat = 110 - var top: CGFloat = 260 - - for (lbl, field) in rows { - view.addSubview(lbl) - view.addSubview(field) - NSLayoutConstraint.activate([ - lbl.trailingAnchor.constraint(equalTo: field.leadingAnchor, constant: -8), - lbl.centerYAnchor.constraint(equalTo: field.centerYAnchor), - lbl.widthAnchor.constraint(equalToConstant: labelWidth), - lbl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - field.topAnchor.constraint(equalTo: view.topAnchor, constant: top - 24), - field.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - ]) - top -= 44 - } - - view.addSubview(titleLabel) - view.addSubview(subtitleLabel) - view.addSubview(saveBtn) - view.addSubview(cancelBtn) - view.addSubview(docsBtn) - - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), - titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - - subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), - subtitleLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), - - saveBtn.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - saveBtn.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), - saveBtn.widthAnchor.constraint(equalToConstant: 80), - - cancelBtn.trailingAnchor.constraint(equalTo: saveBtn.leadingAnchor, constant: -8), - cancelBtn.centerYAnchor.constraint(equalTo: saveBtn.centerYAnchor), - cancelBtn.widthAnchor.constraint(equalToConstant: 80), - - docsBtn.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - docsBtn.centerYAnchor.constraint(equalTo: saveBtn.centerYAnchor), - ]) - } - - private func makeLabel(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor = .labelColor) -> NSTextField { - let f = NSTextField(labelWithString: text) - f.font = .systemFont(ofSize: size, weight: weight) - f.textColor = color - f.translatesAutoresizingMaskIntoConstraints = false - return f - } - - // MARK: - Load / Save - - private func loadValues() { - guard let llm = readLLMBlock() else { return } - apiKeyField.stringValue = llm["apiKey"] as? String ?? "" - modelField.stringValue = llm["model"] as? String ?? "" - systemField.stringValue = llm["systemPrompt"] as? String ?? "" - if let rate = llm["rateLimitSeconds"] as? Double { - rateLimitField.stringValue = String(rate) - } - } - - @objc private func save(_ sender: Any) { - var top: [String: Any] = readTopLevel() ?? [:] - var llm: [String: Any] = top["llm"] as? [String: Any] ?? [:] - - llm["provider"] = "openai" - llm["apiKey"] = apiKeyField.stringValue - llm["model"] = modelField.stringValue.isEmpty ? "gpt-4o" : modelField.stringValue - llm["systemPrompt"] = systemField.stringValue.isEmpty ? "You are a helpful assistant." : systemField.stringValue - llm["rateLimitSeconds"] = Double(rateLimitField.stringValue) ?? 10.0 - top["llm"] = llm - - do { - let data = try JSONSerialization.data(withJSONObject: top, options: [.prettyPrinted, .sortedKeys]) - try FileManager.default.createDirectory(at: configURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - try data.write(to: configURL, options: .atomic) - } catch { - showError(error.localizedDescription) - return - } - - dismiss(self) - } - - @objc private func cancel(_ sender: Any) { - dismiss(self) - } - - @objc private func openDocs(_ sender: Any) { - NSWorkspace.shared.open(URL(string: "https://github.com/ericdahl-dev/Jared/blob/master/Documentation/llm.md")!) - } - - // MARK: - Helpers - - private func readTopLevel() -> [String: Any]? { - guard let data = try? Data(contentsOf: configURL), - let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } - return obj - } - - private func readLLMBlock() -> [String: Any]? { - return readTopLevel()?["llm"] as? [String: Any] - } - - private func showError(_ message: String) { - let alert = NSAlert() - alert.messageText = "Failed to save settings" - alert.informativeText = message - alert.alertStyle = .critical - alert.runModal() - } -} diff --git a/JaredUI/ViewController.swift b/JaredUI/ViewController.swift index 5b3fc84..11859e7 100644 --- a/JaredUI/ViewController.swift +++ b/JaredUI/ViewController.swift @@ -32,7 +32,6 @@ class ViewController: NSViewController, DiskAccessDelegate { private var jaredRow: StatusRowView! private var diskRow: StatusRowView! private var apiRow: StatusRowView! - private var llmRow: StatusRowView! private var webhookRow: StatusRowView! private var contactsRow: StatusRowView! private var sendRow: StatusRowView! @@ -47,7 +46,6 @@ class ViewController: NSViewController, DiskAccessDelegate { private let observeKeys = [ JaredConstants.jaredIsDisabled, JaredConstants.restApiIsDisabled, - JaredConstants.llmIsDisabled, JaredConstants.contactsAccess, JaredConstants.sendMessageAccess, JaredConstants.fullDiskAccess, @@ -166,7 +164,6 @@ class ViewController: NSViewController, DiskAccessDelegate { diskRow = StatusRowView(icon: "internaldrive.fill", iconColor: .systemBlue, title: "Full disk access") apiRow = StatusRowView(icon: "network", iconColor: .systemIndigo, title: "REST API") tunnelRow = StatusRowView(icon: "antenna.radiowaves.left.and.right", iconColor: .systemBlue, title: "Tunnel") - llmRow = StatusRowView(icon: "brain", iconColor: .systemTeal, title: "LLM") webhookRow = StatusRowView(icon: "bolt.horizontal.fill", iconColor: .systemPink, title: "Webhooks") contactsRow = StatusRowView(icon: "person.fill", iconColor: .systemOrange, title: "Contacts") sendRow = StatusRowView(icon: "envelope.fill", iconColor: .systemPurple, title: "Messages automation") @@ -176,8 +173,6 @@ class ViewController: NSViewController, DiskAccessDelegate { apiRow.actionButton.action = #selector(EnableDisableRestApiAction(_:)) tunnelRow.actionButton.target = self tunnelRow.actionButton.action = #selector(openTunnelManagement(_:)) - llmRow.actionButton.target = self - llmRow.actionButton.action = #selector(EnableDisableLLMAction(_:)) webhookRow.actionButton.target = self webhookRow.actionButton.action = #selector(openWebhookConfig(_:)) contactsRow.actionButton.target = self @@ -192,7 +187,7 @@ class ViewController: NSViewController, DiskAccessDelegate { let rows: [NSView] = [ sectionHeader("Status"), jaredRow, diskRow, sleepRow, separator(), - sectionHeader("Services"), apiRow, tunnelRow, llmRow, webhookRow, + sectionHeader("Services"), apiRow, tunnelRow, webhookRow, separator(), sectionHeader("Permissions"), contactsRow, sendRow, separator(), @@ -221,7 +216,7 @@ class ViewController: NSViewController, DiskAccessDelegate { content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor), ]) - for row in [jaredRow, diskRow, sleepRow, apiRow, tunnelRow, llmRow, webhookRow, contactsRow, sendRow] as [NSView] { + for row in [jaredRow, diskRow, sleepRow, apiRow, tunnelRow, webhookRow, contactsRow, sendRow] as [NSView] { row.leadingAnchor.constraint(equalTo: stack.leadingAnchor).isActive = true row.trailingAnchor.constraint(equalTo: stack.trailingAnchor).isActive = true } @@ -233,19 +228,15 @@ class ViewController: NSViewController, DiskAccessDelegate { let reloadBtn = toolButton("Reload plugins", icon: "arrow.clockwise", action: #selector(ReloadButtonPressed(_:))) let pluginsBtn = toolButton("Open plugins folder", icon: "folder", action: #selector(OpenPluginsButtonAction(_:))) - let llmBtn = toolButton("Configure LLM…", icon: "brain", action: #selector(openLLMSettings(_:))) container.addSubview(reloadBtn) container.addSubview(pluginsBtn) - container.addSubview(llmBtn) NSLayoutConstraint.activate([ container.heightAnchor.constraint(equalToConstant: 56), reloadBtn.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20), reloadBtn.centerYAnchor.constraint(equalTo: container.centerYAnchor), pluginsBtn.leadingAnchor.constraint(equalTo: reloadBtn.trailingAnchor, constant: 8), pluginsBtn.centerYAnchor.constraint(equalTo: container.centerYAnchor), - llmBtn.leadingAnchor.constraint(equalTo: pluginsBtn.trailingAnchor, constant: 8), - llmBtn.centerYAnchor.constraint(equalTo: container.centerYAnchor), ]) return container } @@ -386,14 +377,6 @@ class ViewController: NSViewController, DiskAccessDelegate { self.tunnelRow?.update(statusText: "Disabled", state: .off, buttonTitle: "Manage") } - // LLM row - let llmOff = self.defaults.bool(forKey: JaredConstants.llmIsDisabled) - if llmOff { - self.llmRow?.update(statusText: "Disabled", state: .off, buttonTitle: "Enable") - } else { - self.llmRow?.update(statusText: "Enabled", state: .on, buttonTitle: "Disable") - } - // Webhook row let count = self.defaults.integer(forKey: JaredConstants.webhookCount) let whText = count == 0 ? "None configured" : count == 1 ? "1 configured" : "\(count) configured" @@ -497,10 +480,6 @@ class ViewController: NSViewController, DiskAccessDelegate { updateTouchBarButton() } - @IBAction func EnableDisableLLMAction(_ sender: Any) { - defaults.set(!defaults.bool(forKey: JaredConstants.llmIsDisabled), forKey: JaredConstants.llmIsDisabled) - } - @IBAction func contactsButtonAction(_ sender: Any) { DispatchQueue.global(qos: .background).async { switch CNAuthorizationStatus(rawValue: self.defaults.integer(forKey: JaredConstants.contactsAccess)) { @@ -534,11 +513,6 @@ class ViewController: NSViewController, DiskAccessDelegate { (NSApplication.shared.delegate as? AppDelegate)?.pluginManager.reload() } - @objc private func openLLMSettings(_ sender: Any) { - let vc = LLMSettingsViewController() - presentAsSheet(vc) - } - func displayAccessError() { let alert = NSAlert() alert.messageText = "Full disk access required" diff --git a/readme.md b/readme.md index 642c9bc..bb3636d 100644 --- a/readme.md +++ b/readme.md @@ -78,7 +78,6 @@ A configuration file is located at `~/Library/Application Support/Jared/config.j + Configure webhooks + Set REST API port + Disable specific routes -+ Configure LLM integration See [config-sample.json](Documentation/config-sample.json) for an example. @@ -96,11 +95,6 @@ Jared supports webhooks for sending your server information about incoming and o ### REST API Jared contains a web server with a REST API that can be enabled. This allows you make HTTP requests to send messages to any recipient. For more information, [check out the REST API documentation](Documentation/restapi.md). -### LLM Integration -Jared can forward any non-command message to a large language model (e.g. GPT-4o) and reply with the response, turning any iMessage conversation into an AI chat. - -Add an `"llm"` block to `config.json` with your API key to enable it. For full configuration options and rate limiting details, see the [LLM documentation](Documentation/llm.md). - ## How Jared works Jared reads from the Messages database on a set interval and queries for new messages. It provides a routing framework for actioning on messages, and uses AppleScript to send outgoing messages. It's also multi-threaded so it can take care of multiple requests at once. Jared allows expansion via `.bundle` plugin files, webhooks, and a REST API. This allows commands to be added without modifying the main Jared code base.