From 92f5b83c4e22fc55c5dfc6c577807cfa1b4ed982 Mon Sep 17 00:00:00 2001 From: Anup Ghatage Date: Wed, 13 Aug 2025 18:11:01 -0700 Subject: [PATCH] Add function calling in iOS example --- .../LeapChatExample/ChatStore.swift | 133 ++++++++++++++++-- .../LeapChatExample/MessageBubble.swift | 16 ++- .../LeapChatExample/MessageRow.swift | 64 ++++++--- .../LeapChatExample/MessagesListView.swift | 2 +- iOS/LeapChatExample/README.md | 38 ++++- 5 files changed, 216 insertions(+), 37 deletions(-) diff --git a/iOS/LeapChatExample/LeapChatExample/ChatStore.swift b/iOS/LeapChatExample/LeapChatExample/ChatStore.swift index 20ed11b..55dece7 100644 --- a/iOS/LeapChatExample/LeapChatExample/ChatStore.swift +++ b/iOS/LeapChatExample/LeapChatExample/ChatStore.swift @@ -23,16 +23,16 @@ class ChatStore { messages.append( MessageBubble( content: "🔍 Checking LeapSDK integration...", - isUser: false)) + messageType: .assistant)) guard let modelURL = Bundle.main.url( - forResource: "qwen3-1_7b_8da4w", withExtension: "bundle") + forResource: "LFM2-1.2B-8da4w_output_8da8w-seq_4096", withExtension: "bundle") else { messages.append( MessageBubble( - content: "❗️ Could not find qwen3-1_7b_8da4w.bundle in the bundle.", - isUser: false)) + content: "❗️ Could not find LFM2-1.2B-8da4w_output_8da8w-seq_4096.bundle in the bundle.", + messageType: .assistant)) isModelLoading = false return } @@ -41,25 +41,44 @@ class ChatStore { messages.append( MessageBubble( content: "📁 Found model bundle at: \(modelURL.lastPathComponent)", - isUser: false)) + messageType: .assistant)) let modelRunner = try await Leap.load(url: modelURL) self.modelRunner = modelRunner - conversation = Conversation(modelRunner: modelRunner, history: []) + let conversation = Conversation(modelRunner: modelRunner, history: []) + + // Register the compute_sum function + conversation.registerFunction( + LeapFunction( + name: "compute_sum", + description: "Compute sum of a series of numbers", + parameters: [ + LeapFunctionParameter( + name: "values", + type: LeapFunctionParameterType.array(ArrayType( + itemType: LeapFunctionParameterType.string(StringType()) + )), + description: "Numbers to compute sum. Values should be represented as strings." + ) + ] + ) + ) + + self.conversation = conversation messages.append( MessageBubble( content: "✅ Model loaded successfully! You can start chatting.", - isUser: false)) + messageType: .assistant)) } catch { print("Error loading model: \(error)") let errorMessage = "🚨 Failed to load model: \(error.localizedDescription)" - messages.append(MessageBubble(content: errorMessage, isUser: false)) + messages.append(MessageBubble(content: errorMessage, messageType: .assistant)) // Check if it's a LeapError if let leapError = error as? LeapError { print("LeapError details: \(leapError)") messages.append( - MessageBubble(content: "📋 Error type: \(String(describing: leapError))", isUser: false)) + MessageBubble(content: "📋 Error type: \(String(describing: leapError))", messageType: .assistant)) } } @@ -74,12 +93,14 @@ class ChatStore { guard !trimmed.isEmpty else { return } let userMessage = ChatMessage(role: .user, content: [.text(trimmed)]) - messages.append(MessageBubble(content: trimmed, isUser: true)) + messages.append(MessageBubble(content: trimmed, messageType: .user)) input = "" isLoading = true currentAssistantMessage = "" let stream = conversation!.generateResponse(message: userMessage) + var functionCallsToProcess: [LeapFunctionCall] = [] + do { for try await resp in stream { print(resp) @@ -88,19 +109,101 @@ class ChatStore { currentAssistantMessage.append(str) case .complete(_, _): if !currentAssistantMessage.isEmpty { - messages.append(MessageBubble(content: currentAssistantMessage, isUser: false)) + messages.append(MessageBubble(content: currentAssistantMessage, messageType: .assistant)) } currentAssistantMessage = "" isLoading = false - case .functionCall(_): - break // Function calls not used in this example - case default: + case .functionCall(let calls): + functionCallsToProcess.append(contentsOf: calls) + default: break // Handle any other case } } + + // Process function calls after the generation is complete + if !functionCallsToProcess.isEmpty { + await processFunctionCalls(functionCallsToProcess) + } + } catch { + currentAssistantMessage = "Error: \(error.localizedDescription)" + messages.append(MessageBubble(content: currentAssistantMessage, messageType: .assistant)) + currentAssistantMessage = "" + isLoading = false + } + } + + @MainActor + private func processFunctionCalls(_ functionCalls: [LeapFunctionCall]) async { + for call in functionCalls { + switch call.name { + case "compute_sum": + // Extract values from arguments + if let valuesArray = call.arguments["values"] as? [String] { + var sum = 0.0 + for value in valuesArray { + sum += Double(value) ?? 0.0 + } + let result = "Sum = \(sum)" + + // Add tool message to display + messages.append(MessageBubble(content: result, messageType: .tool)) + + // Send tool response back to conversation + let toolMessage = ChatMessage(role: .tool, content: [.text(result)]) + await sendToolResponse(toolMessage) + } else { + let errorResult = "Error: Could not process values for compute_sum" + messages.append(MessageBubble(content: errorResult, messageType: .tool)) + + let toolMessage = ChatMessage(role: .tool, content: [.text(errorResult)]) + await sendToolResponse(toolMessage) + } + default: + let unknownResult = "Tool: \(call.name) is not available" + messages.append(MessageBubble(content: unknownResult, messageType: .tool)) + + let toolMessage = ChatMessage(role: .tool, content: [.text(unknownResult)]) + await sendToolResponse(toolMessage) + } + } + } + + @MainActor + private func sendToolResponse(_ toolMessage: ChatMessage) async { + guard let conversation = conversation else { return } + + isLoading = true + currentAssistantMessage = "" + + let stream = conversation.generateResponse(message: toolMessage) + var functionCallsToProcess: [LeapFunctionCall] = [] + + do { + for try await resp in stream { + print(resp) + switch resp { + case .chunk(let str): + currentAssistantMessage.append(str) + case .complete(_, _): + if !currentAssistantMessage.isEmpty { + messages.append(MessageBubble(content: currentAssistantMessage, messageType: .assistant)) + } + currentAssistantMessage = "" + isLoading = false + case .functionCall(let calls): + functionCallsToProcess.append(contentsOf: calls) + default: + break + } + } + + // Process any additional function calls + if !functionCallsToProcess.isEmpty { + await processFunctionCalls(functionCallsToProcess) + } } catch { currentAssistantMessage = "Error: \(error.localizedDescription)" - messages.append(MessageBubble(content: currentAssistantMessage, isUser: false)) + messages.append(MessageBubble(content: currentAssistantMessage, messageType: .assistant)) currentAssistantMessage = "" isLoading = false } diff --git a/iOS/LeapChatExample/LeapChatExample/MessageBubble.swift b/iOS/LeapChatExample/LeapChatExample/MessageBubble.swift index eb1d4d6..9cdb224 100644 --- a/iOS/LeapChatExample/LeapChatExample/MessageBubble.swift +++ b/iOS/LeapChatExample/LeapChatExample/MessageBubble.swift @@ -1,8 +1,22 @@ import Foundation +enum MessageType { + case user + case assistant + case tool +} + struct MessageBubble { let id = UUID() let content: String - let isUser: Bool + let messageType: MessageType let timestamp: Date = Date() + + var isUser: Bool { + return messageType == .user + } + + var isTool: Bool { + return messageType == .tool + } } diff --git a/iOS/LeapChatExample/LeapChatExample/MessageRow.swift b/iOS/LeapChatExample/LeapChatExample/MessageRow.swift index aad592d..f5cfd2d 100644 --- a/iOS/LeapChatExample/LeapChatExample/MessageRow.swift +++ b/iOS/LeapChatExample/LeapChatExample/MessageRow.swift @@ -4,27 +4,57 @@ struct MessageRow: View { let message: MessageBubble var body: some View { - HStack { - if message.isUser { + if message.isTool { + // Tool message display + HStack(alignment: .top, spacing: 8) { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(.secondary) + .frame(width: 24, height: 24) + .background( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary, lineWidth: 1) + ) + + VStack(alignment: .leading, spacing: 4) { + Text("Tool") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.medium) + + Text(message.content) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .foregroundColor(.primary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + Spacer(minLength: 60) + } + .padding(.horizontal, 4) + } else { + HStack { + if message.isUser { + Spacer(minLength: 60) - Text(message.content) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.blue) - .foregroundColor(.white) - .clipShape(RoundedRectangle(cornerRadius: 18)) - } else { - Text(message.content) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(.systemGray5)) - .foregroundColor(.primary) - .clipShape(RoundedRectangle(cornerRadius: 18)) + Text(message.content) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.blue) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 18)) + } else { + Text(message.content) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(.systemGray5)) + .foregroundColor(.primary) + .clipShape(RoundedRectangle(cornerRadius: 18)) - Spacer(minLength: 60) + Spacer(minLength: 60) + } } + .padding(.horizontal, 4) } - .padding(.horizontal, 4) } } diff --git a/iOS/LeapChatExample/LeapChatExample/MessagesListView.swift b/iOS/LeapChatExample/LeapChatExample/MessagesListView.swift index b8a558e..a96ea5b 100644 --- a/iOS/LeapChatExample/LeapChatExample/MessagesListView.swift +++ b/iOS/LeapChatExample/LeapChatExample/MessagesListView.swift @@ -21,7 +21,7 @@ struct MessagesListView: View { if store.isLoading && !store.currentAssistantMessage.isEmpty { MessageRow( - message: MessageBubble(content: store.currentAssistantMessage, isUser: false) + message: MessageBubble(content: store.currentAssistantMessage, messageType: .assistant) ) .id("streaming") } else if store.isLoading { diff --git a/iOS/LeapChatExample/README.md b/iOS/LeapChatExample/README.md index e4886a6..239715f 100644 --- a/iOS/LeapChatExample/README.md +++ b/iOS/LeapChatExample/README.md @@ -9,6 +9,7 @@ A comprehensive chat application demonstrating advanced LeapSDK features includi - **Message History**: Persistent conversation management - **Rich UI Components**: Custom message rows, input views, and animations - **Error Handling**: Robust error management and user feedback +- **Function Calling**: Tool support with compute_sum example function ## Requirements @@ -55,12 +56,13 @@ LeapChatExample/ ├── ChatStore.swift # Core business logic ├── MessagesListView.swift # Message list component ├── MessageRow.swift # Individual message display - ├── MessageBubble.swift # Message bubble UI + ├── MessageBubble.swift # Message bubble data model + ├── ToolMessageRow.swift # Tool message display component ├── ChatInputView.swift # Input field component ├── TypingIndicator.swift # Typing animation ├── Assets.xcassets # App assets └── Resources/ # Model bundles - └── qwen3-1_7b_8da4w.bundle + └── LFM2-1.2B-8da4w_output_8da8w-seq_4096.bundle ``` ## Code Overview @@ -118,6 +120,36 @@ for try await chunk in modelRunner.generateResponse(for: conversation) { } ``` +### Function Calling +The app includes tool support with a `compute_sum` function that can add numbers: +```swift +// Function registration +conversation.registerFunction( + LeapFunction( + name: "compute_sum", + description: "Compute sum of a series of numbers", + parameters: [ + LeapFunctionParameter( + name: "values", + type: .array(.string), + description: "Numbers to compute sum. Values should be represented as strings." + ) + ] + ) +) + +// Function call handling +case .functionCall(let calls): + for call in calls { + if call.name == "compute_sum" { + let result = computeSum(call.arguments["values"]) + // Display tool result and continue conversation + } + } +``` + +Try asking the model: "Can you compute the sum of 5, 10, and 15?" + ### Conversation Management Maintains chat context across messages: ```swift @@ -146,7 +178,7 @@ Smooth animations for message appearance and typing indicators. Update the model path in `ChatStore.swift`: ```swift let modelRunner = try await Leap.load( - modelPath: Bundle.main.bundlePath + "/qwen3-1_7b_8da4w.bundle" + modelPath: Bundle.main.bundlePath + "/LFM2-1.2B-8da4w_output_8da8w-seq_4096.bundle" ) ```