Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 76 additions & 30 deletions NotchBuddy/Sources/App/ClaudeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,15 @@ final class ClaudeService {

private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")!
private let anthropicVersion = "2023-06-01"
private let model = "claude-sonnet-4-6"
private let defaultModel = "claude-sonnet-4-6"
private let defaultOllamaEndpoint = "http://127.0.0.1:11434/api/chat"

var apiKey: String? { KeychainStore.shared.get("anthropic-api-key") }
var provider: String { UserDefaults.standard.string(forKey: "chatProvider") ?? "anthropic" }
var model: String { UserDefaults.standard.string(forKey: "chatModel") ?? defaultModel }
var ollamaEndpoint: String {
UserDefaults.standard.string(forKey: "ollamaEndpoint") ?? defaultOllamaEndpoint
}

// Multi-turn conversation messages (for API)
private var conversationMessages: [[String: Any]] = []
Expand All @@ -133,7 +139,7 @@ final class ClaudeService {
// MARK: - Chat (multi-turn, natural text + web search)

func chat(query: String, context: PromptContext?, state: AppState) async {
guard let key = apiKey, !key.isEmpty else {
guard provider == "ollama" || (apiKey?.isEmpty == false) else {
await showError("API key missing. Open settings.", state: state)
return
}
Expand All @@ -159,16 +165,10 @@ final class ClaudeService {

conversationMessages.append(["role": "user", "content": userContent])

let body: [String: Any] = [
"model": model,
"max_tokens": 4096,
"tools": webSearchTools,
"system": systemPrompt,
"messages": conversationMessages,
]
let body = requestBody(messages: conversationMessages, tools: webSearchTools)

do {
let data = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05")
let data = try await callAPI(body: body, beta: "web-search-2025-03-05")
await handleChatResult(data, state: state)
} catch {
conversationMessages.removeLast()
Expand All @@ -179,7 +179,7 @@ final class ClaudeService {
// MARK: - Structured search (M8 — window attach + web search)

func search(query: String, context: PromptContext?, state: AppState) async {
guard let key = apiKey, !key.isEmpty else {
guard provider == "ollama" || (apiKey?.isEmpty == false) else {
await showError("Anthropic API key missing. Open settings to configure it.", state: state)
return
}
Expand Down Expand Up @@ -211,16 +211,25 @@ final class ClaudeService {
["type": "web_search_20250305", "name": "web_search", "max_uses": 3]
]

let body: [String: Any] = [
"model": model,
"max_tokens": 1024,
"tools": tools,
"system": system,
"messages": [["role": "user", "content": userContent]],
]
let body: [String: Any]
if provider == "ollama" {
body = [
"model": model,
"stream": false,
"messages": [["role": "user", "content": userContent.compactMap { $0["text"] as? String }.joined(separator: "\n")]],
]
} else {
body = [
"model": model,
"max_tokens": 1024,
"tools": tools,
"system": system,
"messages": [["role": "user", "content": userContent]],
]
}

do {
let result = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05")
let result = try await callAPI(body: body, beta: "web-search-2025-03-05")
await handleResult(result, state: state)
} catch {
await showError("Network error: \(error.localizedDescription)", state: state)
Expand All @@ -229,13 +238,17 @@ final class ClaudeService {

// MARK: - API call

private func callAPI(body: [String: Any], key: String, beta: String? = nil) async throws -> Data {
var request = URLRequest(url: endpoint)
private func callAPI(body: [String: Any], beta: String? = nil) async throws -> Data {
let url = provider == "ollama" ? URL(string: ollamaEndpoint) : endpoint
guard let url else { throw NSError(domain: "Chat", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid chat endpoint."]) }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(key, forHTTPHeaderField: "x-api-key")
request.setValue(anthropicVersion, forHTTPHeaderField: "anthropic-version")
if provider != "ollama", let key = apiKey {
request.setValue(key, forHTTPHeaderField: "x-api-key")
request.setValue(anthropicVersion, forHTTPHeaderField: "anthropic-version")
}
request.setValue("application/json", forHTTPHeaderField: "content-type")
if let beta { request.setValue(beta, forHTTPHeaderField: "anthropic-beta") }
if provider != "ollama", let beta { request.setValue(beta, forHTTPHeaderField: "anthropic-beta") }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 45

Expand All @@ -248,14 +261,39 @@ final class ClaudeService {
return data
}

private func requestBody(messages: [[String: Any]], tools: [[String: Any]]) -> [String: Any] {
if provider == "ollama" {
let flattened = messages.map { message -> [String: Any] in
let content = (message["content"] as? [[String: Any]]) ?? []
let text = content.compactMap { $0["text"] as? String }.joined(separator: "\n")
return ["role": message["role"] ?? "user", "content": text]
}
return ["model": model, "stream": false, "messages": flattened]
}
return ["model": model, "max_tokens": 4096, "tools": tools, "system": systemPrompt, "messages": messages]
}

// MARK: - Chat result handler

private func handleChatResult(_ data: Data, state: AppState) async {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let content = json["content"] as? [[String: Any]] else {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
await showError("Unexpected API response.", state: state)
return
}
let content: [[String: Any]]
if provider == "ollama" {
guard let text = (json["message"] as? [String: Any])?["content"] as? String, !text.isEmpty else {
await showError("Unexpected Ollama response.", state: state)
return
}
content = [["type": "text", "text": text]]
} else {
guard let anthropicContent = json["content"] as? [[String: Any]] else {
await showError("Unexpected API response.", state: state)
return
}
content = anthropicContent
}

// Store full content (includes tool_use/tool_result blocks) for correct multi-turn context
conversationMessages.append(["role": "assistant", "content": content])
Expand All @@ -278,10 +316,18 @@ final class ClaudeService {

private func handleResult(_ data: Data, state: AppState) async {
// Extract text from Anthropic response (may contain tool_use / web_search_tool_result blocks)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let content = json["content"] as? [[String: Any]],
let textBlock = content.first(where: { $0["type"] as? String == "text" }),
let text = textBlock["text"] as? String else {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
await showError("Unexpected API response.", state: state)
return
}
let text: String?
if provider == "ollama" {
text = (json["message"] as? [String: Any])?["content"] as? String
} else {
let content = json["content"] as? [[String: Any]]
text = content?.first(where: { $0["type"] as? String == "text" })?["text"] as? String
}
guard let text else {
await showError("Unexpected API response.", state: state)
return
}
Expand Down
22 changes: 21 additions & 1 deletion NotchBuddy/Sources/App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,28 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 18) {

// MARK: API
GroupBox("Anthropic API") {
GroupBox("Chat provider") {
VStack(alignment: .leading, spacing: 8) {
Picker("Provider", selection: Binding(
get: { UserDefaults.standard.string(forKey: "chatProvider") ?? "anthropic" },
set: { UserDefaults.standard.set($0, forKey: "chatProvider") }
)) {
Text("Anthropic (cloud)").tag("anthropic")
Text("Ollama (local)").tag("ollama")
}
TextField("Model", text: Binding(
get: { UserDefaults.standard.string(forKey: "chatModel") ?? "claude-sonnet-4-6" },
set: { UserDefaults.standard.set($0, forKey: "chatModel") }
))
.textFieldStyle(.roundedBorder)
TextField(
"Ollama endpoint",
text: Binding(
get: { UserDefaults.standard.string(forKey: "ollamaEndpoint") ?? "http://127.0.0.1:11434/api/chat" },
set: { UserDefaults.standard.set($0, forKey: "ollamaEndpoint") }
)
)
.textFieldStyle(.roundedBorder)
SecureField("API key (sk-ant-…)", text: $apiKey)
.textFieldStyle(.roundedBorder)
Button("Save") {
Expand Down
12 changes: 12 additions & 0 deletions docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Règle d'or : **vérifier la doc officielle au moment d'implémenter**. Les form
- API Claude (Messages, outil de recherche web, modèles) : https://docs.claude.com/en/api/overview
- API publique n8n : `{URL de l'instance}/api/v1/docs` (playground de l'instance de Louis)

Le fournisseur Ollama décrit ci-dessous concerne uniquement le chat intégré de
Coucou. Il ne transforme pas Claude Code en client Ollama.

---

## 1. Claude Code (sessions de Louis)
Expand All @@ -19,6 +22,9 @@ claude (terminal, VS Code, app Claude)
- `nb-hook` : cible séparée dans le projet, copiée dans `~/Library/Application Support/NotchBuddy/bin/nb-hook` au premier lancement.
- Socket : `~/Library/Application Support/NotchBuddy/nb.sock`.
- `nb-hook <Event>` lit le JSON du hook sur stdin, ajoute le contexte du terminal (`TERM_PROGRAM`, `ITERM_SESSION_ID`, `TERM_SESSION_ID`, `__CFBundleIdentifier`, le tty trouvé en remontant les processus parents, `cwd`), l'envoie à l'app.
- Le hook observe et relaie un processus Claude Code déjà lancé ; il ne lance,
ne pilote et ne remplace jamais ce processus. Si Coucou est fermé ou lent,
Claude Code reprend son comportement normal dans le terminal.
- **Si l'app ne répond pas en 300 ms, `nb-hook` sort en code 0 sans rien écrire** : Claude Code continue normalement. Jamais de blocage.

### Événements à brancher et état du bonhomme
Expand Down Expand Up @@ -110,6 +116,12 @@ Permissions : Enregistrement de l'écran (capture) et Automatisation (navigateur

## 5. API Claude (recherche)

Le chat et les questions sur fichier peuvent aussi utiliser un serveur Ollama
local compatible avec `/api/chat`. L'endpoint par défaut est
`http://127.0.0.1:11434/api/chat`, et le modèle est réglable dans les réglages.
Ollama ne demande pas de clé et reçoit des messages texte ; les pièces jointes
non textuelles ne sont donc pas envoyées comme images ou PDF à ce fournisseur.

- `POST https://api.anthropic.com/v1/messages`, en-têtes `x-api-key`, `anthropic-version`, `content-type: application/json` (versions à vérifier dans la doc).
- Modèle par défaut : `claude-sonnet-5`, réglable dans les réglages. Vérifier la liste des modèles disponibles dans la doc.
- Outil de recherche web côté serveur de l'API : l'identifiant de type à jour est dans la doc (au moment d'écrire, `web_search_20250305`) ; `max_uses` 5.
Expand Down
15 changes: 12 additions & 3 deletions windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,18 @@ It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash.

## Chat and keys

**Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows
Credential Manager**, never on disk and never in the interface — the island can
only ask whether a key exists. Same for every integration key.
**Settings… → Chat provider** supports Anthropic or a local Ollama-compatible
endpoint. Anthropic keys live in the **Windows Credential Manager**, never on
disk and never in the interface — the island can only ask whether a key exists.
Ollama uses `http://127.0.0.1:11434/api/chat` by default and needs no key; set
the endpoint and model in Settings. Ollama chat is text-only, so image/PDF
attachments are reduced to any readable text context.

The Claude Code hooks are not an agent backend: they observe an already-running
Claude Code process and relay events/permission requests to Coucou. Selecting
Ollama changes only Coucou's built-in chat/file-question view; it cannot make
Claude Code use Ollama or replace its terminal process. If Coucou is closed or
does not answer, the hook exits and Claude Code continues in the terminal.

No telemetry. The only network requests Coucou makes are to the services you
configure yourself.
Expand Down
Loading