Files
brov-macbook/NotchBuddy/Sources/App/ClaudeService.swift
T

714 lines
32 KiB
Swift

import Foundation
import Security
// MARK: - Keychain helpers
enum Keychain {
static let service = "fr.louisraille.NotchBuddy"
static func save(key: String, value: String) {
guard let data = value.data(using: .utf8) else { return }
// Delete existing item first (update pattern)
let lookup: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(lookup as CFDictionary)
// Add with strictest access control:
// WhenUnlockedThisDeviceOnly = accessible only while Mac is unlocked,
// never synced to iCloud, never migrated to another device.
let item: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecAttrSynchronizable as String: kCFBooleanFalse!,
]
SecItemAdd(item as CFDictionary, nil)
}
static func load(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}
// MARK: - Keychain cache (reads each key ONCE at launch; all subsequent access via dict)
final class KeychainStore: @unchecked Sendable {
static let shared = KeychainStore()
private var cache: [String: String] = [:]
private let lock = NSLock()
private static let allKeys = [
"anthropic-api-key",
"google-api-key",
"openai-api-key",
"resend-api-key", "resend-from",
"n8n-url", "n8n-api-key",
"vercel-token",
"github-token",
"stripe-api-key",
"calcom-api-key",
"notion-api-key",
]
private init() {
// Called once, on main thread (AppDelegate triggers shared at launch).
for key in Self.allKeys {
if let v = Keychain.load(key: key) { cache[key] = v }
}
}
/// Thread-safe read — never touches the Keychain.
func get(_ key: String) -> String? {
lock.withLock { cache[key] }
}
/// Updates cache + persists to Keychain.
func set(_ key: String, value: String) {
lock.withLock { cache[key] = value }
Keychain.save(key: key, value: value)
}
/// Removes from cache + Keychain only if the key was previously set.
func remove(_ key: String) {
let had = lock.withLock { () -> Bool in
let exists = cache[key] != nil
cache[key] = nil
return exists
}
if had { Keychain.delete(key: key) }
}
}
// MARK: - Claude API
@MainActor
final class ClaudeService {
static let shared = ClaudeService()
private let endpoint = URL(string: "https://api.anthropic.com/v1/messages")!
private let anthropicVersion = "2023-06-01"
// MARK: - Model list
/// Fetches available models from the Anthropic API in the order the API returns them
/// (newest first). Returns an empty array on any error — callers fall back to a static list.
static func fetchModels(apiKey: String) async -> [(id: String, label: String)] {
guard let url = URL(string: "https://api.anthropic.com/v1/models?limit=100") else { return [] }
var req = URLRequest(url: url, timeoutInterval: 10)
req.setValue(apiKey, forHTTPHeaderField: "x-api-key")
req.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
guard let (data, response) = try? await URLSession.shared.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else { return [] }
return items.compactMap { item in
guard let id = item["id"] as? String,
let name = item["display_name"] as? String else { return nil }
return (id: id, label: name)
}
}
/// Fetches Gemini models via the OpenAI-compatible endpoint.
/// Strips the "models/" prefix that the API sometimes returns and filters non-chat models.
static func fetchGoogleModels(apiKey: String) async -> [(id: String, label: String)] {
guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/openai/models") else { return [] }
var req = URLRequest(url: url, timeoutInterval: 10)
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
guard let (data, response) = try? await URLSession.shared.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else { return [] }
let excluded = ["embed", "imagen", "veo", "aqa", "tts", "audio", "live"]
return items.compactMap { item in
guard let raw = item["id"] as? String else { return nil }
let id = raw.hasPrefix("models/") ? String(raw.dropFirst(7)) : raw
let lower = id.lowercased()
guard !excluded.contains(where: { lower.contains($0) }) else { return nil }
return (id: id, label: id)
}
}
/// Fetches chat models from the OpenAI API, sorted newest-first by creation date.
/// Excludes non-chat model families.
static func fetchOpenAIModels(apiKey: String) async -> [(id: String, label: String)] {
guard let url = URL(string: "https://api.openai.com/v1/models") else { return [] }
var req = URLRequest(url: url, timeoutInterval: 10)
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
guard let (data, response) = try? await URLSession.shared.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let items = json["data"] as? [[String: Any]] else { return [] }
let excluded = ["embed", "tts", "whisper", "dall-e", "audio", "realtime", "moderat",
"codex", "computer-use", "transcribe", "image", "sora",
"babbage", "davinci", "instruct"]
return items
.compactMap { item -> (id: String, created: Int)? 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, created: item["created"] as? Int ?? 0)
}
.sorted { $0.created > $1.created }
.map { (id: $0.id, label: $0.id) }
}
/// Chosen in Settings; falls back to the default when the field is left empty.
private var model: String {
let m = AppState.shared.claudeModel.trimmingCharacters(in: .whitespacesAndNewlines)
return m.isEmpty ? AppState.defaultClaudeModel : m
}
var apiKey: String? { KeychainStore.shared.get("anthropic-api-key") }
// Multi-turn conversation messages (for API)
private var conversationMessages: [[String: Any]] = []
func clearConversation() {
conversationMessages = []
}
/// Resolved once: NSFullUserName() is a system call, and the name cannot change under us
/// while the app runs.
private let systemPrompt = ClaudeService.makeSystemPrompt()
/// Greets the user by their macOS first name when there is one worth using, and stays
/// neutral otherwise — same wording as the Windows build.
private nonisolated static func makeSystemPrompt() -> String {
let opening = if let firstName = resolveUserFirstName() {
"You are BroV, \(firstName)'s personal AI assistant embedded in the notch of their Mac."
} else {
"You are BroV, a personal AI assistant embedded in the notch of the user's Mac."
}
return """
\(opening) \
You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \
Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \
Use 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.
"""
}
private let webSearchTools: [[String: Any]] = [
["type": "web_search_20250305", "name": "web_search", "max_uses": 5]
]
// MARK: - Chat (multi-turn, natural text + web search)
func chat(query: String, context: PromptContext?, state: AppState) async {
if state.chatProvider == .claudeCode {
await chatClaudeCode(query: query, context: context, state: state)
return
}
guard state.chatProvider == .anthropic else {
await chatOpenAICompatible(query: query, context: context, state: state)
return
}
guard let key = apiKey, !key.isEmpty else {
await showError("Нет API-ключа. Откройте Настройки.", state: state)
return
}
// Build user content for this turn
var userContent: [[String: Any]] = []
// Add file/window context on first message only
if conversationMessages.isEmpty, let context = context {
switch context {
case .window(let app, let title, let url):
var text = "Context — App: \(app), Window: \(title)"
if let url = url { text += ", URL: \(url)" }
userContent.append(["type": "text", "text": text])
case .file(let name, let fileURL):
if let fileURL = fileURL, let block = readFileAsBlock(url: fileURL) {
userContent.append(block)
}
userContent.append(["type": "text", "text": "File: \(name)"])
}
}
userContent.append(["type": "text", "text": query])
conversationMessages.append(["role": "user", "content": userContent])
let body: [String: Any] = [
"model": model,
"max_tokens": 4096,
"tools": webSearchTools,
"system": systemPrompt,
"messages": conversationMessages,
]
do {
let data = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05")
await handleChatResult(data, state: state)
} catch {
conversationMessages.removeLast()
await showError(error.localizedDescription, state: state)
}
}
// MARK: - Claude Code CLI chat (BroV: the user's own `claude`, no API key)
func chatClaudeCode(query: String, context: PromptContext?, state: AppState) async {
// Each brov-chat tab has its own claude conversation and history.
let tab = state.activeTabId
let cli = ChatTabs.shared.cli(for: tab)
func isOnScreen() -> Bool { tab == nil || state.activeTabId == tab }
var prompt = query
// Context goes with the first message of a conversation, like the API chat.
if cli.sessionID == nil, let context {
switch context {
case .window(let app, let title, let url):
var prefix = "Context — App: \(app), Window: \(title)"
if let url { prefix += ", URL: \(url)" }
prompt = prefix + "\n\n" + query
case .file(let name, let fileURL):
// The CLI reads files itself: hand it the path.
if let fileURL {
prompt = "Attached file: \(fileURL.path)\n(read it before answering)\n\n" + query
} else {
prompt = "File: \(name)\n\n" + query
}
}
}
let placeholder = ChatMessage(role: .assistant, content: "")
let msgId = placeholder.id
if let tab { ChatTabs.shared.append(tab: tab, placeholder) } else { state.chatHistory.append(placeholder) }
state.stateOverride = .thinking
do {
let final = try await cli.send(
prompt: prompt,
model: state.claudeCodeModel,
workingDirectory: state.chatWorkingDirectory,
binaryPath: state.claudeBinaryPath,
permissionMode: state.claudePermissionMode
) { [state, msgId] visible in
if !visible.isEmpty, state.stateOverride == .thinking {
state.stateOverride = nil // hide typing dots on first visible text
}
if let tab {
ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = visible }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
state.chatHistory[idx].content = visible
}
}
let trimmed = final.trimmingCharacters(in: .whitespacesAndNewlines)
if let tab {
if trimmed.isEmpty { ChatTabs.shared.remove(tab: tab, message: msgId) }
else { ChatTabs.shared.update(tab: tab, message: msgId) { $0.content = trimmed } }
} else if let idx = state.chatHistory.firstIndex(where: { $0.id == msgId }) {
if trimmed.isEmpty { state.chatHistory.remove(at: idx) }
else { state.chatHistory[idx].content = trimmed }
}
state.stateOverride = nil
if isOnScreen() && state.view != .terminal { state.view = .prompt }
state.claudeNeedsLogin = false
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
} catch is CancellationError {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
state.stateOverride = nil
} catch {
state.chatHistory.removeAll { $0.id == msgId && $0.content.isEmpty }
if let tab { ChatTabs.shared.remove(tab: tab, message: msgId) }
state.stateOverride = nil
if isOnScreen() { await showError(error.localizedDescription, state: state) }
}
}
// 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 }
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, .claudeCode: baseURL = ""
}
}
guard !baseURL.isEmpty else {
if provider.isLocal {
let name = provider == .ollama ? "Ollama" : "LM Studio"
await showError("Сначала подключите \(name) в Настройки → Чат.", state: state)
}
return
}
guard let url = URL(string: "\(baseURL)/chat/completions") else { return }
// Auth header
let authHeader: String
if provider.isLocal {
authHeader = "Bearer ollama"
} else {
guard let key = KeychainStore.shared.get(provider.keychainKey), !key.isEmpty else {
await showError("Нет API-ключа \(provider.displayName). Добавьте его в Настройки → Чат.", state: state)
return
}
authHeader = "Bearer \(key)"
}
// Build messages
var msgs: [[String: Any]] = [["role": "system", "content": systemPrompt]]
for m in conversationMessages {
var simplified = m
if let content = m["content"] as? [[String: Any]],
let textBlock = content.first(where: { ($0["type"] as? String) == "text" }),
let text = textBlock["text"] as? String {
simplified["content"] = text
}
msgs.append(simplified)
}
var userText = query
if conversationMessages.isEmpty, let ctx = context {
switch ctx {
case .window(let app, let title, let url):
var prefix = "Context — App: \(app), Window: \(title)"
if let u = url { prefix += ", URL: \(u)" }
userText = prefix + "\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 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: useStream ? 120 : 30)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue(authHeader, forHTTPHeaderField: "Authorization")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
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
}
}
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 не запущена. Откройте её и спросите снова."
: "Запустите локальный сервер в LM Studio и спросите снова."
case .modelNotFound(let m):
msg = "Модель \(m) не установлена. Выберите другую над полем чата."
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)
}
} 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: "Неожиданный формат ответа"])
}
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)
}
}
}
// MARK: - Structured search (M8 — window attach + web search)
func search(query: String, context: PromptContext?, state: AppState) async {
guard let key = apiKey, !key.isEmpty else {
await showError("Нет API-ключа Anthropic. Добавьте его в Настройки → Чат.", state: state)
return
}
var userContent: [[String: Any]] = []
switch context {
case .window(let appName, let title, let url):
var text = "App: \(appName)\nWindow title: \(title)"
if let url = url { text += "\nURL: \(url)" }
text += "\n\nRequest: \(query)"
userContent.append(["type": "text", "text": text])
case .file(let name, let fileURL):
if let fileURL = fileURL, let fileBlock = readFileAsBlock(url: fileURL) {
userContent.append(fileBlock)
}
userContent.append(["type": "text", "text": "File: \(name)\n\nRequest: \(query)"])
case nil:
userContent.append(["type": "text", "text": query])
}
let system = """
You are an assistant built into the notch of a Mac. Reply in English, short and precise.
Reply ONLY with valid JSON in this exact format:
{"title":"...","items":[{"label":"...","detail":"...","url":"..."}],"note":"..."}
Maximum 3 items. "url" is optional. "note" is optional.
"""
let tools: [[String: Any]] = [
["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]],
]
do {
let result = try await callAPI(body: body, key: key, beta: "web-search-2025-03-05")
await handleResult(result, state: state)
} catch {
await showError(error.localizedDescription, state: state)
}
}
// MARK: - API call
private func callAPI(body: [String: Any], key: String, beta: String? = nil) async throws -> Data {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
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") }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 45
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
// Parse Anthropic error format: {"type":"error","error":{"type":"…","message":"…"}}
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let err = json["error"] as? [String: Any],
let errType = err["type"] as? String,
let errMsg = err["message"] as? String {
if errType == "not_found_error" {
let id = AppState.shared.claudeModel
throw NSError(domain: "Claude", code: 0,
userInfo: [NSLocalizedDescriptionKey:
"Модель не найдена: \(id). Выберите другую в Настройках."])
}
throw NSError(domain: "Claude", code: 0,
userInfo: [NSLocalizedDescriptionKey: errMsg])
}
let msg = String(data: data, encoding: .utf8) ?? "неизвестная ошибка"
throw NSError(domain: "Claude", code: 0, userInfo: [NSLocalizedDescriptionKey: msg])
}
return data
}
// 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 {
await showError("Неожиданный ответ API.", state: state)
return
}
// Store full content (includes tool_use/tool_result blocks) for correct multi-turn context
conversationMessages.append(["role": "assistant", "content": content])
guard let textBlock = content.first(where: { $0["type"] as? String == "text" }),
let text = textBlock["text"] as? String, !text.isEmpty else {
await showError("Ответ пустой.", state: state)
return
}
// Add to display history
state.chatHistory.append(ChatMessage(role: .assistant, content: text.trimmingCharacters(in: .whitespacesAndNewlines)))
state.stateOverride = nil
state.view = .prompt
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.happy)
}
// MARK: - Structured result handler
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 {
await showError("Неожиданный ответ API.", state: state)
return
}
// Strip markdown code fences if present, then extract JSON object
let cleanText: String
if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") {
cleanText = String(text[start...end])
} else {
cleanText = text
}
// Try to parse as our JSON format
if let resultData = cleanText.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: resultData) as? [String: Any] {
let title = parsed["title"] as? String ?? "Результат"
let note = parsed["note"] as? String
var items: [ResultItem] = []
if let rawItems = parsed["items"] as? [[String: Any]] {
for item in rawItems.prefix(3) {
items.append(ResultItem(
label: item["label"] as? String ?? "",
detail: item["detail"] as? String ?? "",
url: item["url"] as? String
))
}
}
state.searchResult = SearchResult(title: title, items: items, note: note)
} else {
// Fallback: show raw text in 3-line chunks
let lines = cleanText.components(separatedBy: "\n").filter { !$0.isEmpty }.prefix(3)
state.searchResult = SearchResult(
title: "Ответ Claude",
items: lines.map { ResultItem(label: $0, detail: "", url: nil) },
note: nil
)
}
state.stateOverride = nil
state.view = .result
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
}
private func showError(_ message: String, state: AppState) async {
state.stateOverride = .error
state.noteMessage = message
state.view = .note
}
// MARK: - File content block builder
private func readFileAsBlock(url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
let ext = url.pathExtension.lowercased()
let base64 = data.base64EncodedString()
if ext == "pdf" {
return ["type": "document", "source": ["type": "base64", "media_type": "application/pdf", "data": base64]]
} else if ["jpg", "jpeg"].contains(ext) {
return ["type": "image", "source": ["type": "base64", "media_type": "image/jpeg", "data": base64]]
} else if ext == "png" {
return ["type": "image", "source": ["type": "base64", "media_type": "image/png", "data": base64]]
} else if ext == "gif" {
return ["type": "image", "source": ["type": "base64", "media_type": "image/gif", "data": base64]]
} else if ext == "webp" {
return ["type": "image", "source": ["type": "base64", "media_type": "image/webp", "data": base64]]
} else {
// Text/code — inline as text if <= 200 KB
guard data.count <= 200_000,
let text = String(data: data, encoding: .utf8) else { return nil }
return ["type": "text", "text": "File contents:\n\(text)"]
}
}
}