From 57e36be93d7842ce3eabee1fa24124ace6fff4f4 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Fri, 2 Oct 2026 23:07:26 +0200 Subject: [PATCH 1/6] feat: Ollama and LM Studio chat via OpenAI-compatible streaming API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ollama/lmstudio cases to ChatProvider with isLocal, pillID, init?(pillID:) - Add LocalChat helpers: normaliseURL, parseSSEDelta, filterThinkingBlocks - Stream local responses token-by-token via URLSession.bytes - Add ai_ollama (#FACC15) and ai_lmstudio (#A3E635) pills to PillCatalog - Add server URL settings in Settings → Chat (Local models GroupBox) - NSAllowsLocalNetworking in both Info.plist targets Co-Authored-By: Claude Sonnet 4.6 --- .../NotchBuddy.xcodeproj/project.pbxproj | 18 ++ NotchBuddy/Resources/Info.plist | 5 + NotchBuddy/Resources/InfoAppStore.plist | 5 + NotchBuddy/Sources/App/AppState.swift | 52 +++++- NotchBuddy/Sources/App/ClaudeService.swift | 169 +++++++++++++----- NotchBuddy/Sources/App/IslandTypes.swift | 35 ++++ .../Sources/App/IslandViewContent.swift | 40 +++-- NotchBuddy/Sources/App/LocalChat.swift | 40 +++++ NotchBuddy/Sources/App/PillCatalog.swift | 4 + NotchBuddy/Sources/App/SettingsView.swift | 45 +++++ NotchBuddy/project.yml | 4 + 11 files changed, 360 insertions(+), 57 deletions(-) create mode 100644 NotchBuddy/Sources/App/LocalChat.swift diff --git a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj index 92d192850..866c65c06 100644 --- a/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj +++ b/NotchBuddy/NotchBuddy.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 3756B7E1A55D9CEC8C4D34A4 /* IslandStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */; }; 3F19756A396F125EC711247F /* IslandTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */; }; 4117E7C8AA231AAE36E7509D /* UploadCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B241B043223AFBBFB7418A5E /* UploadCanvasView.swift */; }; + 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; 4B6AC00F22EC75E90E0157D5 /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */; }; 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */; }; 56BE8C733F10A030A638FB60 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; @@ -42,16 +43,20 @@ 63F9F3AAC881D3EDDDD8AED9 /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */; }; 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA02B08886939FF6FE0475DD /* IslandViewContent.swift */; }; + 6D158183870434FEDF07E050 /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; }; 6FC65C190ED614C664C9BC17 /* SafeWebURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */; }; + 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */; }; 78B3C6814431B2DD9D1F4949 /* SoundEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE21C2B315D37AA70ED45E9 /* SoundEngine.swift */; }; 78FCEDC8F78B683AB10E6529 /* NotionPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */; }; 878924960FC0F2B3524D48AC /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 192844E7299DEED5C2372067 /* AppDelegate.swift */; }; 8A6B3688D6B3BF728D106D8B /* UserIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4A13AEE6E06F46CEC9D908C /* UserIdentity.swift */; }; 8C32A0C3EAF536D3836F61D5 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; 8D1C01F283FA7806119A1C85 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */; }; + 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; }; 967B525F7D3837F31D740D18 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; 9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F142FB3952B5F0639383047 /* MusicController.swift */; }; 9DD56C95ECABDDCE23652D50 /* HookServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */; }; + B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */; }; B3EC9CF3C8BD629C3530E8F1 /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; B605F976F4141435F27CF266 /* IslandRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A1A8206F73376F4CAF2057 /* IslandRootView.swift */; }; C0E18D0F6F5F6D131E6066B8 /* GreetingCanvasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */; }; @@ -69,6 +74,7 @@ E4B9986674F4D425826F15A8 /* StripePoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EF78999D68D56ADE4CEFBE2 /* StripePoller.swift */; }; E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3232F258523DD8D09B670363 /* FileDropView.swift */; }; E7A5D23F455565FE1E210398 /* PillCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A38B5686C4A1F15AB5D29 /* PillCatalog.swift */; }; + E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8790ACE9F73BD74640A0F20C /* LocalChat.swift */; }; EB23ED9F1C12EA8B54A76A91 /* ResendPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B5FDDE03F5F711146937E2 /* ResendPoller.swift */; }; ECECA2F225E82B7C951658D7 /* UploadSequenceEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FD65943C32E150F1CD65C31 /* UploadSequenceEngine.swift */; }; F06EE0A97CB77096A337AA8B /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = C066E2B991AA4F56B2724263 /* sounds */; }; @@ -94,6 +100,7 @@ 78BC1357785CA1B099A33969 /* IslandScreenGeometry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandScreenGeometry.swift; sourceTree = ""; }; 79D8DD79C3DD9BBC584B3CB2 /* HookServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HookServer.swift; sourceTree = ""; }; 7C1A7D5CBE2AC084A10C5FF3 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 8790ACE9F73BD74640A0F20C /* LocalChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalChat.swift; sourceTree = ""; }; 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GithubPoller.swift; sourceTree = ""; }; 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandTypes.swift; sourceTree = ""; }; 9F6D7015D6A77AA59323C0E7 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; @@ -104,11 +111,13 @@ B2FDED98F7F6C755A72C97A3 /* SafeWebURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeWebURL.swift; sourceTree = ""; }; B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = N8nPoller.swift; sourceTree = ""; }; BE03E03D1804B0B92C5C2DAD /* NotionPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotionPoller.swift; sourceTree = ""; }; + BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdownView.swift; sourceTree = ""; }; C066E2B991AA4F56B2724263 /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; name = sounds; path = Resources/sounds; sourceTree = SOURCE_ROOT; }; C2E4F18AA940EFF7E047B9F7 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; C581F7B1A9F14B281B60063E /* GreetingCanvasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GreetingCanvasView.swift; sourceTree = ""; }; D4540F630F8BA04AFFFA3979 /* BotEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BotEngine.swift; sourceTree = ""; }; D710BD7834228045869D0184 /* IslandWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandWindowController.swift; sourceTree = ""; }; + D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMarkdown.swift; sourceTree = ""; }; D8212018F09EA1E96FBD63CD /* IslandStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IslandStateMachine.swift; sourceTree = ""; }; E91B733ADC638E14E9BB6EFA /* WindowContextCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowContextCapture.swift; sourceTree = ""; }; F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBuddyApp.swift; sourceTree = ""; }; @@ -126,6 +135,8 @@ A29F4C0B02BE91654D02AA0C /* BotCanvasView.swift */, D4540F630F8BA04AFFFA3979 /* BotEngine.swift */, 3104222B3F17BA5E8DEA5277 /* CalcomPoller.swift */, + D797CC384B5BC70D34F121BC /* ChatMarkdown.swift */, + BFE88A53AEAA637F866D479D /* ChatMarkdownView.swift */, A83D5410BD030B243ADDCFF4 /* ClaudeService.swift */, 3232F258523DD8D09B670363 /* FileDropView.swift */, 8CB8B6E5E9DA6164F979E88F /* GithubPoller.swift */, @@ -137,6 +148,7 @@ 8D3CE3617E9D336DDBA10F7A /* IslandTypes.swift */, FA02B08886939FF6FE0475DD /* IslandViewContent.swift */, D710BD7834228045869D0184 /* IslandWindowController.swift */, + 8790ACE9F73BD74640A0F20C /* LocalChat.swift */, 4F142FB3952B5F0639383047 /* MusicController.swift */, B89DA1A0C21AE3535A1760BB /* N8nPoller.swift */, F1BD8F3CAA60CF37640759E1 /* NotchBuddyApp.swift */, @@ -300,6 +312,8 @@ CD8DECA66B9E09C97E469DB9 /* BotCanvasView.swift in Sources */, C24D863967D8D32D828ED4B1 /* BotEngine.swift in Sources */, 4F1E0B3C838DA3CD6F864BC0 /* CalcomPoller.swift in Sources */, + 8FC5907E9506DC79EB1DDC3C /* ChatMarkdown.swift in Sources */, + 4976F8BF814D6F817370F1D8 /* ChatMarkdownView.swift in Sources */, 2DEB459FBD0BA3CEAAA5FDE9 /* ClaudeService.swift in Sources */, E776C7D1DE5C62EA8D4BA3E3 /* FileDropView.swift in Sources */, C808B654C84D9F71DBF8493A /* GithubPoller.swift in Sources */, @@ -311,6 +325,7 @@ 3F19756A396F125EC711247F /* IslandTypes.swift in Sources */, 065FCE817315454DE3F85F25 /* IslandViewContent.swift in Sources */, 091EC3B2F1060D7EE1317E4E /* IslandWindowController.swift in Sources */, + 6D158183870434FEDF07E050 /* LocalChat.swift in Sources */, 5C6D0BC8C546718CAA82BF90 /* MusicController.swift in Sources */, 0A267EE2955C7AD8E02A296C /* N8nPoller.swift in Sources */, 06BF5FD95E03010CAFDBF2D0 /* NotchBuddyApp.swift in Sources */, @@ -339,6 +354,8 @@ 651FA3F09608DE5D02D61CCE /* BotCanvasView.swift in Sources */, 0E9CE475EC53C67DAB00F8E5 /* BotEngine.swift in Sources */, 1969590AFEE6CB21404346F1 /* CalcomPoller.swift in Sources */, + B2D86BE268D6F4C86BD0C446 /* ChatMarkdown.swift in Sources */, + 733FCC6A45DE963EF2024B4E /* ChatMarkdownView.swift in Sources */, 0329E76BA4A8FEEA8A6D76E7 /* ClaudeService.swift in Sources */, 2132477000FD77F7EB234DAA /* FileDropView.swift in Sources */, C18227D148D99FE6EE3CC51A /* GithubPoller.swift in Sources */, @@ -350,6 +367,7 @@ D82E93014C8087AE81511E40 /* IslandTypes.swift in Sources */, 68A950E3C23FD612639D9776 /* IslandViewContent.swift in Sources */, C61DDE2EC5FDDB39E4AA3B34 /* IslandWindowController.swift in Sources */, + E81575CE4433C6F88E424A8F /* LocalChat.swift in Sources */, 9C653B8291EFFA414565E8A8 /* MusicController.swift in Sources */, 160EDB9D06275E2607DA5151 /* N8nPoller.swift in Sources */, DE05D09A32117582AC93A24A /* NotchBuddyApp.swift in Sources */, diff --git a/NotchBuddy/Resources/Info.plist b/NotchBuddy/Resources/Info.plist index 2eb7fa8cd..b25f25efa 100644 --- a/NotchBuddy/Resources/Info.plist +++ b/NotchBuddy/Resources/Info.plist @@ -24,6 +24,11 @@ NSAccessibilityUsageDescription Pour lire le titre de la fenêtre active et l'attacher comme contexte. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSAppleEventsUsageDescription Pour sauter au terminal, envoyer des mails et piloter Musique. NSHighResolutionCapable diff --git a/NotchBuddy/Resources/InfoAppStore.plist b/NotchBuddy/Resources/InfoAppStore.plist index ab3b158d0..f4808c73a 100644 --- a/NotchBuddy/Resources/InfoAppStore.plist +++ b/NotchBuddy/Resources/InfoAppStore.plist @@ -26,6 +26,11 @@ public.app-category.developer-tools LSUIElement + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 0d0619c73..58e4904c0 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -69,6 +69,18 @@ final class AppState: ObservableObject { @Published var openAIChatModel: String = ChatProvider.openai.defaultModel { didSet { UserDefaults.standard.set(openAIChatModel, forKey: "openAIChatModel") } } + @Published var ollamaChatModel: String = ChatProvider.ollama.defaultModel { + didSet { UserDefaults.standard.set(ollamaChatModel, forKey: "ollamaChatModel") } + } + @Published var lmstudioChatModel: String = ChatProvider.lmstudio.defaultModel { + didSet { UserDefaults.standard.set(lmstudioChatModel, forKey: "lmstudioChatModel") } + } + @Published var ollamaServerURL: String = "http://localhost:11434" { + didSet { UserDefaults.standard.set(ollamaServerURL, forKey: "ollamaServerURL") } + } + @Published var lmstudioServerURL: String = "http://localhost:1234" { + didSet { UserDefaults.standard.set(lmstudioServerURL, forKey: "lmstudioServerURL") } + } // The always-on workspace pill (default: VS Code). Persisted. @Published var mainPillId: String = PillCatalog.defaultMainPillId { @@ -85,6 +97,34 @@ final class AppState: ObservableObject { func fetchModelsIfNeeded(for provider: ChatProvider) { guard !loadingProviderModels.contains(provider), fetchedProviderModels[provider] == nil else { return } + // Local providers: fetch from server URL (no API key needed) + if provider.isLocal { + let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL + let normalised = LocalChat.normaliseURL(baseURL) + guard !normalised.isEmpty else { + providerModelFetchError[provider] = "No server URL configured." + return + } + loadingProviderModels.insert(provider) + providerModelFetchError.removeValue(forKey: provider) + Task { + let models = await ClaudeService.fetchLocalModels(baseURL: normalised) + loadingProviderModels.remove(provider) + if models.isEmpty { + providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" + } else { + fetchedProviderModels[provider] = models + let current = provider == .ollama ? ollamaChatModel : lmstudioChatModel + if !models.contains(where: { $0.id == current }) { + let first = models.first!.id + if provider == .ollama { ollamaChatModel = first } + else { lmstudioChatModel = first } + } + } + } + return + } + // Remote providers: require API key guard let apiKey = KeychainStore.shared.get(provider.keychainKey), !apiKey.isEmpty else { providerModelFetchError[provider] = "No API key — add it in Settings." return @@ -97,14 +137,13 @@ final class AppState: ObservableObject { case .anthropic: models = await ClaudeService.fetchModels(apiKey: apiKey) case .google: models = await ClaudeService.fetchGoogleModels(apiKey: apiKey) case .openai: models = await ClaudeService.fetchOpenAIModels(apiKey: apiKey) + case .ollama, .lmstudio: models = [] // handled above } loadingProviderModels.remove(provider) if models.isEmpty { providerModelFetchError[provider] = "Failed to load models. Check your API key." } else { fetchedProviderModels[provider] = models - // If the saved model isn't in the fetched list, pick a sensible default: - // prefer "sonnet" (Anthropic), "flash" (Google), "mini" (OpenAI); else first. switch provider { case .anthropic: if !models.contains(where: { $0.id == claudeModel }) { @@ -118,6 +157,7 @@ final class AppState: ObservableObject { if !models.contains(where: { $0.id == openAIChatModel }) { openAIChatModel = models.first(where: { $0.id.contains("mini") })?.id ?? models.first!.id } + case .ollama, .lmstudio: break } } } @@ -129,6 +169,8 @@ final class AppState: ObservableObject { case .anthropic: return claudeModel case .google: return googleChatModel case .openai: return openAIChatModel + case .ollama: return ollamaChatModel + case .lmstudio: return lmstudioChatModel } } @@ -256,6 +298,10 @@ final class AppState: ObservableObject { if let v = ud.string(forKey: "chatProvider"), let p = ChatProvider(rawValue: v) { chatProvider = p } if let v = ud.string(forKey: "googleChatModel"), !v.isEmpty { googleChatModel = v } if let v = ud.string(forKey: "openAIChatModel"), !v.isEmpty { openAIChatModel = v } + if let v = ud.string(forKey: "ollamaChatModel"), !v.isEmpty { ollamaChatModel = v } + if let v = ud.string(forKey: "lmstudioChatModel"), !v.isEmpty { lmstudioChatModel = v } + if let v = ud.string(forKey: "ollamaServerURL"), !v.isEmpty { ollamaServerURL = v } + if let v = ud.string(forKey: "lmstudioServerURL"), !v.isEmpty { lmstudioServerURL = v } // Migrate old 60s default → 15s if let v = ud.object(forKey: "autoCloseInterval") as? Double { autoCloseInterval = (v == 60) ? 15 : v @@ -572,5 +618,5 @@ enum ChatRole { case user, assistant } struct ChatMessage: Identifiable { let id = UUID() let role: ChatRole - let content: String + var content: String // var for streaming updates } diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 7336198cd..0dcbe67b0 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -175,6 +175,22 @@ final class ClaudeService { .map { (id: $0.id, label: $0.id) } } + /// Fetches the model list from a local OpenAI-compatible server (Ollama, LM Studio). + /// Expects `GET /v1/models` with an `{"data":[{"id":"…"}]}` response. + static func fetchLocalModels(baseURL: String) async -> [(id: String, label: String)] { + guard let url = URL(string: "\(baseURL)/v1/models") else { return [] } + var req = URLRequest(url: url, timeoutInterval: 5) + req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { return [] } + return items.compactMap { item -> (id: String, label: String)? in + guard let id = item["id"] as? String else { return nil } + return (id: id, label: id) + } + } + /// Chosen in Settings; falls back to the default when the field is left empty. private var model: String { let m = AppState.shared.claudeModel.trimmingCharacters(in: .whitespacesAndNewlines) @@ -206,7 +222,7 @@ final class ClaudeService { \(opening) \ You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \ Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ - No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks. + Use markdown formatting where it helps clarity: **bold**, headings (##), bullet lists, and `code` spans. For code blocks use triple backticks. """ } @@ -264,28 +280,42 @@ final class ClaudeService { } } - // MARK: - OpenAI-compatible chat (Google Gemini / OpenAI) + // MARK: - OpenAI-compatible chat (Google Gemini / OpenAI / Ollama / LM Studio) func chatOpenAICompatible(query: String, context: PromptContext?, state: AppState) async { let provider = state.chatProvider guard provider != .anthropic else { return } - guard let key = KeychainStore.shared.get(provider.keychainKey), !key.isEmpty else { - await showError("\(provider.displayName) API key missing. Configure it in Settings.", state: state) - return - } let baseURL: String - switch provider { - case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" - case .openai: baseURL = "https://api.openai.com/v1/chat/completions" - case .anthropic: return + if provider == .ollama { + baseURL = LocalChat.normaliseURL(state.ollamaServerURL) + } else if provider == .lmstudio { + baseURL = LocalChat.normaliseURL(state.lmstudioServerURL) + } else { + switch provider { + case .google: baseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + case .openai: baseURL = "https://api.openai.com/v1" + case .anthropic, .ollama, .lmstudio: baseURL = "" + } + } + + guard !baseURL.isEmpty, let url = URL(string: "\(baseURL)/chat/completions") else { return } + + // Auth header + let authHeader: String + if provider.isLocal { + authHeader = "Bearer ollama" + } else { + guard let key = KeychainStore.shared.get(provider.keychainKey), !key.isEmpty else { + await showError("\(provider.displayName) API key missing. Configure it in Settings.", state: state) + return + } + authHeader = "Bearer \(key)" } - guard let url = URL(string: baseURL) else { return } - // Build messages: system + conversation history + new user turn + // Build messages var msgs: [[String: Any]] = [["role": "system", "content": systemPrompt]] for m in conversationMessages { - // Remap Anthropic content arrays to plain strings for OpenAI compat var simplified = m if let content = m["content"] as? [[String: Any]], let textBlock = content.first(where: { ($0["type"] as? String) == "text" }), @@ -294,7 +324,6 @@ final class ClaudeService { } msgs.append(simplified) } - // Add user message (plain text for OpenAI compat) var userText = query if conversationMessages.isEmpty, let ctx = context { switch ctx { @@ -309,44 +338,104 @@ final class ClaudeService { msgs.append(["role": "user", "content": userText]) conversationMessages.append(["role": "user", "content": userText]) - let body: [String: Any] = [ + let useStream = provider.isLocal + var body: [String: Any] = [ "model": state.activeChatModel, "max_tokens": 4096, "messages": msgs, ] + if useStream { body["stream"] = true } - var req = URLRequest(url: url, timeoutInterval: 30) + var req = URLRequest(url: url, timeoutInterval: useStream ? 120 : 30) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + req.setValue(authHeader, forHTTPHeaderField: "Authorization") req.httpBody = try? JSONSerialization.data(withJSONObject: body) - do { - let (data, response) = try await URLSession.shared.data(for: req) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let err = (json["error"] as? [String: Any])?["message"] as? String { - throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: err]) - } - throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)"]) + if useStream { + // Insert a placeholder assistant message for streaming + await MainActor.run { + state.chatHistory.append(ChatMessage(role: .assistant, content: "")) + state.stateOverride = .thinking } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let choices = json["choices"] as? [[String: Any]], - let message = choices.first?["message"] as? [String: Any], - let content = message["content"] as? String else { - throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unexpected response format"]) + do { + let (bytes, response) = try await URLSession.shared.bytes(for: req) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + var errBody = "" + for try await byte in bytes { errBody.append(Character(UnicodeScalar(byte))) } + conversationMessages.removeLast() + if let data = errBody.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let err = (json["error"] as? [String: Any])?["message"] as? String { + await showError(err, state: state) + } else { + await showError("HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)", state: state) + } + await MainActor.run { + state.chatHistory.removeLast() + state.stateOverride = nil + } + return + } + var accumulated = "" + for try await line in bytes.lines { + guard let delta = LocalChat.parseSSEDelta(line) else { continue } + accumulated += delta + let filtered = LocalChat.filterThinkingBlocks(accumulated) + await MainActor.run { + if let idx = state.chatHistory.indices.last { + state.chatHistory[idx].content = filtered + } + } + } + let final = LocalChat.filterThinkingBlocks(accumulated) + .trimmingCharacters(in: .whitespacesAndNewlines) + conversationMessages.append(["role": "assistant", "content": final]) + await MainActor.run { + if let idx = state.chatHistory.indices.last { + state.chatHistory[idx].content = final + } + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } + } catch { + conversationMessages.removeLast() + await MainActor.run { + state.chatHistory.removeLast() + state.stateOverride = nil + } + await showError(error.localizedDescription, state: state) } - let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) - conversationMessages.append(["role": "assistant", "content": trimmed]) - await MainActor.run { - state.chatHistory.append(ChatMessage(role: .assistant, content: trimmed)) - state.stateOverride = nil - state.view = .prompt - NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } else { + // Non-streaming (Google, OpenAI) + do { + let (data, response) = try await URLSession.shared.data(for: req) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let err = (json["error"] as? [String: Any])?["message"] as? String { + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: err]) + } + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)"]) + } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let content = message["content"] as? String else { + throw NSError(domain: "ChatAPI", code: 0, userInfo: [NSLocalizedDescriptionKey: "Unexpected response format"]) + } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + conversationMessages.append(["role": "assistant", "content": trimmed]) + await MainActor.run { + state.chatHistory.append(ChatMessage(role: .assistant, content: trimmed)) + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } + } catch { + conversationMessages.removeLast() + await showError(error.localizedDescription, state: state) } - } catch { - conversationMessages.removeLast() - await showError(error.localizedDescription, state: state) } } diff --git a/NotchBuddy/Sources/App/IslandTypes.swift b/NotchBuddy/Sources/App/IslandTypes.swift index 109a2457c..356a0681f 100644 --- a/NotchBuddy/Sources/App/IslandTypes.swift +++ b/NotchBuddy/Sources/App/IslandTypes.swift @@ -73,12 +73,16 @@ enum ChatProvider: String, CaseIterable, Codable { case anthropic = "anthropic" case google = "google" case openai = "openai" + case ollama = "ollama" + case lmstudio = "lmstudio" var displayName: String { switch self { case .anthropic: "Anthropic" case .google: "Google" case .openai: "OpenAI" + case .ollama: "Ollama" + case .lmstudio: "LM Studio" } } @@ -87,6 +91,8 @@ enum ChatProvider: String, CaseIterable, Codable { case .anthropic: "#E07950" case .google: "#4285F4" case .openai: "#10A37F" + case .ollama: "#FACC15" + case .lmstudio: "#A3E635" } } @@ -95,6 +101,8 @@ enum ChatProvider: String, CaseIterable, Codable { case .anthropic: "claude-sonnet-4-6" case .google: "gemini-2.0-flash" case .openai: "gpt-4o" + case .ollama: "llama3.2" + case .lmstudio: "local-model" } } @@ -103,6 +111,33 @@ enum ChatProvider: String, CaseIterable, Codable { case .anthropic: "anthropic-api-key" case .google: "google-api-key" case .openai: "openai-api-key" + case .ollama: "" + case .lmstudio: "" + } + } + + var isLocal: Bool { + self == .ollama || self == .lmstudio + } + + var pillID: String { + switch self { + case .anthropic: "ai_anthropic" + case .google: "ai_google" + case .openai: "ai_openai" + case .ollama: "ai_ollama" + case .lmstudio: "ai_lmstudio" + } + } + + init?(pillID: String) { + switch pillID { + case "ai_anthropic": self = .anthropic + case "ai_google": self = .google + case "ai_openai": self = .openai + case "ai_ollama": self = .ollama + case "ai_lmstudio": self = .lmstudio + default: return nil } } } diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index d8ea52a11..a00d782f7 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -175,6 +175,10 @@ struct OverviewView: View { switchChatProvider(.google) case "ai_openai": switchChatProvider(.openai) + case "ai_ollama": + switchChatProvider(.ollama) + case "ai_lmstudio": + switchChatProvider(.lmstudio) case "integration_music": #if !APPSTORE MusicController.shared.openMusic() @@ -893,7 +897,10 @@ struct ModelPickerView: View { VStack(alignment: .leading, spacing: 12) { // Provider chips HStack(spacing: 6) { - ForEach(ChatProvider.allCases, id: \.self) { provider in + ForEach(ChatProvider.allCases.filter { p in + !p.isLocal || (p == .ollama && !AppState.shared.ollamaServerURL.isEmpty) + || (p == .lmstudio && !AppState.shared.lmstudioServerURL.isEmpty) + }, id: \.self) { provider in Button { guard provider != state.chatProvider else { return } withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { @@ -964,6 +971,8 @@ struct ModelPickerView: View { case .anthropic: state.claudeModel = model.id case .google: state.googleChatModel = model.id case .openai: state.openAIChatModel = model.id + case .ollama: state.ollamaChatModel = model.id + case .lmstudio: state.lmstudioChatModel = model.id } isPresented = false SoundEngine.shared.play("blip") @@ -1012,11 +1021,7 @@ struct ChatBubble: View { .background(Color.white.opacity(0.13)) .clipShape(RoundedRectangle(cornerRadius: 12)) } else { - Text(message.content) - .font(.system(size: 12.5)) - .foregroundColor(Color(hex: "#B0B5BE")) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) + ChatMarkdownView(markdown: message.content) Spacer(minLength: 8) } } @@ -1193,6 +1198,8 @@ struct IntegrationCardView: View { case "ai_anthropic": return KeychainStore.shared.get("anthropic-api-key") != nil case "ai_google": return KeychainStore.shared.get("google-api-key") != nil case "ai_openai": return KeychainStore.shared.get("openai-api-key") != nil + case "ai_ollama": return !AppState.shared.ollamaServerURL.isEmpty + case "ai_lmstudio": return !AppState.shared.lmstudioServerURL.isEmpty case "integration_resend": return KeychainStore.shared.get("resend-api-key") != nil case "integration_n8n": return KeychainStore.shared.get("n8n-api-key") != nil case "integration_vercel": return KeychainStore.shared.get("vercel-token") != nil @@ -1303,14 +1310,21 @@ struct IntegrationCardView: View { : nil if let err = svcErr { return err } let isHooks = task.id == "agent_gemini" || task.id == "agent_antigravity" - let isAI = task.id == "ai_anthropic" || task.id == "ai_google" || task.id == "ai_openai" + let isAI = ChatProvider(pillID: task.id) != nil if isConfigured { if isHooks { return "Hooks installed" } if isAI { - let model = task.id == "ai_anthropic" ? appState.claudeModel - : task.id == "ai_google" ? appState.googleChatModel - : appState.openAIChatModel - return "Key configured · \(model)" + let model: String + switch task.id { + case "ai_anthropic": model = appState.claudeModel + case "ai_google": model = appState.googleChatModel + case "ai_openai": model = appState.openAIChatModel + case "ai_ollama": model = appState.ollamaChatModel + case "ai_lmstudio": model = appState.lmstudioChatModel + default: model = "" + } + let prefix = task.id == "ai_ollama" || task.id == "ai_lmstudio" ? "Server configured" : "Key configured" + return "\(prefix) · \(model)" } return "Connected · loading…" } else { @@ -1451,10 +1465,8 @@ struct IntegrationCardView: View { .buttonStyle(.plain) } #endif - } else if task.id == "ai_anthropic" || task.id == "ai_google" || task.id == "ai_openai" { + } else if let provider = ChatProvider(pillID: task.id) { if isConfigured { - let provider: ChatProvider = task.id == "ai_anthropic" ? .anthropic - : task.id == "ai_google" ? .google : .openai Button("Chat with \(task.name)") { switchChatProvider(provider) } diff --git a/NotchBuddy/Sources/App/LocalChat.swift b/NotchBuddy/Sources/App/LocalChat.swift new file mode 100644 index 000000000..88d5f3894 --- /dev/null +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -0,0 +1,40 @@ +import Foundation + +// MARK: - Local model helpers (Foundation only — importable by both app and test target) + +enum LocalChat { + + /// Removes trailing slashes and known sub-paths (e.g. /api, /v1) that users + /// sometimes copy from documentation. Returns a clean base URL string. + static func normaliseURL(_ raw: String) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + while s.hasSuffix("/") { s = String(s.dropLast()) } + for suffix in ["/api", "/v1"] { + if s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } + } + return s + } + + /// Parses `delta.content` from a single SSE event line in the OpenAI streaming format. + /// Returns nil for non-data lines, heartbeats, `[DONE]`, or absent content. + static func parseSSEDelta(_ line: String) -> String? { + guard line.hasPrefix("data: ") else { return nil } + let payload = String(line.dropFirst(6)) + guard payload != "[DONE]" else { return nil } + guard let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let delta = choices.first?["delta"] as? [String: Any], + let content = delta["content"] as? String else { return nil } + return content + } + + /// Strips `…` blocks (used by reasoning models like DeepSeek-R1). + static func filterThinkingBlocks(_ text: String) -> String { + let pattern = "[\\s\\S]*?" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } + let range = NSRange(text.startIndex..., in: text) + return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/NotchBuddy/Sources/App/PillCatalog.swift b/NotchBuddy/Sources/App/PillCatalog.swift index bf4c9c8a8..530b5ecfa 100644 --- a/NotchBuddy/Sources/App/PillCatalog.swift +++ b/NotchBuddy/Sources/App/PillCatalog.swift @@ -66,6 +66,10 @@ enum PillCatalog { category: .ai, subtitle: "Chat", source: .n8n), .init(id: "ai_openai", name: "OpenAI", color: ChatProvider.openai.accentHex, category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_ollama", name: "Ollama", color: ChatProvider.ollama.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), + .init(id: "ai_lmstudio", name: "LM Studio", color: ChatProvider.lmstudio.accentHex, + category: .ai, subtitle: "Chat", source: .n8n), // ── Services ───────────────────────────────────────────────────────── .init(id: "integration_resend", name: "Resend", color: "#22C55E", category: .service, subtitle: "Integration", source: .n8n), diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 8f243f9cc..66f867554 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -52,6 +52,8 @@ struct SettingsView: View { // Multi-provider chat keys @State private var googleKey: String = KeychainStore.shared.get("google-api-key") ?? "" @State private var openAIKey: String = KeychainStore.shared.get("openai-api-key") ?? "" + @State private var ollamaURL: String = AppState.shared.ollamaServerURL + @State private var lmstudioURL: String = AppState.shared.lmstudioServerURL // Integration keys @State private var resendKey: String = KeychainStore.shared.get("resend-api-key") ?? "" @@ -551,6 +553,49 @@ struct SettingsView: View { } .padding(.vertical, 4) } + + GroupBox("Local models") { + VStack(alignment: .leading, spacing: 12) { + Text("Connect to a local model server. No API key needed — server must be running on your Mac.") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8) + Text("Ollama").font(.system(size: 12, weight: .semibold)) + } + TextField("Server URL (http://localhost:11434)", text: $ollamaURL) + .textFieldStyle(.roundedBorder) + Button("Save") { + let url = LocalChat.normaliseURL(ollamaURL) + state.ollamaServerURL = url + ollamaURL = url + state.fetchedProviderModels[.ollama] = nil + state.providerModelFetchError[.ollama] = nil + statusMessage = "✓ Ollama URL saved." + } + .buttonStyle(.borderedProminent) + + Divider() + + HStack(spacing: 8) { + Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8) + Text("LM Studio").font(.system(size: 12, weight: .semibold)) + } + TextField("Server URL (http://localhost:1234)", text: $lmstudioURL) + .textFieldStyle(.roundedBorder) + Button("Save") { + let url = LocalChat.normaliseURL(lmstudioURL) + state.lmstudioServerURL = url + lmstudioURL = url + state.fetchedProviderModels[.lmstudio] = nil + state.providerModelFetchError[.lmstudio] = nil + statusMessage = "✓ LM Studio URL saved." + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 4) + } } // MARK: - Integrations section diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index ed1577555..d03d3da73 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -69,6 +69,8 @@ targets: NSSupportsAutomaticGraphicsSwitching: YES NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique." NSAccessibilityUsageDescription: "Pour lire le titre de la fenêtre active et l'attacher comme contexte." + NSAppTransportSecurity: + NSAllowsLocalNetworking: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.NotchBuddy @@ -103,6 +105,8 @@ targets: NSSupportsAutomaticGraphicsSwitching: YES LSApplicationCategoryType: public.app-category.developer-tools ITSAppUsesNonExemptEncryption: NO + NSAppTransportSecurity: + NSAllowsLocalNetworking: true settings: base: PRODUCT_BUNDLE_IDENTIFIER: fr.louisraille.Coucou From 4507a2e522ccca6744dc226bfa36bc1b63851bf3 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Fri, 2 Oct 2026 23:07:34 +0200 Subject: [PATCH 2/6] feat: Markdown rendering in chat assistant responses - Add ChatMarkdown block parser (Foundation only) and ChatMarkdownView renderer - ChatBubble uses ChatMarkdownView for assistant messages - Update system prompt to allow markdown formatting - Add ChatMarkdown/LocalChat test suite with compile-and-run script - Add test-chat-parsing step to CI build workflow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 3 + NotchBuddy/Sources/App/ChatMarkdown.swift | 95 ++++++++++++++++ NotchBuddy/Sources/App/ChatMarkdownView.swift | 101 ++++++++++++++++++ scripts/test-chat-parsing.sh | 14 +++ tests/ChatParsingTests.swift | 82 ++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 NotchBuddy/Sources/App/ChatMarkdown.swift create mode 100644 NotchBuddy/Sources/App/ChatMarkdownView.swift create mode 100755 scripts/test-chat-parsing.sh create mode 100644 tests/ChatParsingTests.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 07e363f65..48185b782 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,9 @@ jobs: - name: Test link filter run: bash scripts/test-safe-links.sh + - name: Test chat parsing + run: bash scripts/test-chat-parsing.sh + - name: Build run: | xcodebuild \ diff --git a/NotchBuddy/Sources/App/ChatMarkdown.swift b/NotchBuddy/Sources/App/ChatMarkdown.swift new file mode 100644 index 000000000..6c592412d --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdown.swift @@ -0,0 +1,95 @@ +import Foundation + +// MARK: - Markdown block types + +enum MDBlock { + case heading(level: Int, text: String) + case paragraph(text: String) + case codeBlock(lang: String, code: String) + case listItem(text: String) + case rule +} + +// MARK: - Parser (Foundation only — testable without SwiftUI) + +enum ChatMarkdown { + + /// Splits a markdown string into top-level blocks. + static func parse(_ input: String) -> [MDBlock] { + var blocks: [MDBlock] = [] + let lines = input.components(separatedBy: "\n") + var i = 0 + while i < lines.count { + let line = lines[i] + + // Fenced code block + if line.hasPrefix("```") { + let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var code: [String] = [] + i += 1 + while i < lines.count && !lines[i].hasPrefix("```") { + code.append(lines[i]) + i += 1 + } + blocks.append(.codeBlock(lang: lang, code: code.joined(separator: "\n"))) + i += 1 + continue + } + + // ATX heading + if line.hasPrefix("#") { + var level = 0 + var rest = line + while rest.hasPrefix("#") { level += 1; rest = String(rest.dropFirst()) } + let text = rest.trimmingCharacters(in: .whitespaces) + if !text.isEmpty { blocks.append(.heading(level: min(level, 6), text: text)) } + i += 1 + continue + } + + // Horizontal rule + let stripped = line.trimmingCharacters(in: .whitespaces) + if stripped == "---" || stripped == "***" || stripped == "___" { + blocks.append(.rule) + i += 1 + continue + } + + // List item (-, *, +, or numbered) + if stripped.hasPrefix("- ") || stripped.hasPrefix("* ") || stripped.hasPrefix("+ ") { + let text = String(stripped.dropFirst(2)) + blocks.append(.listItem(text: text)) + i += 1 + continue + } + if let colonRange = stripped.range(of: "^\\d+\\.\\s+", options: .regularExpression) { + let text = String(stripped[colonRange.upperBound...]) + blocks.append(.listItem(text: text)) + i += 1 + continue + } + + // Blank line — skip + if stripped.isEmpty { + i += 1 + continue + } + + // Paragraph: accumulate consecutive non-special lines + var paragraphLines: [String] = [line] + i += 1 + while i < lines.count { + let next = lines[i] + let nextStripped = next.trimmingCharacters(in: .whitespaces) + if nextStripped.isEmpty { break } + if next.hasPrefix("#") || next.hasPrefix("```") { break } + if nextStripped.hasPrefix("- ") || nextStripped.hasPrefix("* ") || nextStripped.hasPrefix("+ ") { break } + if nextStripped == "---" || nextStripped == "***" || nextStripped == "___" { break } + paragraphLines.append(next) + i += 1 + } + blocks.append(.paragraph(text: paragraphLines.joined(separator: "\n"))) + } + return blocks + } +} diff --git a/NotchBuddy/Sources/App/ChatMarkdownView.swift b/NotchBuddy/Sources/App/ChatMarkdownView.swift new file mode 100644 index 000000000..5ccd19aa7 --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdownView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +// MARK: - Markdown renderer for chat assistant messages + +struct ChatMarkdownView: View { + let markdown: String + + var body: some View { + let blocks = ChatMarkdown.parse(markdown) + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + blockView(block) + } + } + } + + @ViewBuilder + private func blockView(_ block: MDBlock) -> some View { + switch block { + case .heading(let level, let text): + Text(inlineAttributed(text)) + .font(.system(size: level <= 2 ? 14 : 13, weight: level <= 2 ? .bold : .semibold)) + .foregroundColor(Color(hex: "#F1F2F4")) + .fixedSize(horizontal: false, vertical: true) + + case .paragraph(let text): + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#B0B5BE")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + + case .codeBlock(_, let code): + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(code) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(hex: "#C8CDD4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + CopyButton(text: code) + } + .padding(10) + } + .background(Color(hex: "#0D0E12")) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.08), lineWidth: 1)) + + case .listItem(let text): + HStack(alignment: .top, spacing: 6) { + Text("•") + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#6B7079")) + .padding(.top, 0) + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#B0B5BE")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + + case .rule: + Divider().opacity(0.3) + } + } + + /// Converts inline markdown (bold, italic, code) to AttributedString. + private func inlineAttributed(_ text: String) -> AttributedString { + var options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace) + options.failurePolicy = .returnPartiallyParsedIfPossible + return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text) + } +} + +// MARK: - Copy button for code blocks + +private struct CopyButton: View { + let text: String + @State private var copied = false + + var body: some View { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + withAnimation { copied = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { copied = false } + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(copied ? Color(hex: "#22C55E") : Color(hex: "#6B7079")) + } + .buttonStyle(.plain) + .frame(width: 22, height: 22) + .background(Color.white.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/scripts/test-chat-parsing.sh b/scripts/test-chat-parsing.sh new file mode 100755 index 000000000..68a74a014 --- /dev/null +++ b/scripts/test-chat-parsing.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")" +trap 'rm -rf "$TEST_DIR"' EXIT + +swiftc \ + NotchBuddy/Sources/App/LocalChat.swift \ + NotchBuddy/Sources/App/ChatMarkdown.swift \ + tests/ChatParsingTests.swift \ + -o "$TEST_DIR/chat-parsing-tests" + +"$TEST_DIR/chat-parsing-tests" diff --git a/tests/ChatParsingTests.swift b/tests/ChatParsingTests.swift new file mode 100644 index 000000000..b7b3c89ec --- /dev/null +++ b/tests/ChatParsingTests.swift @@ -0,0 +1,82 @@ +import Foundation + +// MARK: - Tests for LocalChat and ChatMarkdown + +@main +enum ChatParsingTests { + static var failures = 0 + + static func check(_ label: String, _ got: String, _ expected: String) { + if got == expected { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + print(" got: \(got.debugDescription)") + print(" expected: \(expected.debugDescription)") + failures += 1 + } + } + + static func checkTrue(_ label: String, _ value: Bool) { + if value { + print(" ✓ \(label)") + } else { + print(" ✗ \(label)") + failures += 1 + } + } + + static func main() { + // MARK: - LocalChat.normaliseURL + + print("LocalChat.normaliseURL") + check("strips trailing slash", LocalChat.normaliseURL("http://localhost:11434/"), "http://localhost:11434") + check("strips /api suffix", LocalChat.normaliseURL("http://localhost:11434/api"), "http://localhost:11434") + check("strips /v1 suffix", LocalChat.normaliseURL("http://localhost:1234/v1"), "http://localhost:1234") + check("no-op clean URL", LocalChat.normaliseURL("http://localhost:11434"), "http://localhost:11434") + check("trims whitespace", LocalChat.normaliseURL(" http://localhost:11434 "), "http://localhost:11434") + + // MARK: - LocalChat.parseSSEDelta + + print("LocalChat.parseSSEDelta") + let sseData = #"data: {"id":"1","choices":[{"delta":{"content":"hello"}}]}"# + check("parses delta", LocalChat.parseSSEDelta(sseData) ?? "", "hello") + checkTrue("ignores [DONE]", LocalChat.parseSSEDelta("data: [DONE]") == nil) + checkTrue("ignores non-data", LocalChat.parseSSEDelta(": heartbeat") == nil) + checkTrue("ignores empty content", LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{}}]}"#) == nil) + + // MARK: - LocalChat.filterThinkingBlocks + + print("LocalChat.filterThinkingBlocks") + check("removes think block", LocalChat.filterThinkingBlocks("internalanswer"), "answer") + check("no-op without think block", LocalChat.filterThinkingBlocks("hello"), "hello") + check("multiline think block", LocalChat.filterThinkingBlocks("\nstep1\nstep2\nresult"), "result") + + // MARK: - ChatMarkdown.parse + + print("ChatMarkdown.parse") + let blocks = ChatMarkdown.parse("## Hello\n\nThis is a paragraph.\n\n- item 1\n- item 2\n\n```swift\nlet x = 1\n```") + checkTrue("heading count", blocks.filter { if case .heading = $0 { return true }; return false }.count == 1) + checkTrue("paragraph count", blocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1) + checkTrue("list item count", blocks.filter { if case .listItem = $0 { return true }; return false }.count == 2) + checkTrue("code block count", blocks.filter { if case .codeBlock = $0 { return true }; return false }.count == 1) + + // Check heading level + if case .heading(let level, let text) = blocks.first(where: { if case .heading = $0 { return true }; return false })! { + checkTrue("heading level 2", level == 2) + checkTrue("heading text", text == "Hello") + } else { + print(" ✗ first heading not found"); failures += 1 + } + + // MARK: - Result + + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} From bc07ee4f6b52a68ae7b5f988c3fdd1564ce87672 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Fri, 2 Oct 2026 23:24:54 +0200 Subject: [PATCH 3/6] refactor: move local model networking into LocalChat, add fake server e2e tests - Add LocalChatError typed enum (serverUnreachable, modelNotFound, serverError) - Add LocalChat.fetchModels with embedding model filter (nomic-embed, bge, etc.) - Add LocalChat.streamChat: full SSE streaming with typed error handling - Add LocalChat.progressiveFilter for partial think-block suppression - ClaudeService delegates local streaming entirely to LocalChat.streamChat - Add tests/fake_local_llm.py: stdlib Python server with think blocks, null-content deltas, model filter, unknown model 404, and Markdown streaming - ChatParsingTests: unit tests + 4 end-to-end tests against fake server - test-chat-parsing.sh starts/stops fake server with port-file handoff Co-Authored-By: Claude Sonnet 4.6 --- NotchBuddy/Sources/App/AppState.swift | 2 +- NotchBuddy/Sources/App/ClaudeService.swift | 106 +++++-------- NotchBuddy/Sources/App/LocalChat.swift | 155 ++++++++++++++++++- scripts/test-chat-parsing.sh | 25 ++- tests/ChatParsingTests.swift | 172 ++++++++++++++++----- tests/fake_local_llm.py | 125 +++++++++++++++ 6 files changed, 470 insertions(+), 115 deletions(-) create mode 100644 tests/fake_local_llm.py diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 58e4904c0..c1386f2a6 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -108,7 +108,7 @@ final class AppState: ObservableObject { loadingProviderModels.insert(provider) providerModelFetchError.removeValue(forKey: provider) Task { - let models = await ClaudeService.fetchLocalModels(baseURL: normalised) + let models = await LocalChat.fetchModels(baseURL: normalised) loadingProviderModels.remove(provider) if models.isEmpty { providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 0dcbe67b0..35614ae5d 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -175,22 +175,6 @@ final class ClaudeService { .map { (id: $0.id, label: $0.id) } } - /// Fetches the model list from a local OpenAI-compatible server (Ollama, LM Studio). - /// Expects `GET /v1/models` with an `{"data":[{"id":"…"}]}` response. - static func fetchLocalModels(baseURL: String) async -> [(id: String, label: String)] { - guard let url = URL(string: "\(baseURL)/v1/models") else { return [] } - var req = URLRequest(url: url, timeoutInterval: 5) - req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") - guard let (data, response) = try? await URLSession.shared.data(for: req), - (response as? HTTPURLResponse)?.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let items = json["data"] as? [[String: Any]] else { return [] } - return items.compactMap { item -> (id: String, label: String)? in - guard let id = item["id"] as? String else { return nil } - return (id: id, label: id) - } - } - /// Chosen in Settings; falls back to the default when the field is left empty. private var model: String { let m = AppState.shared.claudeModel.trimmingCharacters(in: .whitespacesAndNewlines) @@ -353,58 +337,46 @@ final class ClaudeService { req.httpBody = try? JSONSerialization.data(withJSONObject: body) if useStream { - // Insert a placeholder assistant message for streaming - await MainActor.run { - state.chatHistory.append(ChatMessage(role: .assistant, content: "")) - state.stateOverride = .thinking - } + // Placeholder bubble for streaming + state.chatHistory.append(ChatMessage(role: .assistant, content: "")) + state.stateOverride = .thinking + let histIdx = state.chatHistory.indices.last! + let modelCopy = state.activeChatModel + let streamBody: [String: Any] = [ + "model": modelCopy, + "messages": msgs, + "stream": true, + "max_tokens": 4096, + ] + let encodedBody = (try? JSONSerialization.data(withJSONObject: streamBody)) ?? Data() do { - let (bytes, response) = try await URLSession.shared.bytes(for: req) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - var errBody = "" - for try await byte in bytes { errBody.append(Character(UnicodeScalar(byte))) } - conversationMessages.removeLast() - if let data = errBody.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let err = (json["error"] as? [String: Any])?["message"] as? String { - await showError(err, state: state) - } else { - await showError("HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)", state: state) - } - await MainActor.run { - state.chatHistory.removeLast() - state.stateOverride = nil - } - return - } - var accumulated = "" - for try await line in bytes.lines { - guard let delta = LocalChat.parseSSEDelta(line) else { continue } - accumulated += delta - let filtered = LocalChat.filterThinkingBlocks(accumulated) - await MainActor.run { - if let idx = state.chatHistory.indices.last { - state.chatHistory[idx].content = filtered - } - } + let final = try await LocalChat.streamChat( + baseURL: baseURL, + encodedBody: encodedBody, + model: modelCopy + ) { [state] visible in + state.chatHistory[histIdx].content = visible } - let final = LocalChat.filterThinkingBlocks(accumulated) - .trimmingCharacters(in: .whitespacesAndNewlines) conversationMessages.append(["role": "assistant", "content": final]) - await MainActor.run { - if let idx = state.chatHistory.indices.last { - state.chatHistory[idx].content = final - } - state.stateOverride = nil - state.view = .prompt - NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + state.chatHistory[histIdx].content = final + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) + } catch let e as LocalChatError { + conversationMessages.removeLast() + state.chatHistory.removeLast() + state.stateOverride = nil + let msg: String + switch e { + case .serverUnreachable: msg = "Cannot reach the local server. Is it running?" + case .modelNotFound(let m): msg = "Model '\(m)' is not installed. Run `ollama pull \(m)` or pick another model." + case .serverError(let s): msg = s } + await showError(msg, state: state) } catch { conversationMessages.removeLast() - await MainActor.run { - state.chatHistory.removeLast() - state.stateOverride = nil - } + state.chatHistory.removeLast() + state.stateOverride = nil await showError(error.localizedDescription, state: state) } } else { @@ -426,12 +398,10 @@ final class ClaudeService { } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) conversationMessages.append(["role": "assistant", "content": trimmed]) - await MainActor.run { - state.chatHistory.append(ChatMessage(role: .assistant, content: trimmed)) - state.stateOverride = nil - state.view = .prompt - NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) - } + state.chatHistory.append(ChatMessage(role: .assistant, content: trimmed)) + state.stateOverride = nil + state.view = .prompt + NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) } catch { conversationMessages.removeLast() await showError(error.localizedDescription, state: state) diff --git a/NotchBuddy/Sources/App/LocalChat.swift b/NotchBuddy/Sources/App/LocalChat.swift index 88d5f3894..a8a467006 100644 --- a/NotchBuddy/Sources/App/LocalChat.swift +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -1,11 +1,31 @@ import Foundation -// MARK: - Local model helpers (Foundation only — importable by both app and test target) +// MARK: - Typed error for local model calls + +enum LocalChatError: Error { + /// The server is not reachable (wrong URL, not running, network error). + case serverUnreachable(String) + /// The model was requested but is not installed on the server. + case modelNotFound(String) + /// The server replied with an error message. + case serverError(String) + + var localizedDescription: String { + switch self { + case .serverUnreachable(let url): return "Cannot reach \(url). Is the server running?" + case .modelNotFound(let model): return "Model '\(model)' is not installed." + case .serverError(let msg): return msg + } + } +} + +// MARK: - Local model helpers (Foundation only) enum LocalChat { - /// Removes trailing slashes and known sub-paths (e.g. /api, /v1) that users - /// sometimes copy from documentation. Returns a clean base URL string. + // MARK: URL normalisation + + /// Removes trailing slashes and common documentation sub-paths (/api, /v1). static func normaliseURL(_ raw: String) -> String { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) while s.hasSuffix("/") { s = String(s.dropLast()) } @@ -15,8 +35,10 @@ enum LocalChat { return s } - /// Parses `delta.content` from a single SSE event line in the OpenAI streaming format. - /// Returns nil for non-data lines, heartbeats, `[DONE]`, or absent content. + // MARK: SSE parsing + + /// Parses `delta.content` from one OpenAI-SSE event line. + /// Returns nil for non-data lines, `[DONE]`, missing or null content. static func parseSSEDelta(_ line: String) -> String? { guard line.hasPrefix("data: ") else { return nil } let payload = String(line.dropFirst(6)) @@ -29,7 +51,9 @@ enum LocalChat { return content } - /// Strips `…` blocks (used by reasoning models like DeepSeek-R1). + // MARK: Think-block filtering + + /// Removes completed `…` blocks (reasoning models like DeepSeek-R1). static func filterThinkingBlocks(_ text: String) -> String { let pattern = "[\\s\\S]*?" guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } @@ -37,4 +61,123 @@ enum LocalChat { return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "") .trimmingCharacters(in: .whitespacesAndNewlines) } + + /// For streaming: removes closed think blocks AND hides content inside an + /// open (unclosed) `` block that hasn't received its closing tag yet. + static func progressiveFilter(_ text: String) -> String { + let cleaned = filterThinkingBlocks(text) + if let range = cleaned.range(of: "") { + return String(cleaned[.. [(id: String, label: String)] { + guard let url = URL(string: "\(baseURL)/v1/models") else { return [] } + var req = URLRequest(url: url, timeoutInterval: 5) + req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") + guard let (data, response) = try? await URLSession.shared.data(for: req), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { return [] } + let excluded = ["embed", "bge-", "all-minilm", "clip", "rerank"] + return items.compactMap { item -> (id: String, label: String)? in + guard let id = item["id"] as? String else { return nil } + let lower = id.lowercased() + guard !excluded.contains(where: { lower.contains($0) }) else { return nil } + return (id: id, label: id) + } + } + + // MARK: Streaming chat + + /// Sends a conversation to a local OpenAI-compatible server and streams the reply. + /// + /// - Parameters: + /// - baseURL: Base URL of the server (e.g. `http://localhost:11434`). + /// - model: Model identifier. + /// - messages: OpenAI-format message array (`[{"role":…,"content":…}]`). + /// - onToken: Called on the **main actor** with the current visible text after + /// each new token. Think-block content is hidden while the block is open. + /// - Returns: The complete final response with thinking blocks removed. + /// - Throws: `LocalChatError` + /// Convenience overload that accepts messages as `[[String: Any]]`. + /// Serialises the body on the caller's actor before crossing the isolation boundary. + static func streamChat( + baseURL: String, + model: String, + messages: [[String: Any]], + onToken: @MainActor @escaping (String) -> Void + ) async throws -> String { + let body: [String: Any] = [ + "model": model, + "messages": messages, + "stream": true, + "max_tokens": 4096, + ] + let bodyData = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + return try await streamChat(baseURL: baseURL, encodedBody: bodyData, model: model, onToken: onToken) + } + + static func streamChat( + baseURL: String, + encodedBody: Data, + model: String, + onToken: @MainActor @escaping (String) -> Void + ) async throws -> String { + guard let url = URL(string: "\(baseURL)/v1/chat/completions") else { + throw LocalChatError.serverUnreachable(baseURL) + } + + var req = URLRequest(url: url, timeoutInterval: 120) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") + req.httpBody = encodedBody + + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await URLSession.shared.bytes(for: req) + } catch { + throw LocalChatError.serverUnreachable(baseURL) + } + + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + + if status == 404 { + throw LocalChatError.modelNotFound(model) + } + + if status != 200 { + var raw = "" + do { for try await byte in bytes { raw.append(Character(UnicodeScalar(byte))); if raw.count > 4096 { break } } } catch {} + if let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let msg = ((json["error"] as? [String: Any])?["message"] as? String) { + throw LocalChatError.serverError(msg) + } + throw LocalChatError.serverError("HTTP \(status)") + } + + var accumulated = "" + do { + for try await line in bytes.lines { + guard let delta = parseSSEDelta(line) else { continue } + accumulated += delta + let visible = progressiveFilter(accumulated) + await MainActor.run { onToken(visible) } + } + } catch { + throw LocalChatError.serverUnreachable(baseURL) + } + + return filterThinkingBlocks(accumulated) + .trimmingCharacters(in: .whitespacesAndNewlines) + } } diff --git a/scripts/test-chat-parsing.sh b/scripts/test-chat-parsing.sh index 68a74a014..47244eeb4 100755 --- a/scripts/test-chat-parsing.sh +++ b/scripts/test-chat-parsing.sh @@ -3,12 +3,33 @@ set -euo pipefail cd "$(dirname "$0")/.." TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")" -trap 'rm -rf "$TEST_DIR"' EXIT +trap 'rm -rf "$TEST_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT +# ── Start fake LLM server ──────────────────────────────────────────────────── +PORT_FILE="$TEST_DIR/port.txt" +python3 tests/fake_local_llm.py "$PORT_FILE" & +SERVER_PID=$! + +# Wait up to 3 s for the server to write its port +for i in $(seq 1 30); do + [ -s "$PORT_FILE" ] && break + sleep 0.1 +done + +if [ ! -s "$PORT_FILE" ]; then + echo "ERROR: fake LLM server did not start in time" >&2 + exit 1 +fi + +PORT=$(cat "$PORT_FILE") +echo "Fake LLM server listening on port $PORT (PID $SERVER_PID)" + +# ── Compile test binary ────────────────────────────────────────────────────── swiftc \ NotchBuddy/Sources/App/LocalChat.swift \ NotchBuddy/Sources/App/ChatMarkdown.swift \ tests/ChatParsingTests.swift \ -o "$TEST_DIR/chat-parsing-tests" -"$TEST_DIR/chat-parsing-tests" +# ── Run tests ──────────────────────────────────────────────────────────────── +"$TEST_DIR/chat-parsing-tests" "$PORT" diff --git a/tests/ChatParsingTests.swift b/tests/ChatParsingTests.swift index b7b3c89ec..f7c328837 100644 --- a/tests/ChatParsingTests.swift +++ b/tests/ChatParsingTests.swift @@ -1,9 +1,10 @@ import Foundation -// MARK: - Tests for LocalChat and ChatMarkdown +// MARK: - Test harness @main enum ChatParsingTests { + static var failures = 0 static func check(_ label: String, _ got: String, _ expected: String) { @@ -18,59 +19,154 @@ enum ChatParsingTests { } static func checkTrue(_ label: String, _ value: Bool) { - if value { - print(" ✓ \(label)") - } else { - print(" ✗ \(label)") - failures += 1 - } + if value { print(" ✓ \(label)") } + else { print(" ✗ \(label)"); failures += 1 } } - static func main() { - // MARK: - LocalChat.normaliseURL + // MARK: - Entry point - print("LocalChat.normaliseURL") - check("strips trailing slash", LocalChat.normaliseURL("http://localhost:11434/"), "http://localhost:11434") - check("strips /api suffix", LocalChat.normaliseURL("http://localhost:11434/api"), "http://localhost:11434") - check("strips /v1 suffix", LocalChat.normaliseURL("http://localhost:1234/v1"), "http://localhost:1234") - check("no-op clean URL", LocalChat.normaliseURL("http://localhost:11434"), "http://localhost:11434") - check("trims whitespace", LocalChat.normaliseURL(" http://localhost:11434 "), "http://localhost:11434") + static func main() async { + + // ── Unit tests (no network) ────────────────────────────────────────── - // MARK: - LocalChat.parseSSEDelta + print("LocalChat.normaliseURL") + check("strips trailing slash", LocalChat.normaliseURL("http://localhost:11434/"), "http://localhost:11434") + check("strips /api suffix", LocalChat.normaliseURL("http://localhost:11434/api"), "http://localhost:11434") + check("strips /v1 suffix", LocalChat.normaliseURL("http://localhost:1234/v1"), "http://localhost:1234") + check("no-op clean URL", LocalChat.normaliseURL("http://localhost:11434"), "http://localhost:11434") + check("trims whitespace", LocalChat.normaliseURL(" http://localhost:11434 "), "http://localhost:11434") print("LocalChat.parseSSEDelta") let sseData = #"data: {"id":"1","choices":[{"delta":{"content":"hello"}}]}"# - check("parses delta", LocalChat.parseSSEDelta(sseData) ?? "", "hello") - checkTrue("ignores [DONE]", LocalChat.parseSSEDelta("data: [DONE]") == nil) - checkTrue("ignores non-data", LocalChat.parseSSEDelta(": heartbeat") == nil) - checkTrue("ignores empty content", LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{}}]}"#) == nil) - - // MARK: - LocalChat.filterThinkingBlocks + check("parses delta", LocalChat.parseSSEDelta(sseData) ?? "", "hello") + checkTrue("ignores [DONE]", LocalChat.parseSSEDelta("data: [DONE]") == nil) + checkTrue("ignores non-data", LocalChat.parseSSEDelta(": heartbeat") == nil) + checkTrue("ignores null content", + LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{"content":null}}]}"#) == nil) + checkTrue("ignores missing content", + LocalChat.parseSSEDelta(#"data: {"choices":[{"delta":{}}]}"#) == nil) print("LocalChat.filterThinkingBlocks") - check("removes think block", LocalChat.filterThinkingBlocks("internalanswer"), "answer") - check("no-op without think block", LocalChat.filterThinkingBlocks("hello"), "hello") - check("multiline think block", LocalChat.filterThinkingBlocks("\nstep1\nstep2\nresult"), "result") + check("removes closed block", + LocalChat.filterThinkingBlocks("internalanswer"), "answer") + check("no-op without block", + LocalChat.filterThinkingBlocks("hello"), "hello") + check("multiline block", + LocalChat.filterThinkingBlocks("\nstep1\nstep2\nresult"), "result") - // MARK: - ChatMarkdown.parse + print("LocalChat.progressiveFilter") + check("open block → hide", + LocalChat.progressiveFilter("\nstep one"), "") + check("open after text → keep prefix", + LocalChat.progressiveFilter("visiblehidden"), "visible") + check("closed block removed", + LocalChat.progressiveFilter("doneanswer"), "answer") print("ChatMarkdown.parse") - let blocks = ChatMarkdown.parse("## Hello\n\nThis is a paragraph.\n\n- item 1\n- item 2\n\n```swift\nlet x = 1\n```") - checkTrue("heading count", blocks.filter { if case .heading = $0 { return true }; return false }.count == 1) - checkTrue("paragraph count", blocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1) - checkTrue("list item count", blocks.filter { if case .listItem = $0 { return true }; return false }.count == 2) - checkTrue("code block count", blocks.filter { if case .codeBlock = $0 { return true }; return false }.count == 1) - - // Check heading level - if case .heading(let level, let text) = blocks.first(where: { if case .heading = $0 { return true }; return false })! { + let blocks = ChatMarkdown.parse( + "## Hello\n\nThis is a paragraph.\n\n- item 1\n- item 2\n\n```swift\nlet x = 1\n```") + checkTrue("heading count", blocks.filter { if case .heading = $0 { return true }; return false }.count == 1) + checkTrue("paragraph count", blocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1) + checkTrue("list item count", blocks.filter { if case .listItem = $0 { return true }; return false }.count == 2) + checkTrue("code block count", blocks.filter { if case .codeBlock = $0 { return true }; return false }.count == 1) + if case .heading(let level, let text) = + blocks.first(where: { if case .heading = $0 { return true }; return false })! { checkTrue("heading level 2", level == 2) - checkTrue("heading text", text == "Hello") - } else { - print(" ✗ first heading not found"); failures += 1 + checkTrue("heading text", text == "Hello") + } else { print(" ✗ heading not found"); failures += 1 } + + // ── End-to-end tests (fake server) ─────────────────────────────────── + + let baseURL: String = { + guard CommandLine.arguments.count > 1 else { return "" } + return "http://127.0.0.1:\(CommandLine.arguments[1])" + }() + + guard !baseURL.isEmpty else { + print("\n(Skipping end-to-end tests — no server port provided.)") + finish() + } + + print("LocalChat.fetchModels (fake server)") + + let models = await LocalChat.fetchModels(baseURL: baseURL) + checkTrue("models list non-empty", !models.isEmpty) + checkTrue("llama3.2 present", models.contains { $0.id == "llama3.2" }) + checkTrue("nomic-embed-text filtered", !models.contains { $0.id == "nomic-embed-text" }) + + print("LocalChat.streamChat — happy path (fake server)") + + var tokens: [String] = [] + do { + let response = try await LocalChat.streamChat( + baseURL: baseURL, + model: "llama3.2", + messages: [["role": "user", "content": "hello"]], + onToken: { visible in tokens.append(visible) } + ) + checkTrue("sent multiple tokens", tokens.count > 1) + checkTrue("intermediate tokens non-empty", tokens.contains { !$0.isEmpty }) + checkTrue("think block removed from response", !response.contains("")) + checkTrue("markdown heading in response", response.contains("## Answer")) + checkTrue("list item in response", response.contains("- **item 1**")) + checkTrue("code block in response", response.contains("```python")) + } catch { + print(" ✗ unexpected error: \(error)") + failures += 1 + } + + print("LocalChat.streamChat — unknown model (fake server)") + + do { + _ = try await LocalChat.streamChat( + baseURL: baseURL, + model: "unknown-model", + messages: [["role": "user", "content": "hello"]], + onToken: { _ in } + ) + print(" ✗ should have thrown for unknown model") + failures += 1 + } catch let e as LocalChatError { + if case .modelNotFound(let m) = e { + checkTrue("model name in error", m == "unknown-model") + } else { + print(" ✗ wrong error case: \(e)") + failures += 1 + } + } catch { + print(" ✗ unexpected error type: \(error)") + failures += 1 + } + + print("LocalChat.streamChat — unreachable server") + + do { + _ = try await LocalChat.streamChat( + baseURL: "http://127.0.0.1:1", // nothing on port 1 + model: "llama3.2", + messages: [["role": "user", "content": "hello"]], + onToken: { _ in } + ) + print(" ✗ should have thrown for unreachable server") + failures += 1 + } catch let e as LocalChatError { + if case .serverUnreachable = e { + print(" ✓ serverUnreachable error") + } else { + print(" ✗ wrong error case: \(e)") + failures += 1 + } + } catch { + print(" ✗ unexpected error type: \(error)") + failures += 1 } - // MARK: - Result + finish() + } + + // MARK: - Finish + private static func finish() -> Never { if failures == 0 { print("\nAll tests passed.") exit(0) diff --git a/tests/fake_local_llm.py b/tests/fake_local_llm.py new file mode 100644 index 000000000..d8292552f --- /dev/null +++ b/tests/fake_local_llm.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Minimal fake Ollama / LM Studio server for CI testing. + +Usage: python3 fake_local_llm.py + Writes the bound port to , then serves until killed. + +Routes: + GET /v1/models → two models (one embed, should be filtered out) + POST /v1/chat/completions → SSE stream with block, reasoning-only + deltas (content=null), and Markdown answer + POST /v1/chat/completions → 404 when model == "unknown-model" +""" + +import json +import sys +import socket +import time +from http.server import BaseHTTPRequestHandler, HTTPServer + +# Two models; "nomic-embed-text" must be filtered by the Swift client +MODELS_RESPONSE = { + "data": [ + {"id": "llama3.2", "object": "model"}, + {"id": "nomic-embed-text", "object": "model"}, + ] +} + +# SSE events that make up the streamed reply: +# - events 0-2: a block split over two chunks (should be hidden from UI) +# - event 3: a delta with reasoning_content only (content is null → ignored) +# - events 4-7: real Markdown answer +SSE_EVENTS = [ + {"choices": [{"delta": {"content": "\nstep "}}]}, + {"choices": [{"delta": {"content": "one\n"}}]}, + # reasoning-only delta: content key present but value is null + {"choices": [{"delta": {"content": None, "reasoning_content": "internal"}}]}, + {"choices": [{"delta": {"content": "## Answer\n\n"}}]}, + {"choices": [{"delta": {"content": "Here is the result:\n\n"}}]}, + {"choices": [{"delta": {"content": "- **item 1**\n"}}]}, + {"choices": [{"delta": {"content": "- item 2\n\n"}}]}, + {"choices": [{"delta": {"content": "```python\nprint('hello')\n```"}}]}, +] + +EXPECTED_RESPONSE = "## Answer\n\nHere is the result:\n\n- **item 1**\n- item 2\n\n```python\nprint('hello')\n```" + + +class FakeLLMHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass # suppress request logging + + def do_GET(self): + if self.path == "/v1/models": + body = json.dumps(MODELS_RESPONSE).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + req_body = json.loads(raw) + except Exception: + req_body = {} + + model = req_body.get("model", "") + + if self.path != "/v1/chat/completions": + self.send_response(404) + self.end_headers() + return + + # Unknown model → 404 with OpenAI-format error + if model == "unknown-model": + err = json.dumps({ + "error": { + "message": f"model '{model}' not found, try pulling it first", + "type": "not_found", + } + }).encode() + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(err))) + self.end_headers() + self.wfile.write(err) + return + + # SSE stream + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + + for event in SSE_EVENTS: + line = ("data: " + json.dumps(event) + "\n\n").encode() + self.wfile.write(line) + self.wfile.flush() + time.sleep(0.005) + + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +if __name__ == "__main__": + port = find_free_port() + server = HTTPServer(("127.0.0.1", port), FakeLLMHandler) + + # Write port to the file passed as argv[1] so the caller can read it + if len(sys.argv) > 1: + with open(sys.argv[1], "w") as f: + f.write(str(port)) + f.flush() + + server.serve_forever() From ca473e1c61c8f0125b498e2d841dec80497a23a3 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Fri, 2 Oct 2026 23:43:45 +0200 Subject: [PATCH 4/6] Local model connect flow, Markdown improvements, and docs update Co-Authored-By: Claude Sonnet 4.6 --- NotchBuddy/Sources/App/AppState.swift | 17 ++- NotchBuddy/Sources/App/ChatMarkdown.swift | 51 +++++--- NotchBuddy/Sources/App/ChatMarkdownView.swift | 52 +++++--- NotchBuddy/Sources/App/ClaudeService.swift | 55 ++++++--- .../Sources/App/IslandViewContent.swift | 105 ++++++++++++---- NotchBuddy/Sources/App/LocalChat.swift | 59 ++++++--- NotchBuddy/Sources/App/SettingsView.swift | 114 ++++++++++++++---- README.md | 4 +- docs/INTEGRATIONS.md | 27 +++++ docs/SPEC.md | 15 +++ docs/privacy.html | 2 + docs/support.html | 4 + tests/ChatParsingTests.swift | 29 +++++ 13 files changed, 417 insertions(+), 117 deletions(-) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index c1386f2a6..70fa530b2 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -75,10 +75,10 @@ final class AppState: ObservableObject { @Published var lmstudioChatModel: String = ChatProvider.lmstudio.defaultModel { didSet { UserDefaults.standard.set(lmstudioChatModel, forKey: "lmstudioChatModel") } } - @Published var ollamaServerURL: String = "http://localhost:11434" { + @Published var ollamaServerURL: String = "" { didSet { UserDefaults.standard.set(ollamaServerURL, forKey: "ollamaServerURL") } } - @Published var lmstudioServerURL: String = "http://localhost:1234" { + @Published var lmstudioServerURL: String = "" { didSet { UserDefaults.standard.set(lmstudioServerURL, forKey: "lmstudioServerURL") } } @@ -108,11 +108,14 @@ final class AppState: ObservableObject { loadingProviderModels.insert(provider) providerModelFetchError.removeValue(forKey: provider) Task { - let models = await LocalChat.fetchModels(baseURL: normalised) + let result = await LocalChat.fetchModelsResult(baseURL: normalised) loadingProviderModels.remove(provider) - if models.isEmpty { - providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" - } else { + switch result { + case .success(let models) where models.isEmpty: + providerModelFetchError[provider] = provider == .ollama + ? "No models yet. Download one in Ollama first." + : "No models yet. Download one in LM Studio first." + case .success(let models): fetchedProviderModels[provider] = models let current = provider == .ollama ? ollamaChatModel : lmstudioChatModel if !models.contains(where: { $0.id == current }) { @@ -120,6 +123,8 @@ final class AppState: ObservableObject { if provider == .ollama { ollamaChatModel = first } else { lmstudioChatModel = first } } + case .failure: + providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" } } return diff --git a/NotchBuddy/Sources/App/ChatMarkdown.swift b/NotchBuddy/Sources/App/ChatMarkdown.swift index 6c592412d..506f41f83 100644 --- a/NotchBuddy/Sources/App/ChatMarkdown.swift +++ b/NotchBuddy/Sources/App/ChatMarkdown.swift @@ -6,15 +6,16 @@ enum MDBlock { case heading(level: Int, text: String) case paragraph(text: String) case codeBlock(lang: String, code: String) - case listItem(text: String) + /// prefix: "•" for unordered, "1." / "2." etc for ordered; indent: nesting level (0-based) + case listItem(prefix: String, text: String, indent: Int) + case quote(text: String) case rule } -// MARK: - Parser (Foundation only — testable without SwiftUI) +// MARK: - Parser (Foundation only) enum ChatMarkdown { - /// Splits a markdown string into top-level blocks. static func parse(_ input: String) -> [MDBlock] { var blocks: [MDBlock] = [] let lines = input.components(separatedBy: "\n") @@ -36,15 +37,19 @@ enum ChatMarkdown { continue } - // ATX heading + // ATX heading — MUST have space after the #s if line.hasPrefix("#") { var level = 0 var rest = line while rest.hasPrefix("#") { level += 1; rest = String(rest.dropFirst()) } - let text = rest.trimmingCharacters(in: .whitespaces) - if !text.isEmpty { blocks.append(.heading(level: min(level, 6), text: text)) } - i += 1 - continue + // Require a space (or end of line) after the hashes + if rest.hasPrefix(" ") || rest.isEmpty { + let text = rest.trimmingCharacters(in: .whitespaces) + if !text.isEmpty { blocks.append(.heading(level: min(level, 6), text: text)) } + i += 1 + continue + } + // else fall through to paragraph } // Horizontal rule @@ -55,16 +60,30 @@ enum ChatMarkdown { continue } - // List item (-, *, +, or numbered) + // Blockquote + if stripped.hasPrefix("> ") || stripped == ">" { + let text = stripped.hasPrefix("> ") ? String(stripped.dropFirst(2)) : "" + blocks.append(.quote(text: text)) + i += 1 + continue + } + + // List item — detect indent level by leading spaces, then match marker + let leadingSpaces = line.prefix(while: { $0 == " " }).count + let indent = leadingSpaces / 2 if stripped.hasPrefix("- ") || stripped.hasPrefix("* ") || stripped.hasPrefix("+ ") { let text = String(stripped.dropFirst(2)) - blocks.append(.listItem(text: text)) + blocks.append(.listItem(prefix: "•", text: text, indent: indent)) i += 1 continue } - if let colonRange = stripped.range(of: "^\\d+\\.\\s+", options: .regularExpression) { - let text = String(stripped[colonRange.upperBound...]) - blocks.append(.listItem(text: text)) + // Ordered list item + if let range = stripped.range(of: "^(\\d+)\\.\\s+", options: .regularExpression) { + let number = String(stripped[.. ") || nextStripped == ">" { break } if nextStripped.hasPrefix("- ") || nextStripped.hasPrefix("* ") || nextStripped.hasPrefix("+ ") { break } if nextStripped == "---" || nextStripped == "***" || nextStripped == "___" { break } + // Break before ordered list items too + if nextStripped.range(of: "^\\d+\\.\\s+", options: .regularExpression) != nil { break } paragraphLines.append(next) i += 1 } diff --git a/NotchBuddy/Sources/App/ChatMarkdownView.swift b/NotchBuddy/Sources/App/ChatMarkdownView.swift index 5ccd19aa7..b8962a61a 100644 --- a/NotchBuddy/Sources/App/ChatMarkdownView.swift +++ b/NotchBuddy/Sources/App/ChatMarkdownView.swift @@ -6,12 +6,16 @@ struct ChatMarkdownView: View { let markdown: String var body: some View { - let blocks = ChatMarkdown.parse(markdown) VStack(alignment: .leading, spacing: 6) { - ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + ForEach(Array(ChatMarkdown.parse(markdown).enumerated()), id: \.offset) { _, block in blockView(block) } } + .environment(\.openURL, OpenURLAction { url in + guard let safe = safeWebURL(url.absoluteString) else { return .discarded } + NSWorkspace.shared.open(safe) + return .handled + }) } @ViewBuilder @@ -22,6 +26,7 @@ struct ChatMarkdownView: View { .font(.system(size: level <= 2 ? 14 : 13, weight: level <= 2 ? .bold : .semibold)) .foregroundColor(Color(hex: "#F1F2F4")) .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) case .paragraph(let text): Text(inlineAttributed(text)) @@ -31,41 +36,52 @@ struct ChatMarkdownView: View { .textSelection(.enabled) case .codeBlock(_, let code): - VStack(alignment: .leading, spacing: 0) { - HStack { - Text(code) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(Color(hex: "#C8CDD4")) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - CopyButton(text: code) - } - .padding(10) + HStack(alignment: .top) { + Text(code) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(Color(hex: "#C8CDD4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + CopyButton(text: code) } + .padding(10) .background(Color(hex: "#0D0E12")) .clipShape(RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.08), lineWidth: 1)) - case .listItem(let text): + case .listItem(let prefix, let text, let indent): HStack(alignment: .top, spacing: 6) { - Text("•") + Text(prefix) .font(.system(size: 12.5)) .foregroundColor(Color(hex: "#6B7079")) - .padding(.top, 0) + .frame(minWidth: prefix.count > 2 ? 20 : 10, alignment: .leading) Text(inlineAttributed(text)) .font(.system(size: 12.5)) .foregroundColor(Color(hex: "#B0B5BE")) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } + .padding(.leading, CGFloat(indent) * 12) + + case .quote(let text): + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(Color(hex: "#4B5563")) + .frame(width: 2) + .clipShape(Capsule()) + Text(inlineAttributed(text)) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#8A8F98")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } case .rule: Divider().opacity(0.3) } } - /// Converts inline markdown (bold, italic, code) to AttributedString. private func inlineAttributed(_ text: String) -> AttributedString { var options = AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace) @@ -74,7 +90,7 @@ struct ChatMarkdownView: View { } } -// MARK: - Copy button for code blocks +// MARK: - Copy button private struct CopyButton: View { let text: String diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 35614ae5d..222d115bf 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -206,7 +206,7 @@ final class ClaudeService { \(opening) \ You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \ Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ - Use markdown formatting where it helps clarity: **bold**, headings (##), bullet lists, and `code` spans. For code blocks use triple backticks. + Use light Markdown when it helps: short paragraphs, bullet lists, **bold**, `inline code` and fenced code blocks. Avoid tables and big headings: the chat window is small. """ } @@ -315,8 +315,22 @@ final class ClaudeService { var prefix = "Context — App: \(app), Window: \(title)" if let u = url { prefix += ", URL: \(u)" } userText = prefix + "\n\n" + query - case .file(let name, _): - userText = "File: \(name)\n\n" + query + case .file(let name, let fileURL): + if provider.isLocal, let fileURL = fileURL { + let ext = fileURL.pathExtension.lowercased() + let binaryExts = ["pdf", "jpg", "jpeg", "png", "gif", "webp"] + if !binaryExts.contains(ext), + let text = try? String(contentsOf: fileURL, encoding: .utf8), !text.isEmpty { + let truncated = text.count > 24_000 + ? String(text.prefix(24_000)) + "\n[truncated]" + : text + userText = "File: \(name)\n\n\(truncated)\n\n" + query + } else { + userText = "File: \(name)\n\n" + query + } + } else { + userText = "File: \(name)\n\n" + query + } } } msgs.append(["role": "user", "content": userText]) @@ -337,10 +351,11 @@ final class ClaudeService { req.httpBody = try? JSONSerialization.data(withJSONObject: body) if useStream { - // Placeholder bubble for streaming - state.chatHistory.append(ChatMessage(role: .assistant, content: "")) + // Add placeholder (hidden until first token via ChatBubble empty-content guard) + let placeholder = ChatMessage(role: .assistant, content: "") + let msgId = placeholder.id + state.chatHistory.append(placeholder) state.stateOverride = .thinking - let histIdx = state.chatHistory.indices.last! let modelCopy = state.activeChatModel let streamBody: [String: Any] = [ "model": modelCopy, @@ -354,28 +369,40 @@ final class ClaudeService { baseURL: baseURL, encodedBody: encodedBody, model: modelCopy - ) { [state] visible in - state.chatHistory[histIdx].content = visible + ) { [state, msgId] visible in + if !visible.isEmpty, state.stateOverride == .thinking { + state.stateOverride = nil // hide typing dots on first visible text + } + if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { + state.chatHistory[idx].content = visible + } } conversationMessages.append(["role": "assistant", "content": final]) - state.chatHistory[histIdx].content = final + if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) { + state.chatHistory[idx].content = final + } state.stateOverride = nil state.view = .prompt NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy) } catch let e as LocalChatError { conversationMessages.removeLast() - state.chatHistory.removeLast() + state.chatHistory.removeAll { $0.id == msgId } state.stateOverride = nil let msg: String switch e { - case .serverUnreachable: msg = "Cannot reach the local server. Is it running?" - case .modelNotFound(let m): msg = "Model '\(m)' is not installed. Run `ollama pull \(m)` or pick another model." - case .serverError(let s): msg = s + case .serverUnreachable: + msg = provider == .ollama + ? "Ollama isn't running. Open it, then ask again." + : "Start the local server in LM Studio, then ask again." + case .modelNotFound(let m): + msg = "\(m) isn't installed. Pick another model above the chat box." + case .serverError(let s): + msg = s } await showError(msg, state: state) } catch { conversationMessages.removeLast() - state.chatHistory.removeLast() + state.chatHistory.removeAll { $0.id == msgId } state.stateOverride = nil await showError(error.localizedDescription, state: state) } diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index a00d782f7..3345639d1 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -789,7 +789,11 @@ struct PromptView: View { } } .onChange(of: state.stateOverride) { _, v in - if v != nil { withAnimation { proxy.scrollTo("typing", anchor: .bottom) } } + if v != nil { + withAnimation { proxy.scrollTo("typing", anchor: .bottom) } + } else if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } } .onAppear { if let last = state.chatHistory.last { @@ -889,18 +893,48 @@ struct PromptView: View { // MARK: - Model / provider picker +/// Wrapping horizontal flow layout for provider chips. +private struct ProviderChipFlow: Layout { + var spacing: CGFloat = 6 + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let r = rows(maxW: proposal.replacingUnspecifiedDimensions().width, subviews: subviews) + return r.size + } + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let r = rows(maxW: bounds.width, subviews: subviews) + for (idx, pt) in r.placements.enumerated() { + subviews[idx].place(at: CGPoint(x: bounds.minX + pt.x, y: bounds.minY + pt.y), proposal: .unspecified) + } + } + private func rows(maxW: CGFloat, subviews: Subviews) -> (size: CGSize, placements: [(x: CGFloat, y: CGFloat)]) { + var x: CGFloat = 0, y: CGFloat = 0, lineH: CGFloat = 0, maxX: CGFloat = 0 + var pts: [(x: CGFloat, y: CGFloat)] = [] + for sv in subviews { + let sz = sv.sizeThatFits(.unspecified) + if x + sz.width > maxW, x > 0 { x = 0; y += lineH + spacing; lineH = 0 } + pts.append((x: x, y: y)) + x += sz.width + spacing + lineH = max(lineH, sz.height) + maxX = max(maxX, x - spacing) + } + return (CGSize(width: max(maxX, 0), height: y + lineH), pts) + } +} + struct ModelPickerView: View { @ObservedObject var state: AppState @Binding var isPresented: Bool var body: some View { VStack(alignment: .leading, spacing: 12) { - // Provider chips - HStack(spacing: 6) { - ForEach(ChatProvider.allCases.filter { p in - !p.isLocal || (p == .ollama && !AppState.shared.ollamaServerURL.isEmpty) - || (p == .lmstudio && !AppState.shared.lmstudioServerURL.isEmpty) - }, id: \.self) { provider in + // Provider chips — wrap; hide local providers when not connected and not already active + let visibleProviders = ChatProvider.allCases.filter { p in + if p == .ollama { return !AppState.shared.ollamaServerURL.isEmpty || state.chatProvider == .ollama } + if p == .lmstudio { return !AppState.shared.lmstudioServerURL.isEmpty || state.chatProvider == .lmstudio } + return true + } + ProviderChipFlow(spacing: 6) { + ForEach(visibleProviders, id: \.self) { provider in Button { guard provider != state.chatProvider else { return } withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) { @@ -940,8 +974,19 @@ struct ModelPickerView: View { } .padding(14) .background(Color(hex: "#16171B")) - .onAppear { state.fetchModelsIfNeeded(for: state.chatProvider) } + .onAppear { + // Force-refresh local providers every time the picker opens + if state.chatProvider.isLocal { + state.fetchedProviderModels[state.chatProvider] = nil + state.providerModelFetchError[state.chatProvider] = nil + } + state.fetchModelsIfNeeded(for: state.chatProvider) + } .onChange(of: state.chatProvider) { _, provider in + if provider.isLocal { + state.fetchedProviderModels[provider] = nil + state.providerModelFetchError[provider] = nil + } state.fetchModelsIfNeeded(for: provider) } } @@ -1009,20 +1054,22 @@ struct ChatBubble: View { let message: ChatMessage var body: some View { - HStack(alignment: .top) { - if message.role == .user { - Spacer(minLength: 32) - Text(message.content) - .font(.system(size: 12.5)) - .foregroundColor(Color(hex: "#F1F2F4")) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - .padding(.horizontal, 10).padding(.vertical, 6) - .background(Color.white.opacity(0.13)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } else { - ChatMarkdownView(markdown: message.content) - Spacer(minLength: 8) + if !message.content.isEmpty { + HStack(alignment: .top) { + if message.role == .user { + Spacer(minLength: 32) + Text(message.content) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#F1F2F4")) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .padding(.horizontal, 10).padding(.vertical, 6) + .background(Color.white.opacity(0.13)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + ChatMarkdownView(markdown: message.content) + Spacer(minLength: 8) + } } } } @@ -1314,21 +1361,27 @@ struct IntegrationCardView: View { if isConfigured { if isHooks { return "Hooks installed" } if isAI { + let provider = ChatProvider(pillID: task.id)! + if provider.isLocal { + let model = provider == .ollama ? appState.ollamaChatModel : appState.lmstudioChatModel + return "Connected · \(model)" + } let model: String switch task.id { case "ai_anthropic": model = appState.claudeModel case "ai_google": model = appState.googleChatModel case "ai_openai": model = appState.openAIChatModel - case "ai_ollama": model = appState.ollamaChatModel - case "ai_lmstudio": model = appState.lmstudioChatModel default: model = "" } - let prefix = task.id == "ai_ollama" || task.id == "ai_lmstudio" ? "Server configured" : "Key configured" - return "\(prefix) · \(model)" + return "Key configured · \(model)" } return "Connected · loading…" } else { if isHooks { return "Hooks not installed" } + if isAI { + let provider = ChatProvider(pillID: task.id)! + return provider.isLocal ? "Not connected" : "Key not configured" + } return "Key not configured" } } diff --git a/NotchBuddy/Sources/App/LocalChat.swift b/NotchBuddy/Sources/App/LocalChat.swift index a8a467006..0bb3e0ab2 100644 --- a/NotchBuddy/Sources/App/LocalChat.swift +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -75,25 +75,41 @@ enum LocalChat { // MARK: Model list - /// Fetches models from a local OpenAI-compatible server (`GET /v1/models`). - /// Filters out embedding and non-chat models (nomic-embed, bge, clip, rerank…). - static func fetchModels(baseURL: String) async -> [(id: String, label: String)] { - guard let url = URL(string: "\(baseURL)/v1/models") else { return [] } + /// Fetches models, returning `.success([])` when the server responds but has no chat models, + /// vs `.failure(.serverUnreachable)` when the server is not reachable. + static func fetchModelsResult(baseURL: String) async -> Result<[(id: String, label: String)], LocalChatError> { + guard let url = URL(string: "\(baseURL)/v1/models") else { + return .failure(.serverUnreachable(baseURL)) + } var req = URLRequest(url: url, timeoutInterval: 5) req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") - guard let (data, response) = try? await URLSession.shared.data(for: req), - (response as? HTTPURLResponse)?.statusCode == 200, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let items = json["data"] as? [[String: Any]] else { return [] } - let excluded = ["embed", "bge-", "all-minilm", "clip", "rerank"] - return items.compactMap { item -> (id: String, label: String)? in - guard let id = item["id"] as? String else { return nil } - let lower = id.lowercased() - guard !excluded.contains(where: { lower.contains($0) }) else { return nil } - return (id: id, label: id) + do { + let (data, response) = try await URLSession.shared.data(for: req) + guard let http = response as? HTTPURLResponse, http.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let items = json["data"] as? [[String: Any]] else { + return .failure(.serverUnreachable(baseURL)) + } + let excluded = ["embed", "bge-", "all-minilm", "clip", "rerank"] + let models = items.compactMap { item -> (id: String, label: String)? in + guard let id = item["id"] as? String else { return nil } + let lower = id.lowercased() + guard !excluded.contains(where: { lower.contains($0) }) else { return nil } + return (id: id, label: id) + } + return .success(models) + } catch { + return .failure(.serverUnreachable(baseURL)) } } + /// Fetches models from a local OpenAI-compatible server (`GET /v1/models`). + /// Filters out embedding and non-chat models (nomic-embed, bge, clip, rerank…). + static func fetchModels(baseURL: String) async -> [(id: String, label: String)] { + if case .success(let models) = await fetchModelsResult(baseURL: baseURL) { return models } + return [] + } + // MARK: Streaming chat /// Sends a conversation to a local OpenAI-compatible server and streams the reply. @@ -134,7 +150,7 @@ enum LocalChat { throw LocalChatError.serverUnreachable(baseURL) } - var req = URLRequest(url: url, timeoutInterval: 120) + var req = URLRequest(url: url, timeoutInterval: 300) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("Bearer ollama", forHTTPHeaderField: "Authorization") @@ -166,16 +182,25 @@ enum LocalChat { } var accumulated = "" + var lastUpdate = Date.distantPast + let minInterval: TimeInterval = 1.0 / 15.0 do { for try await line in bytes.lines { guard let delta = parseSSEDelta(line) else { continue } accumulated += delta - let visible = progressiveFilter(accumulated) - await MainActor.run { onToken(visible) } + let now = Date() + if now.timeIntervalSince(lastUpdate) >= minInterval { + lastUpdate = now + let visible = progressiveFilter(accumulated) + await MainActor.run { onToken(visible) } + } } } catch { throw LocalChatError.serverUnreachable(baseURL) } + // Always send the final filtered state + let visible = progressiveFilter(accumulated) + await MainActor.run { onToken(visible) } return filterThinkingBlocks(accumulated) .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 66f867554..af728cd1f 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -54,6 +54,8 @@ struct SettingsView: View { @State private var openAIKey: String = KeychainStore.shared.get("openai-api-key") ?? "" @State private var ollamaURL: String = AppState.shared.ollamaServerURL @State private var lmstudioURL: String = AppState.shared.lmstudioServerURL + @State private var connectingOllama: Bool = false + @State private var connectingLMStudio: Bool = false // Integration keys @State private var resendKey: String = KeychainStore.shared.get("resend-api-key") ?? "" @@ -556,43 +558,77 @@ struct SettingsView: View { GroupBox("Local models") { VStack(alignment: .leading, spacing: 12) { - Text("Connect to a local model server. No API key needed — server must be running on your Mac.") + Text("Connect to a local model server. No API key needed.") .font(.system(size: 12)) .foregroundColor(.secondary) + // ── Ollama ────────────────────────────────────────────────────── HStack(spacing: 8) { Circle().fill(Color(hex: "#FACC15")).frame(width: 8, height: 8) Text("Ollama").font(.system(size: 12, weight: .semibold)) + if !state.ollamaServerURL.isEmpty { + Text("Connected") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#22C55E")) + } } - TextField("Server URL (http://localhost:11434)", text: $ollamaURL) - .textFieldStyle(.roundedBorder) - Button("Save") { - let url = LocalChat.normaliseURL(ollamaURL) - state.ollamaServerURL = url - ollamaURL = url - state.fetchedProviderModels[.ollama] = nil - state.providerModelFetchError[.ollama] = nil - statusMessage = "✓ Ollama URL saved." + if state.ollamaServerURL.isEmpty { + TextField("http://127.0.0.1:11434", text: $ollamaURL) + .textFieldStyle(.roundedBorder) + Button(connectingOllama ? "Connecting…" : "Connect") { + Task { await connectLocal(provider: .ollama) } + } + .buttonStyle(.borderedProminent) + .disabled(connectingOllama) + } else { + Text(state.ollamaServerURL) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + Button("Disconnect") { + state.ollamaServerURL = "" + ollamaURL = "" + state.fetchedProviderModels[.ollama] = nil + state.providerModelFetchError[.ollama] = nil + if state.chatProvider == .ollama { state.chatProvider = .anthropic } + statusMessage = "Ollama disconnected." + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) Divider() + // ── LM Studio ─────────────────────────────────────────────────── HStack(spacing: 8) { Circle().fill(Color(hex: "#A3E635")).frame(width: 8, height: 8) Text("LM Studio").font(.system(size: 12, weight: .semibold)) + if !state.lmstudioServerURL.isEmpty { + Text("Connected") + .font(.system(size: 10)) + .foregroundColor(Color(hex: "#22C55E")) + } } - TextField("Server URL (http://localhost:1234)", text: $lmstudioURL) - .textFieldStyle(.roundedBorder) - Button("Save") { - let url = LocalChat.normaliseURL(lmstudioURL) - state.lmstudioServerURL = url - lmstudioURL = url - state.fetchedProviderModels[.lmstudio] = nil - state.providerModelFetchError[.lmstudio] = nil - statusMessage = "✓ LM Studio URL saved." + if state.lmstudioServerURL.isEmpty { + TextField("http://127.0.0.1:1234", text: $lmstudioURL) + .textFieldStyle(.roundedBorder) + Button(connectingLMStudio ? "Connecting…" : "Connect") { + Task { await connectLocal(provider: .lmstudio) } + } + .buttonStyle(.borderedProminent) + .disabled(connectingLMStudio) + } else { + Text(state.lmstudioServerURL) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + Button("Disconnect") { + state.lmstudioServerURL = "" + lmstudioURL = "" + state.fetchedProviderModels[.lmstudio] = nil + state.providerModelFetchError[.lmstudio] = nil + if state.chatProvider == .lmstudio { state.chatProvider = .anthropic } + statusMessage = "LM Studio disconnected." + } + .buttonStyle(.bordered) } - .buttonStyle(.borderedProminent) } .padding(.vertical, 4) } @@ -767,6 +803,42 @@ struct SettingsView: View { } #endif + private func connectLocal(provider: ChatProvider) async { + let rawURL = provider == .ollama ? ollamaURL : lmstudioURL + let candidate = rawURL.isEmpty + ? (provider == .ollama ? "http://127.0.0.1:11434" : "http://127.0.0.1:1234") + : rawURL + let normalised = LocalChat.normaliseURL(candidate) + guard normalised.hasPrefix("http://") || normalised.hasPrefix("https://") else { + statusMessage = "Only http:// and https:// URLs are supported." + return + } + if provider == .ollama { connectingOllama = true } else { connectingLMStudio = true } + statusMessage = "" + let result = await LocalChat.fetchModelsResult(baseURL: normalised) + if provider == .ollama { connectingOllama = false } else { connectingLMStudio = false } + let name = provider == .ollama ? "Ollama" : "LM Studio" + switch result { + case .success(let models) where models.isEmpty: + statusMessage = "No models yet — download one in \(name) first." + case .success(let models): + if provider == .ollama { + state.ollamaServerURL = normalised + ollamaURL = normalised + state.fetchedProviderModels[.ollama] = nil + state.providerModelFetchError[.ollama] = nil + } else { + state.lmstudioServerURL = normalised + lmstudioURL = normalised + state.fetchedProviderModels[.lmstudio] = nil + state.providerModelFetchError[.lmstudio] = nil + } + statusMessage = "✓ Connected · \(models.count) model\(models.count == 1 ? "" : "s")" + case .failure: + statusMessage = "Couldn't reach \(name) at \(normalised). Is it running?" + } + } + private func installHooks() { do { pendingHookJSON = try HookServer.shared.previewClaudeHooks() diff --git a/README.md b/README.md index 09985a4c5..83dbf135e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - 🤖 **Claude Code, Cursor, Codex, Gemini CLI, Antigravity and other agents, live** — see every session in your notch: what it reads, edits and runs, step by step. Tag a hook payload with `coucou_agent` to give any agent its own pill (see [`docs/AGENTS.md`](docs/AGENTS.md)). Finished? Mochi does a happy little jump. - ✅ **Approve from the notch** — Claude Code and Codex permission requests show up with **Allow / Deny** (and **Always** for Claude Code), in VS Code, Cursor's terminal, or Codex. One click, back to work. - 🧑‍💻 **Jump to the right terminal** — open the exact terminal window of a session *(macOS)*. -- 💬 **Chat with Claude, or with Gemini and OpenAI models using your own keys** *(Gemini and OpenAI: macOS)* — click the model name above the chat box to switch provider and pick a model; the list comes from each API account. +- 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** *(macOS)* — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. - 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI and service integrations *(macOS)*. - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. @@ -137,6 +137,8 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li | **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service | | **Google AI API key** *(macOS)* | chat with Google AI (Gemini) | Settings → Chat — other providers · Keychain | | **OpenAI API key** *(macOS)* | chat with OpenAI | Settings → Chat — other providers · Keychain | +| **Ollama server** *(macOS)* | chat with local models via Ollama | Settings → Chat → Local models → **Connect** | +| **LM Studio server** *(macOS)* | chat with local models via LM Studio | Settings → Chat → Local models → **Connect** | | **Active pills** *(macOS)* | choose which tools and agents appear in the island | Settings → Active pills | | Stripe, n8n, GitHub, Vercel, Resend, Notion, Cal.com | the service pills | Keychain / Windows Credential Manager / Secret Service, all optional | diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 5276b8778..7af2bb46f 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -172,3 +172,30 @@ Voir le catalogue de pastilles dans `docs/SPEC.md` (section « Catalogue de past | Micro + Reconnaissance vocale (optionnel) | dictée | premier clic sur le micro | Aucune permission Accessibilité nécessaire. + + +--- + +## Local models (Ollama / LM Studio) + +**Pill IDs**: `ai_ollama` (yellow `#FACC15`), `ai_lmstudio` (lime `#A3E635`) +**Category**: AI for the chat +**Platform**: macOS only + +Connect to a local OpenAI-compatible server. No API key needed. + +### Connect + +Settings → Chat → Local models → **Connect**. Coucou sends a `GET /v1/models` request to the server. If the server responds with models, the URL is saved and the provider appears in the model picker. Embedding models (`nomic-embed-text`, `bge-*`, etc.) are filtered out automatically. + +### Streaming + +Messages are streamed token by token via `POST /v1/chat/completions` with `"stream": true`. Reasoning blocks (`…`, used by models like DeepSeek-R1) are hidden from the chat bubble while the block is open, then removed from the final response. + +### File attachment + +Text files are sent inline, truncated to 24 000 characters. Images and PDFs: only the filename is sent. + +### Disconnect + +Settings → Chat → Local models → **Disconnect**. Clears the saved URL and model cache. If a local provider was active in the chat, the chat switches back to Anthropic. diff --git a/docs/SPEC.md b/docs/SPEC.md index 1597fc1cc..ebdfe1fd4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -242,3 +242,18 @@ Chaque jalon se termine par build + capture + comparaison aux références + com - Une session Claude Code n'est jamais bloquée par l'app (app fermée, plantée ou lente → le terminal prend le relais). - Hidden = 0 % CPU ; compact < 3 % ; mémoire < 100 Mo. - La démo (⌃⌥⌘D) se filme d'une traite sans intervention. + +--- + +### ai_ollama +- ID: `ai_ollama` +- Color: `#FACC15` (yellow) +- Connected when: `ollamaServerURL` is non-empty (set via Connect button in Settings → Chat) +- Model list: fetched from `GET /v1/models`, refreshed each time the picker opens +- Streaming: `POST /v1/chat/completions` with `stream: true`, 300 s timeout, 15 fps UI throttle +- Think blocks: `…` hidden during streaming, removed from final response + +### ai_lmstudio +- ID: `ai_lmstudio` +- Color: `#A3E635` (lime) +- Same behaviour as `ai_ollama`, default server `http://127.0.0.1:1234` diff --git a/docs/privacy.html b/docs/privacy.html index 02bb1223e..52ee4c3c6 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -31,6 +31,7 @@

4. Services you connect yourself

  • Anthropic (Claude API) — when you chat using Anthropic or ask about a file or a window, the text you type, the file content and/or the window context you chose are sent to Anthropic. When you open the chat or Settings, the list of available models is also fetched from the Anthropic API using your key. Anthropic privacy policy.
  • Google AI (Gemini API) (macOS) — when you chat using Google AI, the text you type and the current conversation are sent to Google's Gemini API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic). The list of available models is fetched when you open the chat. Google privacy policy.
  • OpenAI (macOS) — when you chat using OpenAI, the text you type and the current conversation are sent to the OpenAI API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic). The list of available models is fetched when you open the chat. OpenAI privacy policy.
  • +
  • Ollama / LM Studio (macOS) — when you chat using a local model, the text you type, the current conversation, and the content of any attached text file (up to 24 000 characters) are sent to the server address you configured in Settings → Chat → Local models. By default that address is 127.0.0.1 on your Mac — nothing leaves your computer. If you point it at a remote server, that server receives the data. The server address is stored in your Mac's user preferences (not the Keychain). The list of available models is fetched each time you open the model picker.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. Coucou only reads from them.
  • We have no access to these requests or their responses.

    @@ -86,6 +87,7 @@

    4. Services que tu connectes toi-même

  • Anthropic (API Claude) : quand tu discutes via Anthropic ou poses une question sur un fichier ou une fenêtre, le texte saisi, le contenu du fichier et/ou le contexte de la fenêtre choisie sont envoyés à Anthropic. À l’ouverture du chat ou des Réglages, la liste des modèles disponibles est aussi récupérée depuis l’API d’Anthropic avec ta clé. Politique de confidentialité d’Anthropic.
  • Google AI (API Gemini) (macOS) : quand tu discutes via Google AI, le texte saisi et la conversation en cours sont envoyés à l’API Gemini de Google. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité de Google.
  • OpenAI (macOS) : quand tu discutes via OpenAI, le texte saisi et la conversation en cours sont envoyés à l’API OpenAI. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité d’OpenAI.
  • +
  • Ollama / LM Studio (macOS) : quand tu discutes via un modèle local, le texte saisi, la conversation en cours et le contenu de tout fichier texte attaché (jusqu’à 24 000 caractères) sont envoyés à l’adresse de serveur configurée dans Réglages → Chat → Local models. Par défaut, cette adresse est 127.0.0.1 sur ton Mac — rien ne quitte ton ordinateur. Si tu la pointes vers un serveur distant, ce serveur reçoit les données. L’adresse est conservée dans les préférences utilisateur de ton Mac (pas dans le trousseau). La liste des modèles disponibles est récupérée à chaque ouverture du sélecteur de modèle.
  • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Coucou ne fait que lire.
  • Le développeur n’a accès ni à ces requêtes ni à leurs réponses.

    diff --git a/docs/support.html b/docs/support.html index 4a6a84d83..17a2f62b3 100644 --- a/docs/support.html +++ b/docs/support.html @@ -41,6 +41,8 @@

    No sound

    Check the speaker icon in the notch header and the volume in Settings.

    Which model does the chat use?

    On Mac, Claude Sonnet 4.6 by default (Anthropic). Switch provider and model by clicking the model name above the chat box. For Anthropic, the model can also be set in Settings → Anthropic API. Each model is billed at its own price by the provider.

    +

    Can I use Ollama or LM Studio instead of a cloud API?

    +

    Yes (macOS). Open Settings → Chat → Local models, type the server address (or leave blank for the default), and click Connect. Coucou checks that the server is reachable and lists available models. To stop using it, click Disconnect. No API key is needed; charges depend entirely on what you run locally.

    Does it work without a notch?

    Yes. On an iMac, a Mac mini, or a MacBook with its lid closed on an external display, Mochi sits in a small bar at the top of the main screen. With the lid open, it stays in the notch.

    How do I uninstall?

    @@ -86,6 +88,8 @@

    Pas de son

    Vérifie l’icône haut-parleur dans l’en-tête du notch et le volume dans les Réglages.

    Quel modèle utilise le chat ?

    Sur Mac, Claude Sonnet 4.6 par défaut (Anthropic). Change de fournisseur et de modèle en cliquant sur le nom du modèle au-dessus du champ de chat. Pour Anthropic, le modèle peut aussi être défini dans Réglages → Anthropic API. Chaque modèle est facturé à son propre tarif par le fournisseur.

    +

    Puis-je utiliser Ollama ou LM Studio à la place d'une API cloud ?

    +

    Oui (macOS). Ouvre Réglages → Chat → Local models, entre l'adresse du serveur (ou laisse vide pour le défaut) et clique sur Connect. Coucou vérifie que le serveur est accessible et liste les modèles disponibles. Pour arrêter, clique sur Disconnect. Pas de clé d'API — les coûts dépendent uniquement de ce que tu fais tourner en local.

    Ça marche sans notch ?

    Oui. Sur un iMac, un Mac mini ou un MacBook capot fermé branché à un écran externe, Mochi s’affiche dans une petite barre en haut de l’écran principal. Capot ouvert, il reste dans le notch.

    Comment désinstaller ?

    diff --git a/tests/ChatParsingTests.swift b/tests/ChatParsingTests.swift index f7c328837..8e3da2716 100644 --- a/tests/ChatParsingTests.swift +++ b/tests/ChatParsingTests.swift @@ -75,6 +75,35 @@ enum ChatParsingTests { checkTrue("heading text", text == "Hello") } else { print(" ✗ heading not found"); failures += 1 } + print("ChatMarkdown.parse — extended") + // Numbered list preserves number + let numBlocks = ChatMarkdown.parse("1. first\n2. second") + let numItems = numBlocks.filter { if case .listItem = $0 { return true }; return false } + checkTrue("ordered list count", numItems.count == 2) + if case .listItem(let prefix, _, _) = numItems.first! { + checkTrue("ordered prefix is '1.'", prefix == "1.") + } + // Heading requires space after # + checkTrue("heading with space", ChatMarkdown.parse("## Hi").contains { if case .heading = $0 { return true }; return false }) + checkTrue("#nospace is paragraph", ChatMarkdown.parse("#nospace").contains { if case .paragraph = $0 { return true }; return false }) + // Nested list indent + let nested = ChatMarkdown.parse("- top\n - nested") + let items = nested.filter { if case .listItem = $0 { return true }; return false } + checkTrue("nested list count", items.count == 2) + if case .listItem(_, _, let indent) = items[1] { checkTrue("nested indent = 1", indent == 1) } + // Blockquote + let qBlocks = ChatMarkdown.parse("> quoted text") + checkTrue("blockquote parsed", qBlocks.contains { if case .quote = $0 { return true }; return false }) + if case .quote(let text) = qBlocks.first! { checkTrue("quote text", text == "quoted text") } + // Paragraph stops before ordered list + let mixBlocks = ChatMarkdown.parse("intro\n1. item") + checkTrue("paragraph + ordered list", mixBlocks.filter { if case .paragraph = $0 { return true }; return false }.count == 1 + && mixBlocks.filter { if case .listItem = $0 { return true }; return false }.count == 1) + // progressiveFilter hides open think block + check("open think → empty", LocalChat.progressiveFilter("\nhalf"), "") + check("open after text", LocalChat.progressiveFilter("answerhidden"), "answer") + check("closed think removed", LocalChat.progressiveFilter("doneresult"), "result") + // ── End-to-end tests (fake server) ─────────────────────────────────── let baseURL: String = { From a900df96ee8dc14fecb1151f433d736dbc7d3447 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Fri, 2 Oct 2026 23:59:13 +0200 Subject: [PATCH 5/6] Fix pill hints, local model error flow, streaming scroll, and docs polish Co-Authored-By: Claude Sonnet 4.6 --- NotchBuddy/Sources/App/AppState.swift | 6 ++- NotchBuddy/Sources/App/ClaudeService.swift | 9 +++- .../Sources/App/IslandViewContent.swift | 6 +-- NotchBuddy/Sources/App/SettingsView.swift | 11 ++-- README.md | 6 +-- docs/INTEGRATIONS.md | 51 ++++++++++--------- docs/SPEC.md | 21 ++------ docs/privacy.html | 8 +-- docs/support.html | 4 +- 9 files changed, 62 insertions(+), 60 deletions(-) diff --git a/NotchBuddy/Sources/App/AppState.swift b/NotchBuddy/Sources/App/AppState.swift index 70fa530b2..82bc69460 100644 --- a/NotchBuddy/Sources/App/AppState.swift +++ b/NotchBuddy/Sources/App/AppState.swift @@ -102,7 +102,9 @@ final class AppState: ObservableObject { let baseURL = provider == .ollama ? ollamaServerURL : lmstudioServerURL let normalised = LocalChat.normaliseURL(baseURL) guard !normalised.isEmpty else { - providerModelFetchError[provider] = "No server URL configured." + providerModelFetchError[provider] = provider == .ollama + ? "Connect Ollama in Settings → Chat first." + : "Connect LM Studio in Settings → Chat first." return } loadingProviderModels.insert(provider) @@ -620,7 +622,7 @@ struct NotionPage: Identifiable { enum ChatRole { case user, assistant } -struct ChatMessage: Identifiable { +struct ChatMessage: Identifiable, Equatable { let id = UUID() let role: ChatRole var content: String // var for streaming updates diff --git a/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 222d115bf..7ebeee1d4 100644 --- a/NotchBuddy/Sources/App/ClaudeService.swift +++ b/NotchBuddy/Sources/App/ClaudeService.swift @@ -283,7 +283,14 @@ final class ClaudeService { } } - guard !baseURL.isEmpty, let url = URL(string: "\(baseURL)/chat/completions") else { return } + guard !baseURL.isEmpty else { + if provider.isLocal { + let name = provider == .ollama ? "Ollama" : "LM Studio" + await showError("Connect \(name) in Settings → Chat first.", state: state) + } + return + } + guard let url = URL(string: "\(baseURL)/chat/completions") else { return } // Auth header let authHeader: String diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 3345639d1..3b2ab5063 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -783,9 +783,9 @@ struct PromptView: View { } .padding(.vertical, 2) } - .onChange(of: state.chatHistory.count) { _, _ in - if let last = state.chatHistory.last { - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + .onChange(of: state.chatHistory) { _, _ in + if let last = state.chatHistory.last(where: { !$0.content.isEmpty }) { + proxy.scrollTo(last.id, anchor: .bottom) } } .onChange(of: state.stateOverride) { _, v in diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index af728cd1f..26c4775e9 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -1057,9 +1057,14 @@ struct SettingsView: View { if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Hooks not installed" } #endif if def.category == .ai { - let keyId = def.id == "ai_anthropic" ? "anthropic-api-key" - : def.id == "ai_google" ? "google-api-key" : "openai-api-key" - if KeychainStore.shared.get(keyId) == nil { return "Key not configured" } + if let provider = ChatProvider(pillID: def.id), provider.isLocal { + let url = provider == .ollama ? state.ollamaServerURL : state.lmstudioServerURL + if url.isEmpty { return "Not connected" } + } else { + let keyId = def.id == "ai_anthropic" ? "anthropic-api-key" + : def.id == "ai_google" ? "google-api-key" : "openai-api-key" + if KeychainStore.shared.get(keyId) == nil { return "Key not configured" } + } } return nil }() diff --git a/README.md b/README.md index 83dbf135e..9951e17bd 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - 🤖 **Claude Code, Cursor, Codex, Gemini CLI, Antigravity and other agents, live** — see every session in your notch: what it reads, edits and runs, step by step. Tag a hook payload with `coucou_agent` to give any agent its own pill (see [`docs/AGENTS.md`](docs/AGENTS.md)). Finished? Mochi does a happy little jump. - ✅ **Approve from the notch** — Claude Code and Codex permission requests show up with **Allow / Deny** (and **Always** for Claude Code), in VS Code, Cursor's terminal, or Codex. One click, back to work. - 🧑‍💻 **Jump to the right terminal** — open the exact terminal window of a session *(macOS)*. -- 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** *(macOS)* — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. -- 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI and service integrations *(macOS)*. +- 💬 **Chat with Claude, Gemini, OpenAI, or a local model (Ollama / LM Studio)** — click the model name above the chat box to switch provider and pick a model. Cloud providers use your own API key; local providers connect to a server running on your Mac. *(Gemini, OpenAI and local models: macOS)* +- 📋 **Declare the tools you use** — open Settings → Active pills and pick your main workspace tool (VS Code, Cursor, Codex or Antigravity), then toggle up to 4 more: Gemini CLI, Anthropic, Google AI, OpenAI, Ollama, LM Studio and service integrations *(macOS)*. - 📎 **Drop a file on the notch** — Mochi turns into a box and swallows it, then ask a question about it or send it by email *(email: macOS, Mail.app)*. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. - 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub, Vercel deployments, Resend emails, Notion, Cal.com. Each one gets its own little colored Mochi. @@ -84,7 +84,7 @@ The first Linux build is out as a beta: download it from [Coucou for Linux 0.1.1 - **Debian / Ubuntu**: `sudo apt install ./Coucou-Linux-*.deb` - **Fedora / openSUSE**: `sudo dnf install ./Coucou-Linux-*.rpm` -Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity and the Google AI and OpenAI chat are macOS only for now. +Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity, Google AI, OpenAI and local model (Ollama / LM Studio) chat are macOS only for now. The island sits on the top edge on compositors with layer-shell — COSMIC, KDE Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 7af2bb46f..b5448e9e2 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -141,6 +141,32 @@ Voir le catalogue de pastilles dans `docs/SPEC.md` (section « Catalogue de past --- +## 5ter. Modèles locaux (Ollama / LM Studio) + +**IDs de pastilles** : `ai_ollama` (jaune `#FACC15`), `ai_lmstudio` (vert citron `#A3E635`) +**Catégorie** : AI for the chat +**Plateforme** : macOS uniquement + +Connexion à un serveur local compatible OpenAI. Aucune clé d'API requise. + +### Connexion + +Réglages → Chat → Local models → **Connect**. Coucou envoie une requête `GET /v1/models` au serveur. Si le serveur répond avec des modèles, l'URL est sauvegardée et le fournisseur apparaît dans le sélecteur de modèle. Les modèles d'embedding (`nomic-embed-text`, `bge-*`, etc.) sont filtrés automatiquement. + +### Streaming + +Les messages sont diffusés token par token via `POST /v1/chat/completions` avec `"stream": true`. Les blocs de raisonnement (`…`, utilisés par des modèles comme DeepSeek-R1) sont masqués dans la bulle de chat tant que le bloc est ouvert, puis retirés de la réponse finale. + +### Pièces jointes + +Les fichiers texte sont envoyés en ligne, tronqués à 24 000 caractères. Images et PDF : seul le nom du fichier est envoyé. + +### Déconnexion + +Réglages → Chat → Local models → **Disconnect**. Efface l'URL sauvegardée et le cache des modèles. Si un fournisseur local était actif dans le chat, le chat revient sur Anthropic. + +--- + ## 6. Mail (app Mail du Mac) - Vue `mail` : À (obligatoire, validation d'adresse), Objet (prérempli : nom du fichier), Message (optionnel, une ligne). @@ -174,28 +200,3 @@ Voir le catalogue de pastilles dans `docs/SPEC.md` (section « Catalogue de past Aucune permission Accessibilité nécessaire. ---- - -## Local models (Ollama / LM Studio) - -**Pill IDs**: `ai_ollama` (yellow `#FACC15`), `ai_lmstudio` (lime `#A3E635`) -**Category**: AI for the chat -**Platform**: macOS only - -Connect to a local OpenAI-compatible server. No API key needed. - -### Connect - -Settings → Chat → Local models → **Connect**. Coucou sends a `GET /v1/models` request to the server. If the server responds with models, the URL is saved and the provider appears in the model picker. Embedding models (`nomic-embed-text`, `bge-*`, etc.) are filtered out automatically. - -### Streaming - -Messages are streamed token by token via `POST /v1/chat/completions` with `"stream": true`. Reasoning blocks (`…`, used by models like DeepSeek-R1) are hidden from the chat bubble while the block is open, then removed from the final response. - -### File attachment - -Text files are sent inline, truncated to 24 000 characters. Images and PDFs: only the filename is sent. - -### Disconnect - -Settings → Chat → Local models → **Disconnect**. Clears the saved URL and model cache. If a local provider was active in the chat, the chat switches back to Anthropic. diff --git a/docs/SPEC.md b/docs/SPEC.md index ebdfe1fd4..ab619113f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -98,10 +98,10 @@ Toutes les pastilles déclarées sont définies dans `PillCatalog.all` (source d |---|---|---|---|---| | `workspace` | Where you code | VS Code, Cursor, Antigravity *(GitHub only)*, Codex *(GitHub only)* | Integration | Claude Code / Cursor / Codex / Agent | | `agent` | Agents | Gemini CLI *(GitHub only)* | Agent | Agent | -| `ai` | AI for the chat | Anthropic, Google AI, OpenAI | Chat | — | +| `ai` | AI for the chat | Anthropic, Google AI, OpenAI, Ollama, LM Studio | Chat | — | | `service` | Services | Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | -Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex`. +Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex` (Ollama `#FACC15`, LM Studio `#A3E635`). Règles : - **`mainPillId`** (défaut `integration_claude`) est la pastille workspace toujours chargée. Elle ne compte pas dans les 4 places. Modifiable via le sélecteur Main dans Settings. @@ -111,7 +111,7 @@ Règles : - `sortTasksByCatalog` : pastilles du catalogue dans l'ordre du catalogue ; pastilles hors catalogue juste après `integration_claude`. - Pastilles `githubOnly` : exclues des builds App Store (`#if APPSTORE`). - Hooks (Gemini CLI, Antigravity, Codex) : `isConfigured` = `HookServer.geminiHooksInstalled()` / `agyHooksInstalled()` / `codexHooksInstalled()` sous `#if !APPSTORE`. La section Codex Hooks dans Settings installe les hooks dans `~/.codex/hooks.json` avec le même flux backup + preview que Gemini CLI. Après l'installation, la carte affiche : « run /hooks in Codex or open Hooks in the app's settings to trust them ». Approbations Codex : carte avec Allow et Deny seulement (pas Always) ; updatedPermissions jamais envoyé ; notes « Handled in Codex. » / « Still waiting in Codex. ». -- Pastilles IA : `isConfigured` = clé API dans le Keychain. Bouton « Chat with… » → change le fournisseur et ouvre la vue `.prompt`. +- Pastilles IA (cloud) : `isConfigured` = clé API dans le Keychain. Pastilles IA locales (Ollama, LM Studio) : `isConfigured` = URL serveur non vide (définie via le bouton **Connect** dans Réglages → Chat). Bouton « Chat with… » → change le fournisseur et ouvre la vue `.prompt`. ### Boutons - Pilule, 12,5 pt medium, fond blanc 9 % (survol 15 %), primaire : fond `#F5F6F8` texte `#0B0C0E`. Appui : échelle 0,94. Raccourcis affichés en petite pastille bordée (Y, N). @@ -210,6 +210,7 @@ Petit item dans la barre de menus (icône : silhouette du Mochi, monochrome). Me Fenêtre Réglages (SwiftUI, simple), sections dans l'ordre d'affichage : - **Anthropic API** : clé (Trousseau), modèle (défaut `claude-sonnet-4-6` ; liste depuis l'API, voir INTEGRATIONS §5). - **Chat — other providers** : clé Google AI (Trousseau) ; clé OpenAI (Trousseau). Les modèles se choisissent dans le chat (voir INTEGRATIONS §5bis). +- **Local models** : URL du serveur Ollama (défaut `http://127.0.0.1:11434`) et/ou LM Studio (défaut `http://127.0.0.1:1234`). Bouton **Connect** : vérifie la joignabilité et sauvegarde l'URL. Bouton **Disconnect** : efface l'URL et le cache. Aucune clé requise (voir INTEGRATIONS §5ter). - **Claude Code Hooks** : état des hooks, bouton Installer / Désinstaller. - **Gemini CLI Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. - **Antigravity Hooks** *(build GitHub)* : état des hooks, bouton Installer / Désinstaller. @@ -243,17 +244,3 @@ Chaque jalon se termine par build + capture + comparaison aux références + com - Hidden = 0 % CPU ; compact < 3 % ; mémoire < 100 Mo. - La démo (⌃⌥⌘D) se filme d'une traite sans intervention. ---- - -### ai_ollama -- ID: `ai_ollama` -- Color: `#FACC15` (yellow) -- Connected when: `ollamaServerURL` is non-empty (set via Connect button in Settings → Chat) -- Model list: fetched from `GET /v1/models`, refreshed each time the picker opens -- Streaming: `POST /v1/chat/completions` with `stream: true`, 300 s timeout, 15 fps UI throttle -- Think blocks: `…` hidden during streaming, removed from final response - -### ai_lmstudio -- ID: `ai_lmstudio` -- Color: `#A3E635` (lime) -- Same behaviour as `ai_ollama`, default server `http://127.0.0.1:1234` diff --git a/docs/privacy.html b/docs/privacy.html index 52ee4c3c6..6b182c7ba 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -29,8 +29,8 @@

    4. Services you connect yourself

    Coucou only talks to the services you configure, directly from your Mac, using your own keys. Those services process your data under their own privacy policies:

    • Anthropic (Claude API) — when you chat using Anthropic or ask about a file or a window, the text you type, the file content and/or the window context you chose are sent to Anthropic. When you open the chat or Settings, the list of available models is also fetched from the Anthropic API using your key. Anthropic privacy policy.
    • -
    • Google AI (Gemini API) (macOS) — when you chat using Google AI, the text you type and the current conversation are sent to Google's Gemini API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic). The list of available models is fetched when you open the chat. Google privacy policy.
    • -
    • OpenAI (macOS) — when you chat using OpenAI, the text you type and the current conversation are sent to the OpenAI API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic). The list of available models is fetched when you open the chat. OpenAI privacy policy.
    • +
    • Google AI (Gemini API) (macOS) — when you chat using Google AI, the text you type and the current conversation are sent to Google's Gemini API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic or to a local model server you connected). The list of available models is fetched when you open the chat. Google privacy policy.
    • +
    • OpenAI (macOS) — when you chat using OpenAI, the text you type and the current conversation are sent to the OpenAI API. If you attached a window, the app name, title and URL are also sent. If you attached a file, only its name is sent (file content is only sent to Anthropic or to a local model server you connected). The list of available models is fetched when you open the chat. OpenAI privacy policy.
    • Ollama / LM Studio (macOS) — when you chat using a local model, the text you type, the current conversation, and the content of any attached text file (up to 24 000 characters) are sent to the server address you configured in Settings → Chat → Local models. By default that address is 127.0.0.1 on your Mac — nothing leaves your computer. If you point it at a remote server, that server receives the data. The server address is stored in your Mac's user preferences (not the Keychain). The list of available models is fetched each time you open the model picker.
    • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n — Coucou reads data from your own accounts (payments, repositories, deployments, emails, pages, bookings, workflow runs) to display them. Coucou only reads from them.
    @@ -85,8 +85,8 @@

    4. Services que tu connectes toi-même

    Coucou ne communique qu’avec les services que tu configures, directement depuis ton Mac, avec tes propres clés. Ces services traitent tes données selon leur propre politique :

    • Anthropic (API Claude) : quand tu discutes via Anthropic ou poses une question sur un fichier ou une fenêtre, le texte saisi, le contenu du fichier et/ou le contexte de la fenêtre choisie sont envoyés à Anthropic. À l’ouverture du chat ou des Réglages, la liste des modèles disponibles est aussi récupérée depuis l’API d’Anthropic avec ta clé. Politique de confidentialité d’Anthropic.
    • -
    • Google AI (API Gemini) (macOS) : quand tu discutes via Google AI, le texte saisi et la conversation en cours sont envoyés à l’API Gemini de Google. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité de Google.
    • -
    • OpenAI (macOS) : quand tu discutes via OpenAI, le texte saisi et la conversation en cours sont envoyés à l’API OpenAI. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité d’OpenAI.
    • +
    • Google AI (API Gemini) (macOS) : quand tu discutes via Google AI, le texte saisi et la conversation en cours sont envoyés à l’API Gemini de Google. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic ou vers le serveur de modèle local que tu as connecté). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité de Google.
    • +
    • OpenAI (macOS) : quand tu discutes via OpenAI, le texte saisi et la conversation en cours sont envoyés à l’API OpenAI. Si une fenêtre est attachée, le nom de l’app, le titre et l’URL sont aussi envoyés. Si un fichier est attaché, seul son nom est envoyé (le contenu d’un fichier ne part que chez Anthropic ou vers le serveur de modèle local que tu as connecté). La liste des modèles disponibles est récupérée à l’ouverture du chat. Politique de confidentialité d’OpenAI.
    • Ollama / LM Studio (macOS) : quand tu discutes via un modèle local, le texte saisi, la conversation en cours et le contenu de tout fichier texte attaché (jusqu’à 24 000 caractères) sont envoyés à l’adresse de serveur configurée dans Réglages → Chat → Local models. Par défaut, cette adresse est 127.0.0.1 sur ton Mac — rien ne quitte ton ordinateur. Si tu la pointes vers un serveur distant, ce serveur reçoit les données. L’adresse est conservée dans les préférences utilisateur de ton Mac (pas dans le trousseau). La liste des modèles disponibles est récupérée à chaque ouverture du sélecteur de modèle.
    • Stripe, GitHub, Vercel, Resend, Notion, Cal.com, n8n : Coucou lit les données de tes propres comptes (paiements, dépôts, déploiements, e-mails, pages, rendez-vous, exécutions de workflows) pour les afficher. Coucou ne fait que lire.
    diff --git a/docs/support.html b/docs/support.html index 17a2f62b3..d9fa00c9e 100644 --- a/docs/support.html +++ b/docs/support.html @@ -42,7 +42,7 @@

    No sound

    Which model does the chat use?

    On Mac, Claude Sonnet 4.6 by default (Anthropic). Switch provider and model by clicking the model name above the chat box. For Anthropic, the model can also be set in Settings → Anthropic API. Each model is billed at its own price by the provider.

    Can I use Ollama or LM Studio instead of a cloud API?

    -

    Yes (macOS). Open Settings → Chat → Local models, type the server address (or leave blank for the default), and click Connect. Coucou checks that the server is reachable and lists available models. To stop using it, click Disconnect. No API key is needed; charges depend entirely on what you run locally.

    +

    Yes (macOS). Open Settings → Chat → Local models, type the server address (or leave blank for the default), and click Connect. Coucou checks that the server is reachable and lists available models. To stop using it, click Disconnect. No API key is needed; it is free: the model runs on your Mac.

    Does it work without a notch?

    Yes. On an iMac, a Mac mini, or a MacBook with its lid closed on an external display, Mochi sits in a small bar at the top of the main screen. With the lid open, it stays in the notch.

    How do I uninstall?

    @@ -89,7 +89,7 @@

    Pas de son

    Quel modèle utilise le chat ?

    Sur Mac, Claude Sonnet 4.6 par défaut (Anthropic). Change de fournisseur et de modèle en cliquant sur le nom du modèle au-dessus du champ de chat. Pour Anthropic, le modèle peut aussi être défini dans Réglages → Anthropic API. Chaque modèle est facturé à son propre tarif par le fournisseur.

    Puis-je utiliser Ollama ou LM Studio à la place d'une API cloud ?

    -

    Oui (macOS). Ouvre Réglages → Chat → Local models, entre l'adresse du serveur (ou laisse vide pour le défaut) et clique sur Connect. Coucou vérifie que le serveur est accessible et liste les modèles disponibles. Pour arrêter, clique sur Disconnect. Pas de clé d'API — les coûts dépendent uniquement de ce que tu fais tourner en local.

    +

    Oui (macOS). Ouvre Réglages → Chat → Local models, entre l’adresse du serveur (ou laisse vide pour le défaut) et clique sur Connect. Coucou vérifie que le serveur est accessible et liste les modèles disponibles. Pour arrêter, clique sur Disconnect. Pas de clé d’API — c’est gratuit : le modèle tourne sur ton Mac.

    Ça marche sans notch ?

    Oui. Sur un iMac, un Mac mini ou un MacBook capot fermé branché à un écran externe, Mochi s’affiche dans une petite barre en haut de l’écran principal. Capot ouvert, il reste dans le notch.

    Comment désinstaller ?

    From 716f0af78247f7469a99c37e92717ede5812a7f0 Mon Sep 17 00:00:00 2001 From: Louis Raille Date: Sat, 3 Oct 2026 00:25:56 +0200 Subject: [PATCH 6/6] Make the chat test server start reliably on CI Co-Authored-By: Claude Sonnet 4.6 --- scripts/test-chat-parsing.sh | 33 +++++++++++++++++++++------------ tests/fake_local_llm.py | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/scripts/test-chat-parsing.sh b/scripts/test-chat-parsing.sh index 47244eeb4..9ad898fd7 100755 --- a/scripts/test-chat-parsing.sh +++ b/scripts/test-chat-parsing.sh @@ -3,33 +3,42 @@ set -euo pipefail cd "$(dirname "$0")/.." TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")" -trap 'rm -rf "$TEST_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT +SERVER_PID="" +trap 'rm -rf "$TEST_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true' EXIT -# ── Start fake LLM server ──────────────────────────────────────────────────── PORT_FILE="$TEST_DIR/port.txt" + +# ── Start fake LLM server ──────────────────────────────────────────────────── python3 tests/fake_local_llm.py "$PORT_FILE" & SERVER_PID=$! -# Wait up to 3 s for the server to write its port -for i in $(seq 1 30); do +# ── Compile test binary (runs in parallel with server startup) ─────────────── +# Compilation typically takes a few seconds, which gives the server plenty of +# time to bind and write its port — avoiding a busy-wait on fast machines. +swiftc \ + NotchBuddy/Sources/App/LocalChat.swift \ + NotchBuddy/Sources/App/ChatMarkdown.swift \ + tests/ChatParsingTests.swift \ + -o "$TEST_DIR/chat-parsing-tests" + +# ── Wait for server port file (up to 30 s) ─────────────────────────────────── +for i in $(seq 1 300); do + # Bail out immediately if the server process has already died + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "ERROR: fake LLM server process exited unexpectedly" >&2 + exit 1 + fi [ -s "$PORT_FILE" ] && break sleep 0.1 done if [ ! -s "$PORT_FILE" ]; then - echo "ERROR: fake LLM server did not start in time" >&2 + echo "ERROR: fake LLM server did not write its port within 30 s" >&2 exit 1 fi PORT=$(cat "$PORT_FILE") echo "Fake LLM server listening on port $PORT (PID $SERVER_PID)" -# ── Compile test binary ────────────────────────────────────────────────────── -swiftc \ - NotchBuddy/Sources/App/LocalChat.swift \ - NotchBuddy/Sources/App/ChatMarkdown.swift \ - tests/ChatParsingTests.swift \ - -o "$TEST_DIR/chat-parsing-tests" - # ── Run tests ──────────────────────────────────────────────────────────────── "$TEST_DIR/chat-parsing-tests" "$PORT" diff --git a/tests/fake_local_llm.py b/tests/fake_local_llm.py index d8292552f..d08684933 100644 --- a/tests/fake_local_llm.py +++ b/tests/fake_local_llm.py @@ -14,6 +14,7 @@ import json import sys import socket +import socketserver import time from http.server import BaseHTTPRequestHandler, HTTPServer @@ -106,6 +107,22 @@ def do_POST(self): self.wfile.flush() +class FastBindHTTPServer(HTTPServer): + """HTTPServer that skips the reverse-DNS lookup in server_bind. + + The default HTTPServer.server_bind calls socket.getfqdn(), which triggers + a reverse-DNS lookup for the bound address. On some CI runners this can + block for several seconds and cause the port-file handshake to time out. + We bypass it by calling TCPServer.server_bind directly and hard-coding + server_name to the loopback address we already know we are binding to. + """ + + def server_bind(self): + socketserver.TCPServer.server_bind(self) + self.server_name = "127.0.0.1" + self.server_port = self.server_address[1] + + def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) @@ -114,7 +131,7 @@ def find_free_port() -> int: if __name__ == "__main__": port = find_free_port() - server = HTTPServer(("127.0.0.1", port), FakeLLMHandler) + server = FastBindHTTPServer(("127.0.0.1", port), FakeLLMHandler) # Write port to the file passed as argv[1] so the caller can read it if len(sys.argv) > 1: