From 8d99f34a4bca49bb986cf0bfe06ff084b5c6c79f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 16:38:02 +0000 Subject: [PATCH] feat: add Higgsfield AI video generation integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new skill (HiggsFieldSkill) that lets the agent generate cinematic videos through the Higgsfield Cloud API (https://cloud.higgsfield.ai/). New agent tools: - higgsfield_generate_video: kick off a generation job (returns request_id) - higgsfield_check_video: poll job status (queued → completed → video_url) - higgsfield_list_models: enumerate available models (Sora 2, Veo 3, Kling 2.1, Seedance, DoP, Flux, Soul) Keychain / Settings: - New KeyStore.Key.higgsfield + Service.higgsfield - Added to Info.plist (iOS + Mac) and Secrets.xcconfig.example - Surfaced in list_api_keys / set_api_key via IntegrationSkill - Higgsfield appears in list_integrations and connect_integration Wiring (all five dispatch points): - Messaging.swift global tools array - AgentHarness system prompt + skill catalog - SkillDispatcher (headless / sub-agent / scheduled runs) - MessagingVC shimmer label (iOS) - VoiceLoopCoordinator dispatch + shimmer (Mac) Missing-key handling: tools return {error: "higgsfield_not_connected"} with a hint to set the key in Settings → Keys. Docs: new Integrations section in README.md. Co-Authored-By: bot_apk --- LoopIOS/AgentHarness/AgentHarness.swift | 2 + LoopIOS/AgentHarness/SkillDispatcher.swift | 3 + LoopIOS/Info.plist | 2 + LoopIOS/MessagingVC.swift | 1 + LoopIOS/Settings/KeyStore.swift | 8 +- LoopIOS/Skills/Dynamic/BundledDocSeeds.swift | 3 +- .../Skills/Higgsfield/HiggsFieldSkill.swift | 425 ++++++++++++++++++ .../Integrations/IntegrationSkill.swift | 41 +- LoopIOS/Structs/Messaging.swift | 1 + LoopMac/Info.plist | 2 + LoopMac/VoiceLoopCoordinator.swift | 4 + README.md | 28 ++ Secrets.xcconfig.example | 1 + 13 files changed, 512 insertions(+), 9 deletions(-) create mode 100644 LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift diff --git a/LoopIOS/AgentHarness/AgentHarness.swift b/LoopIOS/AgentHarness/AgentHarness.swift index fd00ef1..3ed4344 100644 --- a/LoopIOS/AgentHarness/AgentHarness.swift +++ b/LoopIOS/AgentHarness/AgentHarness.swift @@ -131,6 +131,7 @@ final class AgentHarness { #if canImport(HealthKit) && os(iOS) catalog.append(("Apple Health", "Read-only access to steps, distance, workouts, heart rate, sleep, body mass")) #endif + catalog.append(("Higgsfield", "Cinematic video generation via Higgsfield Cloud (Sora 2, Veo 3, Kling, etc.)")) return catalog }() @@ -164,6 +165,7 @@ final class AgentHarness { #if canImport(HealthKit) && os(iOS) fragments.append(HealthSkill.systemPromptFragment) #endif + fragments.append(HiggsFieldSkill.systemPromptFragment) self.toolsDoc = fragments.joined(separator: "\n\n") self.staticToolsDocLength = toolsDoc.count self.staticToolSchemasCount = toolSchemas.count diff --git a/LoopIOS/AgentHarness/SkillDispatcher.swift b/LoopIOS/AgentHarness/SkillDispatcher.swift index bb915ff..ef9c1ed 100644 --- a/LoopIOS/AgentHarness/SkillDispatcher.swift +++ b/LoopIOS/AgentHarness/SkillDispatcher.swift @@ -133,6 +133,9 @@ final class SkillDispatcher { HealthSkill.shared.handle(functionCall: call, completion: completion); return } #endif + if HiggsFieldSkill.shared.handles(functionName: call.name) { + HiggsFieldSkill.shared.handle(functionCall: call, completion: completion); return + } // Runtime-registered skills (Mac-only). Checked under the lock so a // concurrent register() can't tear the iteration. diff --git a/LoopIOS/Info.plist b/LoopIOS/Info.plist index a4bb3e4..90de36e 100644 --- a/LoopIOS/Info.plist +++ b/LoopIOS/Info.plist @@ -37,6 +37,8 @@ $(KIMI_API_KEY) CURSOR_API_KEY $(CURSOR_API_KEY) + HIGGSFIELD_API_KEY + $(HIGGSFIELD_API_KEY) OBSIDIAN_API_KEY $(OBSIDIAN_API_KEY) OBSIDIAN_BASE_URL diff --git a/LoopIOS/MessagingVC.swift b/LoopIOS/MessagingVC.swift index 4143ad5..d94f1b7 100644 --- a/LoopIOS/MessagingVC.swift +++ b/LoopIOS/MessagingVC.swift @@ -1070,6 +1070,7 @@ extension MessagingVC: MessageBoxDelegate { #if canImport(HealthKit) && os(iOS) if let s = HealthSkill.shared.statusText(for: call) { return s } #endif + if let s = HiggsFieldSkill.shared.statusText(for: call) { return s } if let s = DynamicSkillRegistry.shared.statusText(for: call) { return s } switch call.name { diff --git a/LoopIOS/Settings/KeyStore.swift b/LoopIOS/Settings/KeyStore.swift index 5b3cde2..4a15a13 100644 --- a/LoopIOS/Settings/KeyStore.swift +++ b/LoopIOS/Settings/KeyStore.swift @@ -43,6 +43,7 @@ final class KeyStore { case xAPISecret = "X_API_SECRET" case xAccessToken = "X_ACCESS_TOKEN" case xAccessTokenSecret = "X_ACCESS_TOKEN_SECRET" + case higgsfield = "HIGGSFIELD_API_KEY" /// User-facing label shown in Settings. var displayName: String { @@ -67,6 +68,7 @@ final class KeyStore { case .xAPISecret: return "X API Secret" case .xAccessToken: return "X Access Token" case .xAccessTokenSecret: return "X Access Token Secret" + case .higgsfield: return "Higgsfield" } } @@ -93,6 +95,7 @@ final class KeyStore { case .xAPISecret: return "OAuth 1.0a consumer secret — shown once when you regenerate the API key" case .xAccessToken: return "User-level access token with read+write permission from developer.x.com" case .xAccessTokenSecret: return "User-level access token secret — shown once at generation time" + case .higgsfield: return "KEY_ID:KEY_SECRET credentials from cloud.higgsfield.ai → API Keys. Used for cinematic video generation (Sora, Veo, Kling, etc.)." } } } @@ -104,7 +107,7 @@ final class KeyStore { /// either add a new `Service` case here or extend an existing one's `keys`. enum Service: String, CaseIterable { case openAI, anthropic, kimi, deepgram, elevenLabs, exa - case cursor, devin + case cursor, devin, higgsfield case github, slack, notion, obsidian case twitter @@ -119,6 +122,7 @@ final class KeyStore { case .exa: return "Exa" case .cursor: return "Cursor" case .devin: return "Devin" + case .higgsfield: return "Higgsfield" case .github: return "GitHub" case .slack: return "Slack" case .notion: return "Notion" @@ -141,6 +145,7 @@ final class KeyStore { case .exa: return "Web search + answer skill" case .cursor: return "Dispatch coding tasks to Cursor cloud agents (opens PRs)" case .devin: return "Dispatch coding tasks to Devin cloud agents (opens PRs, live transcript)" + case .higgsfield: return "Cinematic video generation via Higgsfield Cloud (Sora 2, Veo 3, Kling, Seedance, etc.)" case .github: return "Review/merge PRs, open PRs and issues, browse repos and notifications" case .slack: return "Read channels/DMs/mentions, search, and send messages with confirmation" case .notion: return "Read and write Notion pages and databases" @@ -164,6 +169,7 @@ final class KeyStore { case .exa: return [.exa] case .cursor: return [.cursor] case .devin: return [.devin, .devinOrgID] + case .higgsfield: return [.higgsfield] case .github: return [.githubPAT, .githubBaseURL] case .slack: return [.slackUserToken] case .notion: return [.notionIntegrationToken] diff --git a/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift b/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift index 867cc02..fdadbd2 100644 --- a/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift +++ b/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift @@ -111,7 +111,8 @@ skill follows the same shape: Bundled skills include: Notion, Scheduler, Web search (Exa), URL fetch, Self-Improvement, File System, Spec Builder, Location, Image, Obsidian, -Calendar, Music, Skill Builder, Sub-Agent, and Integrations. The user (or the +Calendar, Music, Skill Builder, Sub-Agent, Integrations, Cursor, Devin, and +Higgsfield (cinematic video generation). The user (or the agent) can also author new skills in JavaScript at runtime — those hot-load from `Workspace/Skills/` and don't require a rebuild. diff --git a/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift b/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift new file mode 100644 index 0000000..5c88869 --- /dev/null +++ b/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift @@ -0,0 +1,425 @@ +// +// HiggsFieldSkill.swift +// Loop +// +// Lets Loop generate cinematic videos through the Higgsfield Cloud API +// (https://cloud.higgsfield.ai). Calls the REST API directly from the +// device using the HIGGSFIELD_API_KEY stored in the Keychain, so this +// skill does not depend on the backend. +// +// The Higgsfield Cloud API is asynchronous — generation requests return a +// request_id immediately, and the caller polls for completion. This skill +// surfaces three tools: +// - higgsfield_generate_video: kick off a video generation job. +// - higgsfield_check_video: poll a running job's status. +// - higgsfield_list_models: enumerate available models and presets. +// + +import Foundation + +struct HiggsFieldSkill { + static let shared = HiggsFieldSkill() + + private static let baseURL = "https://platform.higgsfield.ai" + + // MARK: - System prompt + + static let systemPromptFragment: String = """ +You can generate cinematic videos through Higgsfield Cloud with these tools: +- higgsfield_generate_video: start a video generation job. Pass a `prompt` (required), plus optional `model` (endpoint slug — see higgsfield_list_models), `aspect_ratio` (e.g. "16:9", "9:16", "1:1"), `reference_image_url`, and `duration_seconds`. Returns a `request_id` to poll. +- higgsfield_check_video: check the status of a running job. Pass `request_id`. Returns `{status, video_url?, thumbnail_url?, error?}`. Status is one of: queued, in_progress, completed, failed, nsfw. +- higgsfield_list_models: list available Higgsfield models (Sora 2, Veo 3, Kling, Seedance, DoP, etc.) and their endpoints. + +Workflow: +1. Call higgsfield_generate_video with a prompt. You get back a request_id immediately. +2. Poll with higgsfield_check_video every few seconds until status is "completed" or terminal ("failed"/"nsfw"). +3. When completed, the response includes video_url (and thumbnail_url for images). + +Notes: +- The user must set HIGGSFIELD_API_KEY in Settings → Keys first (format: KEY_ID:KEY_SECRET from cloud.higgsfield.ai → API Keys). +- Pricing is credit-based on Higgsfield Cloud. Each generation deducts credits; failed/nsfw jobs are refunded. +- If the key is missing, the tool returns an error with a hint to set it. +""" + + // MARK: - Tool schemas + + static let tools: [[String: Any]] = [ + [ + "type": "function", + "function": [ + "name": "higgsfield_generate_video", + "description": "Start a video generation job on Higgsfield Cloud. Returns a request_id to poll with higgsfield_check_video. Requires HIGGSFIELD_API_KEY.", + "parameters": [ + "type": "object", + "properties": [ + "prompt": [ + "type": "string", + "description": "Descriptive prompt for the video to generate." + ], + "model": [ + "type": "string", + "description": "Model endpoint slug (e.g. \"wan/i2v\", \"kling/v2.1/pro/image-to-video\", \"veo/3\"). Defaults to \"wan/i2v\". Use higgsfield_list_models to see all options." + ], + "aspect_ratio": [ + "type": "string", + "description": "Aspect ratio, e.g. \"16:9\", \"9:16\", \"1:1\". Defaults to \"16:9\"." + ], + "reference_image_url": [ + "type": "string", + "description": "Optional URL of a reference image for image-to-video generation." + ], + "duration_seconds": [ + "type": "integer", + "description": "Desired duration in seconds. Availability depends on the model." + ] + ], + "required": ["prompt"] + ] + ] + ], + [ + "type": "function", + "function": [ + "name": "higgsfield_check_video", + "description": "Check the status of a Higgsfield video generation job. Returns status (queued/in_progress/completed/failed/nsfw), video_url, thumbnail_url, or error.", + "parameters": [ + "type": "object", + "properties": [ + "request_id": [ + "type": "string", + "description": "The request_id returned by higgsfield_generate_video." + ] + ], + "required": ["request_id"] + ] + ] + ], + [ + "type": "function", + "function": [ + "name": "higgsfield_list_models", + "description": "List available Higgsfield Cloud video/image generation models, their endpoint slugs, and capabilities.", + "parameters": [ + "type": "object", + "properties": [:], + "required": [] + ] + ] + ] + ] + + static let toolNames: Set = [ + "higgsfield_generate_video", + "higgsfield_check_video", + "higgsfield_list_models" + ] + + func handles(functionName: String) -> Bool { + return HiggsFieldSkill.toolNames.contains(functionName) + } + + func statusText(for call: FunctionCallStruct) -> String? { + switch call.name { + case "higgsfield_generate_video": + if let p = (call.arguments["prompt"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !p.isEmpty { + let short = p.count > 40 ? String(p.prefix(40)) + "…" : p + return "generating video: \(short)" + } + return "generating video" + case "higgsfield_check_video": + return "checking video status" + case "higgsfield_list_models": + return "listing Higgsfield models" + default: + return nil + } + } + + // MARK: - Dispatch + + func handle(functionCall: FunctionCallStruct, + completion: @escaping (MessageStruct) -> Void) { + let args = functionCall.arguments + switch functionCall.name { + case "higgsfield_generate_video": + guard let prompt = args["prompt"] as? String, !prompt.isEmpty else { + completion(missingArgs(for: "higgsfield_generate_video", expected: "prompt")) + return + } + let model = (args["model"] as? String) ?? "wan/i2v" + let aspectRatio = (args["aspect_ratio"] as? String) ?? "16:9" + let refImage = args["reference_image_url"] as? String + let duration = intArg(args["duration_seconds"]) + generateVideo(prompt: prompt, + model: model, + aspectRatio: aspectRatio, + referenceImageURL: refImage, + durationSeconds: duration, + completion: completion) + + case "higgsfield_check_video": + guard let requestId = args["request_id"] as? String, !requestId.isEmpty else { + completion(missingArgs(for: "higgsfield_check_video", expected: "request_id")) + return + } + checkVideo(requestId: requestId, completion: completion) + + case "higgsfield_list_models": + listModels(completion: completion) + + default: + completion(MessageStruct( + role: "assistant", + content: "I don't know how to handle the Higgsfield tool '\(functionCall.name)'." + )) + } + } + + // MARK: - Tool handlers + + private func generateVideo(prompt: String, + model: String, + aspectRatio: String, + referenceImageURL: String?, + durationSeconds: Int?, + completion: @escaping (MessageStruct) -> Void) { + guard let creds = HiggsFieldSkill.credentials else { + completion(HiggsFieldSkill.notConnectedMessage(for: "higgsfield_generate_video")) + return + } + + // Build endpoint — the v2 API takes the model slug as the path. + let endpoint = model.hasPrefix("/") ? model : "/\(model)" + + // Build request body matching the v2 API format (input fields at + // the top level of the JSON body). + var body: [String: Any] = [ + "prompt": prompt, + "aspect_ratio": aspectRatio + ] + if let ref = referenceImageURL, !ref.isEmpty { + body["input_images"] = [ + ["type": "image_url", "image_url": ref] + ] + } + if let dur = durationSeconds { + body["duration"] = dur + } + + post(path: endpoint, credentials: creds, body: body) { json, error in + guard let json = json else { + completion(HiggsFieldSkill.errorMessage( + "Failed to start Higgsfield video generation.", + error: error, + toolName: "higgsfield_generate_video")) + return + } + // The API returns { request_id, status, status_url, cancel_url } + let requestId = (json["request_id"] as? String) ?? "" + let status = (json["status"] as? String) ?? "queued" + let statusURL = (json["status_url"] as? String) ?? "" + + var payload: [String: Any] = [ + "request_id": requestId, + "status": status + ] + if !statusURL.isEmpty { payload["status_url"] = statusURL } + + completion(HiggsFieldSkill.functionMessage( + name: "higgsfield_generate_video", payload: payload)) + } + } + + private func checkVideo(requestId: String, + completion: @escaping (MessageStruct) -> Void) { + guard let creds = HiggsFieldSkill.credentials else { + completion(HiggsFieldSkill.notConnectedMessage(for: "higgsfield_check_video")) + return + } + + let path = "/requests/\(requestId)/status" + get(path: path, credentials: creds) { json, error in + guard let json = json else { + completion(HiggsFieldSkill.errorMessage( + "Failed to check Higgsfield job status.", + error: error, + toolName: "higgsfield_check_video")) + return + } + let status = (json["status"] as? String) ?? "unknown" + var payload: [String: Any] = [ + "request_id": requestId, + "status": status + ] + // Completed jobs include media URLs. + if let video = json["video"] as? [String: Any], + let url = video["url"] as? String { + payload["video_url"] = url + } + if let images = json["images"] as? [[String: Any]], + let first = images.first, + let url = first["url"] as? String { + payload["thumbnail_url"] = url + } + // Failed jobs may include an error message. + if status == "failed" || status == "nsfw" { + let errMsg = (json["error"] as? String) + ?? (json["message"] as? String) + if let e = errMsg { payload["error"] = e } + } + completion(HiggsFieldSkill.functionMessage( + name: "higgsfield_check_video", payload: payload)) + } + } + + private func listModels(completion: @escaping (MessageStruct) -> Void) { + // The Higgsfield Cloud platform exposes many models through + // endpoint-based routing. This is a curated catalog of the + // publicly documented endpoints — kept static so the tool + // works even without network access and doesn't require an + // API key. + let models: [[String: Any]] = [ + ["name": "Wan I2V", "endpoint": "wan/i2v", "type": "image-to-video", "notes": "Wan model — fast image-to-video generation"], + ["name": "Wan T2V", "endpoint": "wan/t2v", "type": "text-to-video", "notes": "Wan model — text-to-video generation"], + ["name": "Kling 2.1 Pro", "endpoint": "kling/v2.1/pro/image-to-video", "type": "image-to-video", "notes": "Kling v2.1 Pro — high-quality cinematic video"], + ["name": "Kling 2.1 Std", "endpoint": "kling/v2.1/standard/image-to-video", "type": "image-to-video", "notes": "Kling v2.1 Standard — faster, lower cost"], + ["name": "Veo 3", "endpoint": "veo/3", "type": "text-to-video", "notes": "Google Veo 3 — high-fidelity video generation"], + ["name": "Seedance 1.0", "endpoint": "seedance/v1/image-to-video", "type": "image-to-video", "notes": "ByteDance Seedance — dance/motion-focused video"], + ["name": "Sora 2", "endpoint": "sora/v2", "type": "text-to-video", "notes": "OpenAI Sora 2 — cinematic text-to-video"], + ["name": "DoP Turbo", "endpoint": "v1/image2video/dop", "type": "image-to-video", "notes": "Higgsfield DoP Turbo — fast cinematic camera movement"], + ["name": "Flux Kontext", "endpoint": "flux-pro/kontext/max/text-to-image", "type": "text-to-image", "notes": "Flux Pro Kontext — high-quality text-to-image"], + ["name": "Soul T2I", "endpoint": "v1/text2image/soul", "type": "text-to-image", "notes": "Higgsfield Soul — stylized text-to-image"], + ] + completion(HiggsFieldSkill.functionMessage( + name: "higgsfield_list_models", + payload: ["models": models])) + } + + // MARK: - HTTP + + /// Parsed KEY_ID:KEY_SECRET pair. + private struct Credentials { + let keyID: String + let keySecret: String + var authHeader: String { "Key \(keyID):\(keySecret)" } + } + + private static var credentials: Credentials? { + guard let raw = KeyStore.shared.value(for: .higgsfield), !raw.isEmpty else { + return nil + } + let parts = raw.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { return nil } + return Credentials(keyID: String(parts[0]), keySecret: String(parts[1])) + } + + private func post(path: String, + credentials: Credentials, + body: [String: Any], + completion: @escaping ([String: Any]?, Error?) -> Void) { + guard let url = URL(string: HiggsFieldSkill.baseURL + path) else { + completion(nil, NSError(domain: "HiggsFieldSkill", code: -2, + userInfo: [NSLocalizedDescriptionKey: "Bad Higgsfield URL"])) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(credentials.authHeader, forHTTPHeaderField: "Authorization") + request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: []) + + URLSession.shared.dataTask(with: request) { data, response, error in + HiggsFieldSkill.parse(data: data, response: response, error: error, completion: completion) + }.resume() + } + + private func get(path: String, + credentials: Credentials, + completion: @escaping ([String: Any]?, Error?) -> Void) { + guard let url = URL(string: HiggsFieldSkill.baseURL + path) else { + completion(nil, NSError(domain: "HiggsFieldSkill", code: -2, + userInfo: [NSLocalizedDescriptionKey: "Bad Higgsfield URL"])) + return + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue(credentials.authHeader, forHTTPHeaderField: "Authorization") + + URLSession.shared.dataTask(with: request) { data, response, error in + HiggsFieldSkill.parse(data: data, response: response, error: error, completion: completion) + }.resume() + } + + private static func parse(data: Data?, + response: URLResponse?, + error: Error?, + completion: @escaping ([String: Any]?, Error?) -> Void) { + if let error = error { + completion(nil, error) + return + } + guard let data = data else { + completion(nil, NSError(domain: "HiggsFieldSkill", code: -3, + userInfo: [NSLocalizedDescriptionKey: "Empty Higgsfield response"])) + return + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + let snippet = String(data: data.prefix(200), encoding: .utf8) ?? "" + completion(nil, NSError(domain: "HiggsFieldSkill", code: status, + userInfo: [NSLocalizedDescriptionKey: "Higgsfield returned non-JSON (status \(status)): \(snippet)"])) + return + } + if status >= 400 { + let msg = (json["error"] as? String) + ?? (json["detail"] as? String) + ?? (json["message"] as? String) + ?? "Higgsfield request failed (status \(status))" + completion(nil, NSError(domain: "HiggsFieldSkill", code: status, + userInfo: [NSLocalizedDescriptionKey: msg])) + return + } + completion(json, nil) + } + + // MARK: - Helpers + + private func intArg(_ value: Any?) -> Int? { + if let i = value as? Int { return i } + if let d = value as? Double { return Int(d) } + if let s = value as? String, let i = Int(s) { return i } + return nil + } + + private func missingArgs(for name: String, expected: String) -> MessageStruct { + return MessageStruct(role: "assistant", + content: "I need \(expected) to call \(name). Please provide them.") + } + + private static func notConnectedMessage(for toolName: String) -> MessageStruct { + let payload: [String: Any] = [ + "error": "higgsfield_not_connected", + "hint": "Set your Higgsfield API key (KEY_ID:KEY_SECRET) in Settings → Keys → Higgsfield. Get one at cloud.higgsfield.ai → API Keys." + ] + return functionMessage(name: toolName, payload: payload) + } + + private static func errorMessage(_ fallback: String, + error: Error?, + toolName: String) -> MessageStruct { + let msg = error?.localizedDescription ?? fallback + return functionMessage(name: toolName, payload: ["error": msg]) + } + + private static func functionMessage(name: String, payload: Any) -> MessageStruct { + let json: String + if let data = try? JSONSerialization.data(withJSONObject: payload, options: []), + let str = String(data: data, encoding: .utf8) { + json = str + } else { + json = "{}" + } + return MessageStruct(role: "function", content: json, name: name) + } +} diff --git a/LoopIOS/Skills/Integrations/IntegrationSkill.swift b/LoopIOS/Skills/Integrations/IntegrationSkill.swift index 5ffddd9..fd092e6 100644 --- a/LoopIOS/Skills/Integrations/IntegrationSkill.swift +++ b/LoopIOS/Skills/Integrations/IntegrationSkill.swift @@ -51,7 +51,7 @@ You can manage the user's integrations and API keys directly: - list_integrations: enumerate the integrations Loop knows about (Google Calendar via EventKit, Notion, Gmail, Slack, Apple Health) and their current connection state. Notion and Slack are token-backed (ntn_… integration token and xoxp- user token respectively, both stored in the Keychain) — status flips to "connected" once the user pastes the relevant key. Apple Health is OS-permission-backed (like Calendar). - connect_integration: kick off the connect flow for a named integration. For Google Calendar this triggers the OS permission prompt when status is undetermined; if access was previously denied, the tool returns a hint telling you to call open_integration_settings with target="calendar_privacy". For Slack, this returns a `needs_api_key` payload with instructions to walk the user through minting an xoxp- token and pasting it in Settings → Keys → Slack User Token. - open_integration_settings: surfaces the in-app Integrations panel (target="in_app", default) or the system Privacy pane (target="calendar_privacy"). Use this when the user says "open integrations" / "let me see my settings". -- list_api_keys: reports which API keys are currently set (Deepgram, ElevenLabs, OpenAI, Exa, Cursor, Obsidian). Values are never returned — only whether each is present. +- list_api_keys: reports which API keys are currently set (Deepgram, ElevenLabs, OpenAI, Exa, Cursor, Obsidian, Higgsfield, etc.). Values are never returned — only whether each is present. - set_api_key: store a value in the keychain. Accepts either the canonical name ("OPENAI_API_KEY") or a friendly alias ("openai"). IMPORTANT: voice transcription is lossy — always read the value back to the user character-by-character and confirm before calling this tool. If the user typed the value, you can call it directly. Tips: @@ -85,7 +85,7 @@ Tips: "properties": [ "name": [ "type": "string", - "description": "Integration identifier. One of: calendar, notion, gmail, slack, health." + "description": "Integration identifier. One of: calendar, notion, gmail, slack, health, higgsfield." ] ], "required": ["name"] @@ -135,7 +135,7 @@ Tips: "properties": [ "name": [ "type": "string", - "description": "Key identifier. Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name." + "description": "Key identifier. Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name, higgsfield." ], "value": [ "type": "string", @@ -255,6 +255,15 @@ Tips: ? "Paste a personal xoxp- token in Settings → Keys → Slack User Token. The user can mint one at api.slack.com/apps with the scopes listed in Specs/3. Integrations Spec.md." : "Connected via personal user token. Slack tools are live." ] + let hfKey = KeyStore.shared.value(for: .higgsfield) ?? "" + let higgsfield: [String: Any] = [ + "name": "higgsfield", + "display_name": "Higgsfield", + "status": hfKey.isEmpty ? "not_connected" : "connected", + "hint": hfKey.isEmpty + ? "Paste KEY_ID:KEY_SECRET credentials from cloud.higgsfield.ai → API Keys in Settings → Keys → Higgsfield." + : "Connected via API key. Higgsfield video generation tools are live." + ] #if canImport(HealthKit) && os(iOS) let healthStatus = HealthKitManager.shared.currentAuthorizationStatus let health: [String: Any] = [ @@ -263,7 +272,7 @@ Tips: "status": healthStatus.rawValue, "hint": healthHint(healthStatus) ] - return [calendar, notion, gmail, slack, health] + return [calendar, notion, gmail, slack, health, higgsfield] #else let health: [String: Any] = [ "name": "health", @@ -271,7 +280,7 @@ Tips: "status": "unavailable", "hint": "HealthKit is not available on this platform." ] - return [calendar, notion, gmail, slack, health] + return [calendar, notion, gmail, slack, health, higgsfield] #endif } @@ -424,11 +433,27 @@ Tips: ])) #endif + case "higgsfield": + let key = KeyStore.shared.value(for: .higgsfield) ?? "" + if !key.isEmpty { + completion(Self.functionMessage(name: toolName, payload: [ + "name": "higgsfield", + "status": "already_connected", + "message": "Higgsfield is already connected via API key. Video generation tools are live." + ])) + } else { + completion(Self.functionMessage(name: toolName, payload: [ + "name": "higgsfield", + "status": "needs_api_key", + "next_action": "Tell the user to paste their Higgsfield credentials (KEY_ID:KEY_SECRET format) in Settings \u{2192} Keys \u{2192} Higgsfield. They can create API keys at cloud.higgsfield.ai \u{2192} API Keys." + ])) + } + default: completion(Self.functionMessage(name: toolName, payload: [ "error": "unknown_integration", "name": which, - "hint": "Known integrations: calendar, notion, gmail, slack, health." + "hint": "Known integrations: calendar, notion, gmail, slack, health, higgsfield." ])) } } @@ -506,6 +531,7 @@ Tips: case .xAPISecret: return "x_api_secret" case .xAccessToken: return "x_access_token" case .xAccessTokenSecret: return "x_access_token_secret" + case .higgsfield: return "higgsfield" } } @@ -519,7 +545,7 @@ Tips: completion(Self.functionMessage(name: toolName, payload: [ "error": "unknown_key", "name": name, - "hint": "Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name." + "hint": "Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name, higgsfield." ])) return } @@ -566,6 +592,7 @@ Tips: case "x_access_token", "twitter_access_token": return .xAccessToken case "x_access_token_secret", "twitter_access_token_secret": return .xAccessTokenSecret + case "higgsfield", "higgsfield_api_key": return .higgsfield default: return nil } } diff --git a/LoopIOS/Structs/Messaging.swift b/LoopIOS/Structs/Messaging.swift index 3c54be1..aec76e6 100644 --- a/LoopIOS/Structs/Messaging.swift +++ b/LoopIOS/Structs/Messaging.swift @@ -486,6 +486,7 @@ var tools: [[String: Any]] = { #if canImport(HealthKit) && os(iOS) all += HealthSkill.tools #endif + all += HiggsFieldSkill.tools // Dynamic, user-authored skills get appended in AgentHarness at every // chat turn so newly hot-loaded skills become visible without restart. return all diff --git a/LoopMac/Info.plist b/LoopMac/Info.plist index 65a469f..54aebd2 100644 --- a/LoopMac/Info.plist +++ b/LoopMac/Info.plist @@ -43,6 +43,8 @@ $(ANTHROPIC_API_KEY) KIMI_API_KEY $(KIMI_API_KEY) + HIGGSFIELD_API_KEY + $(HIGGSFIELD_API_KEY) OBSIDIAN_API_KEY $(OBSIDIAN_API_KEY) OBSIDIAN_BASE_URL diff --git a/LoopMac/VoiceLoopCoordinator.swift b/LoopMac/VoiceLoopCoordinator.swift index 140a471..e6b43d1 100644 --- a/LoopMac/VoiceLoopCoordinator.swift +++ b/LoopMac/VoiceLoopCoordinator.swift @@ -850,6 +850,9 @@ The current date and time is \(now). if CursorSkill.shared.handles(functionName: function.name) { CursorSkill.shared.handle(functionCall: function, completion: cont); return } + if HiggsFieldSkill.shared.handles(functionName: function.name) { + HiggsFieldSkill.shared.handle(functionCall: function, completion: cont); return + } // Dynamic (user-authored JS) skills last — hot-loaded so the // registry is the source of truth for what's currently available. if DynamicSkillRegistry.shared.handles(functionName: function.name) { @@ -879,6 +882,7 @@ The current date and time is \(now). if let s = SubAgentSkill.shared.statusText(for: call) { return s } if let s = DevinSkill.shared.statusText(for: call) { return s } if let s = CursorSkill.shared.statusText(for: call) { return s } + if let s = HiggsFieldSkill.shared.statusText(for: call) { return s } if let s = DynamicSkillRegistry.shared.statusText(for: call) { return s } return "running \(call.name.replacingOccurrences(of: "_", with: " "))" } diff --git a/README.md b/README.md index d15fd69..67d6988 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,34 @@ security policy. Secret scanning runs in CI on every push (`.github/workflows/secret-scan.yml`). To report a vulnerability, see `SECURITY.md`. +## Integrations + +### Higgsfield — AI video generation + +Loop can generate cinematic videos through the +[Higgsfield Cloud](https://cloud.higgsfield.ai/) API. The integration +surfaces three agent tools: + +| Tool | Purpose | +|------|---------| +| `higgsfield_generate_video` | Start a generation job (returns a `request_id` immediately). | +| `higgsfield_check_video` | Poll a job's status; returns `video_url` when complete. | +| `higgsfield_list_models` | List available models (Sora 2, Veo 3, Kling 2.1, Seedance, DoP, Flux, Soul). | + +**Setup:** paste your `KEY_ID:KEY_SECRET` credentials (from +*cloud.higgsfield.ai → API Keys*) into **Settings → Keys → Higgsfield**. +The key is stored in the Keychain and never leaves your device except to +Higgsfield's servers. + +**Async job pattern:** generation is asynchronous — `higgsfield_generate_video` +returns instantly with a `request_id`. The agent then polls +`higgsfield_check_video` until the status reaches `completed` (or a terminal +state like `failed` / `nsfw`). Pricing is credit-based on Higgsfield Cloud; +failed or moderation-flagged jobs are automatically refunded. + +If the API key is not set, any Higgsfield tool call returns +`{"error": "higgsfield_not_connected"}` with a hint to configure the key. + ## Status Early open-source release. APIs and structure will change. Contributions and diff --git a/Secrets.xcconfig.example b/Secrets.xcconfig.example index 97abe88..f9147ad 100644 --- a/Secrets.xcconfig.example +++ b/Secrets.xcconfig.example @@ -32,5 +32,6 @@ ANTHROPIC_API_KEY = // Set this and Kimi K2.6 becomes the default model on first launch. KIMI_API_KEY = CURSOR_API_KEY = +HIGGSFIELD_API_KEY = OBSIDIAN_API_KEY = OBSIDIAN_BASE_URL =