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/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..82bc69460 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 = "" { + didSet { UserDefaults.standard.set(ollamaServerURL, forKey: "ollamaServerURL") } + } + @Published var lmstudioServerURL: String = "" { + 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,41 @@ 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] = provider == .ollama + ? "Connect Ollama in Settings → Chat first." + : "Connect LM Studio in Settings → Chat first." + return + } + loadingProviderModels.insert(provider) + providerModelFetchError.removeValue(forKey: provider) + Task { + let result = await LocalChat.fetchModelsResult(baseURL: normalised) + loadingProviderModels.remove(provider) + 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 }) { + let first = models.first!.id + if provider == .ollama { ollamaChatModel = first } + else { lmstudioChatModel = first } + } + case .failure: + providerModelFetchError[provider] = "Cannot reach \(normalised). Is the server running?" + } + } + 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 +144,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 +164,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 +176,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 +305,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 @@ -569,8 +622,8 @@ struct NotionPage: Identifiable { enum ChatRole { case user, assistant } -struct ChatMessage: Identifiable { +struct ChatMessage: Identifiable, Equatable { let id = UUID() let role: ChatRole - let content: String + var content: String // var for streaming updates } diff --git a/NotchBuddy/Sources/App/ChatMarkdown.swift b/NotchBuddy/Sources/App/ChatMarkdown.swift new file mode 100644 index 000000000..506f41f83 --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdown.swift @@ -0,0 +1,118 @@ +import Foundation + +// MARK: - Markdown block types + +enum MDBlock { + case heading(level: Int, text: String) + case paragraph(text: String) + case codeBlock(lang: String, code: 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) + +enum ChatMarkdown { + + 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 — MUST have space after the #s + if line.hasPrefix("#") { + var level = 0 + var rest = line + while rest.hasPrefix("#") { level += 1; rest = String(rest.dropFirst()) } + // 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 + let stripped = line.trimmingCharacters(in: .whitespaces) + if stripped == "---" || stripped == "***" || stripped == "___" { + blocks.append(.rule) + i += 1 + continue + } + + // 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(prefix: "•", text: text, indent: indent)) + i += 1 + continue + } + // 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 + } + 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..b8962a61a --- /dev/null +++ b/NotchBuddy/Sources/App/ChatMarkdownView.swift @@ -0,0 +1,117 @@ +import SwiftUI + +// MARK: - Markdown renderer for chat assistant messages + +struct ChatMarkdownView: View { + let markdown: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + 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 + 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) + .textSelection(.enabled) + + 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): + 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 prefix, let text, let indent): + HStack(alignment: .top, spacing: 6) { + Text(prefix) + .font(.system(size: 12.5)) + .foregroundColor(Color(hex: "#6B7079")) + .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) + } + } + + 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 + +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/NotchBuddy/Sources/App/ClaudeService.swift b/NotchBuddy/Sources/App/ClaudeService.swift index 7336198cd..7ebeee1d4 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. \ - No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks. + 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. """ } @@ -264,28 +264,49 @@ 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) + + let baseURL: String + 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 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 } - 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 + // 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 +315,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 { @@ -302,51 +322,124 @@ 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]) 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]) + if useStream { + // 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 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 final = try await LocalChat.streamChat( + baseURL: baseURL, + encodedBody: encodedBody, + model: modelCopy + ) { [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 + } } - 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"]) + conversationMessages.append(["role": "assistant", "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.removeAll { $0.id == msgId } + state.stateOverride = nil + let msg: String + switch e { + 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.removeAll { $0.id == msgId } + state.stateOverride = nil + await showError(error.localizedDescription, state: state) } - let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) - conversationMessages.append(["role": "assistant", "content": trimmed]) - await MainActor.run { + } 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]) 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..3b2ab5063 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() @@ -779,13 +783,17 @@ 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 - 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 { @@ -885,15 +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, 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)) { @@ -933,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) } } @@ -964,6 +1016,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") @@ -1000,24 +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 { - Text(message.content) - .font(.system(size: 12.5)) - .foregroundColor(Color(hex: "#B0B5BE")) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - 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) + } } } } @@ -1193,6 +1245,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,18 +1357,31 @@ 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 + 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 + default: 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" } } @@ -1451,10 +1518,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..0bb3e0ab2 --- /dev/null +++ b/NotchBuddy/Sources/App/LocalChat.swift @@ -0,0 +1,208 @@ +import Foundation + +// 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 { + + // 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()) } + for suffix in ["/api", "/v1"] { + if s.hasSuffix(suffix) { s = String(s.dropLast(suffix.count)) } + } + return s + } + + // 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)) + 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 + } + + // 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 } + let range = NSRange(text.startIndex..., in: text) + 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[.. 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") + 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. + /// + /// - 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: 300) + 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 = "" + 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 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/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..26c4775e9 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -52,6 +52,10 @@ 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 + @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") ?? "" @@ -551,6 +555,83 @@ 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.") + .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")) + } + } + 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) + } + + 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")) + } + } + 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) + } + } + .padding(.vertical, 4) + } } // MARK: - Integrations section @@ -722,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() @@ -940,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/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 diff --git a/README.md b/README.md index 09985a4c5..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, 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. -- 📋 **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, @@ -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..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). @@ -172,3 +198,5 @@ 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. + + diff --git a/docs/SPEC.md b/docs/SPEC.md index 1597fc1cc..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. @@ -242,3 +243,4 @@ 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. + diff --git a/docs/privacy.html b/docs/privacy.html index 02bb1223e..6b182c7ba 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -29,8 +29,9 @@

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.

We have no access to these requests or their responses.

@@ -84,8 +85,9 @@

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.

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..d9fa00c9e 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; 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?

@@ -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 — 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 ?

diff --git a/scripts/test-chat-parsing.sh b/scripts/test-chat-parsing.sh new file mode 100755 index 000000000..9ad898fd7 --- /dev/null +++ b/scripts/test-chat-parsing.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/coucou-chat-parsing.XXXXXX")" +SERVER_PID="" +trap 'rm -rf "$TEST_DIR"; [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true' EXIT + +PORT_FILE="$TEST_DIR/port.txt" + +# ── Start fake LLM server ──────────────────────────────────────────────────── +python3 tests/fake_local_llm.py "$PORT_FILE" & +SERVER_PID=$! + +# ── 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 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)" + +# ── Run tests ──────────────────────────────────────────────────────────────── +"$TEST_DIR/chat-parsing-tests" "$PORT" diff --git a/tests/ChatParsingTests.swift b/tests/ChatParsingTests.swift new file mode 100644 index 000000000..8e3da2716 --- /dev/null +++ b/tests/ChatParsingTests.swift @@ -0,0 +1,207 @@ +import Foundation + +// MARK: - Test harness + +@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 } + } + + // MARK: - Entry point + + static func main() async { + + // ── Unit tests (no network) ────────────────────────────────────────── + + 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 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 closed block", + LocalChat.filterThinkingBlocks("internalanswer"), "answer") + check("no-op without block", + LocalChat.filterThinkingBlocks("hello"), "hello") + check("multiline block", + LocalChat.filterThinkingBlocks("\nstep1\nstep2\nresult"), "result") + + 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) + 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(" ✗ 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 = { + 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 + } + + finish() + } + + // MARK: - Finish + + private static func finish() -> Never { + if failures == 0 { + print("\nAll tests passed.") + exit(0) + } else { + print("\n\(failures) test(s) failed.") + exit(1) + } + } +} diff --git a/tests/fake_local_llm.py b/tests/fake_local_llm.py new file mode 100644 index 000000000..d08684933 --- /dev/null +++ b/tests/fake_local_llm.py @@ -0,0 +1,142 @@ +#!/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 socketserver +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() + + +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)) + return s.getsockname()[1] + + +if __name__ == "__main__": + port = find_free_port() + 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: + with open(sys.argv[1], "w") as f: + f.write(str(port)) + f.flush() + + server.serve_forever()