examples/simple-chatbot: move clients to client directory

This commit is contained in:
Aleix Conchillo Flaqué
2025-01-11 19:10:59 -08:00
parent a8ae79831e
commit a04a920e54
113 changed files with 6 additions and 6 deletions

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "appstore.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "Square Black.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,8 @@
<svg width="450" height="450" viewBox="0 0 450 450" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="450" height="450" fill="white"/>
<path d="M104.772 129.77C109.448 128.01 114.725 129.331 118.02 133.086L160.936 182H289.064L331.98 133.086C335.275 129.331 340.552 128.01 345.228 129.77C349.904 131.531 353 136.004 353 141V249H391V273H329V172.873L303.52 201.915C301.242 204.511 297.955 206 294.5 206H155.5C152.045 206 148.758 204.511 146.48 201.915L121 172.873V273H59V249H97V141C97 136.004 100.096 131.531 104.772 129.77Z" fill="black"/>
<path d="M329 297H391V321H329V297Z" fill="black"/>
<path d="M59 297H121V321H59V297Z" fill="black"/>
<path d="M187 257C187 265.837 179.837 273 171 273C162.163 273 155 265.837 155 257C155 248.164 162.163 241 171 241C179.837 241 187 248.164 187 257Z" fill="black"/>
<path d="M295 257C295 265.837 287.837 273 279 273C270.163 273 263 265.837 263 257C263 248.164 270.163 241 279 241C287.837 241 295 248.164 295 257Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 982 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
</array>
<key>NSCameraUsageDescription</key>
<string>Camera is necessary for transmitting video in a call</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone is necessary for transmitting audio in a call</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
@main
struct SimpleChatbotApp: App {
@StateObject var callContainerModel = CallContainerModel()
var body: some Scene {
WindowGroup {
if (!callContainerModel.isInCall) {
PreJoinView().environmentObject(callContainerModel)
} else {
MeetingView().environmentObject(callContainerModel)
}
}
}
}

View File

@@ -0,0 +1,199 @@
import SwiftUI
import RTVIClientIOSDaily
import RTVIClientIOS
class CallContainerModel: ObservableObject {
@Published var voiceClientStatus: String = TransportState.disconnected.description
@Published var isInCall: Bool = false
@Published var isBotReady: Bool = false
@Published var timerCount = 0
@Published var isMicEnabled: Bool = false
@Published var toastMessage: String? = nil
@Published var showToast: Bool = false
@Published
var remoteAudioLevel: Float = 0
@Published
var localAudioLevel: Float = 0
private var meetingTimer: Timer?
var rtviClientIOS: RTVIClient?
init() {
// Changing the log level
RTVIClientIOS.setLogLevel(.warn)
}
@MainActor
func connect(backendURL: String) {
let baseUrl = backendURL.trimmingCharacters(in: .whitespacesAndNewlines)
if(baseUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty){
self.showError(message: "Need to fill the backendURL. For more info visit: https://bots.daily.co")
return
}
let currentSettings = SettingsManager.getSettings()
let rtviClientOptions = RTVIClientOptions.init(
enableMic: currentSettings.enableMic,
enableCam: false,
params: RTVIClientParams(
baseUrl: baseUrl,
endpoints: RTVIURLEndpoints(connect: "/connect")
)
)
self.rtviClientIOS = RTVIClient.init(
transport: DailyTransport.init(options: rtviClientOptions),
options: rtviClientOptions
)
self.rtviClientIOS?.delegate = self
self.rtviClientIOS?.start() { result in
if case .failure(let error) = result {
self.showError(message: error.localizedDescription)
self.rtviClientIOS = nil
}
}
// Selecting the mic based on the preferences
if let selectedMic = currentSettings.selectedMic {
self.rtviClientIOS?.updateMic(micId: MediaDeviceId(id:selectedMic), completion: nil)
}
self.saveCredentials(backendURL: baseUrl)
}
@MainActor
func disconnect() {
self.rtviClientIOS?.disconnect(completion: nil)
}
func showError(message: String) {
self.toastMessage = message
self.showToast = true
// Hide the toast after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.showToast = false
self.toastMessage = nil
}
}
@MainActor
func toggleMicInput() {
self.rtviClientIOS?.enableMic(enable: !self.isMicEnabled) { result in
switch result {
case .success():
self.isMicEnabled = self.rtviClientIOS?.isMicEnabled ?? false
case .failure(let error):
self.showError(message: error.localizedDescription)
}
}
}
private func startTimer(withExpirationTime expirationTime: Int) {
let currentTime = Int(Date().timeIntervalSince1970)
self.timerCount = expirationTime - currentTime
self.meetingTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
DispatchQueue.main.async {
self.timerCount -= 1
}
}
}
private func stopTimer() {
self.meetingTimer?.invalidate()
self.meetingTimer = nil
self.timerCount = 0
}
func saveCredentials(backendURL: String) {
var currentSettings = SettingsManager.getSettings()
currentSettings.backendURL = backendURL
// Saving the settings
SettingsManager.updateSettings(settings: currentSettings)
}
}
extension CallContainerModel:RTVIClientDelegate, LLMHelperDelegate {
private func handleEvent(eventName: String, eventValue: Any? = nil) {
if let value = eventValue {
print("RTVI Demo, received event:\(eventName), value:\(value)")
} else {
print("RTVI Demo, received event: \(eventName)")
}
}
func onTransportStateChanged(state: TransportState) {
Task { @MainActor in
self.handleEvent(eventName: "onTransportStateChanged", eventValue: state)
self.voiceClientStatus = state.description
self.isInCall = ( state == .connecting || state == .connected || state == .ready || state == .authenticating )
}
}
func onBotReady(botReadyData: BotReadyData) {
Task { @MainActor in
self.handleEvent(eventName: "onBotReady.")
self.isBotReady = true
if let expirationTime = self.rtviClientIOS?.expiry() {
self.startTimer(withExpirationTime: expirationTime)
}
}
}
func onConnected() {
Task { @MainActor in
self.isMicEnabled = self.rtviClientIOS?.isMicEnabled ?? false
}
}
func onDisconnected() {
Task { @MainActor in
self.stopTimer()
self.isBotReady = false
}
}
func onRemoteAudioLevel(level: Float, participant: Participant) {
Task { @MainActor in
self.remoteAudioLevel = level
}
}
func onUserAudioLevel(level: Float) {
Task { @MainActor in
self.localAudioLevel = level
}
}
func onUserTranscript(data: Transcript) {
Task { @MainActor in
if (data.final ?? false) {
self.handleEvent(eventName: "onUserTranscript", eventValue: data.text)
}
}
}
func onBotTranscript(data: String) {
Task { @MainActor in
self.handleEvent(eventName: "onBotTranscript", eventValue: data)
}
}
func onError(message: String) {
Task { @MainActor in
self.handleEvent(eventName: "onError", eventValue: message)
self.showError(message: message)
}
}
func onTracksUpdated(tracks: Tracks) {
Task { @MainActor in
self.handleEvent(eventName: "onTracksUpdated", eventValue: tracks)
}
}
}

View File

@@ -0,0 +1,35 @@
import SwiftUI
import RTVIClientIOS
class MockCallContainerModel: CallContainerModel {
override init() {
}
override func connect(backendURL: String) {
print("connect")
}
override func disconnect() {
print("disconnect")
}
override func showError(message: String) {
self.toastMessage = message
self.showToast = true
// Hide the toast after 5 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
self.showToast = false
self.toastMessage = nil
}
}
func startAudioLevelSimulation() {
// Simulate audio level changes
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
let newLevel = Float.random(in: 0...1)
self.remoteAudioLevel = newLevel
self.localAudioLevel = newLevel
}
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
struct MeetingView: View {
@State private var showingSettings = false
@EnvironmentObject private var model: CallContainerModel
var body: some View {
VStack {
// Header Toolbar
HStack {
Image("dailyBot")
.resizable()
.frame(width: 48, height: 48)
Spacer()
HStack {
Image(systemName: "stopwatch")
.resizable()
.frame(width: 24, height: 24)
Text(timerString(from: self.model.timerCount))
.font(.headline)
}.padding()
.background(Color.timer)
.cornerRadius(12)
}
.padding()
// Main Panel
VStack {
VStack {
WaveformView(audioLevel: model.remoteAudioLevel, isBotReady: model.isBotReady, voiceClientStatus: model.voiceClientStatus)
}
.frame(maxHeight: .infinity)
VStack {
HStack {
MicrophoneView(audioLevel: model.localAudioLevel, isMuted: !self.model.isMicEnabled)
.frame(width: 160, height: 160)
.onTapGesture {
self.model.toggleMicInput()
}
}
}
.frame(height: 120)
}
.frame(maxHeight: .infinity)
.padding()
// Bottom Panel
VStack {
HStack {
Button(action: {
self.showingSettings = true
}) {
HStack {
Image(systemName: "gearshape")
.resizable()
.frame(width: 24, height: 24)
Text("Settings")
}
.frame(maxWidth: .infinity)
.padding()
.sheet(isPresented: $showingSettings) {
SettingsView(showingSettings: $showingSettings).environmentObject(self.model)
}
}
.border(Color.buttonsBorder, width: 1)
.cornerRadius(12)
}
.foregroundColor(.black)
.padding([.top, .horizontal])
Button(action: {
self.model.disconnect()
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.resizable()
.frame(width: 24, height: 24)
Text("End")
}
.frame(maxWidth: .infinity)
.padding()
}
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(12)
.padding([.bottom, .horizontal])
}
}
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
func timerString(from count: Int) -> String {
let hours = count / 3600
let minutes = (count % 3600) / 60
let seconds = count % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = MeetingView().environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct PreJoinView: View {
@State var backendURL: String
@EnvironmentObject private var model: CallContainerModel
init() {
let currentSettings = SettingsManager.getSettings()
self.backendURL = currentSettings.backendURL
}
var body: some View {
VStack(spacing: 20) {
Image("pipecat")
.resizable()
.frame(width: 80, height: 80)
Text("Pipecat Client iOS.")
.font(.headline)
TextField("Server URL", text: $backendURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: .infinity)
.padding([.bottom, .horizontal])
Button("Connect") {
self.model.connect(backendURL: self.backendURL)
}
.padding()
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
.frame(maxHeight: .infinity)
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
}
#Preview {
PreJoinView().environmentObject(MockCallContainerModel() as CallContainerModel)
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct MicrophoneView: View {
var audioLevel: Float // Current audio level
var isMuted: Bool // Muted state
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let circleSize = width * 0.9
let innerCircleSize = width * 0.82
let audioCircleSize = CGFloat(audioLevel) * (width * 0.95)
ZStack {
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: circleSize)
Circle()
.fill(isMuted ? Color.disabledMic : Color.backgroundCircle)
.frame(width: innerCircleSize)
if !isMuted {
Circle()
.fill(Color.micVolume)
.opacity(0.5)
.frame(width: audioCircleSize)
.animation(.easeInOut(duration: 0.2), value: audioLevel)
}
Image(systemName: isMuted ? "mic.slash.fill" : "mic.fill")
.resizable()
.scaledToFit()
.frame(width: width * 0.2)
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensures the ZStack is centered
}
}
}
#Preview {
MicrophoneView(audioLevel: 1, isMuted: false)
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct ToastModifier: ViewModifier {
var message: String?
var isShowing: Bool
func body(content: Content) -> some View {
ZStack {
content
if isShowing, let message = message {
VStack {
Text(message)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
.transition(.slide)
.padding(.top, 50)
Spacer()
}
.animation(.easeInOut(duration: 0.5), value: isShowing)
}
}
}
}
extension View {
func toast(message: String?, isShowing: Bool) -> some View {
self.modifier(ToastModifier(message: message, isShowing: isShowing))
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct WaveformView: View {
var audioLevel: Float
var isBotReady: Bool
var voiceClientStatus: String
@State
private var audioLevels: [Float] = Array(repeating: 0, count: 5)
private let dotCount = 5
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
HStack {
Spacer()
ZStack {
// Outer gray border
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
// Gray middle
Circle()
.fill(isBotReady ? Color.backgroundCircle : Color.backgroundCircleNotConnected)
.frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82)
if isBotReady {
if audioLevel > 0 {
// Waveform bars inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { index in
Rectangle()
.fill(Color.white)
.frame(height: CGFloat(audioLevels[index]) * (geometry.size.height))
.cornerRadius(12)
.animation(.easeInOut(duration: 0.2), value: audioLevels[index])
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 5)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.width * 0.5)
.mask(Circle().frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82))
} else {
// Dots inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { _ in
Circle()
.fill(Color.white)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.height * 0.5)
}
} else {
// Gray circle with loading icon when not connected
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(2) // Adjust size of the loading spinner
.padding()
Text(voiceClientStatus)
.foregroundColor(.white)
.font(.headline)
}
}
}
Spacer()
}
Spacer()
}
}
.onChange(of: audioLevel) { oldLevel, newLevel in
// The audio level that we receive from the bot is usually too low
// so just increasing it so we can see a better graph but
// making sure that it is not higher than the maximum 1
var audioLevel = audioLevel + 0.4
if(audioLevel > 1) {
audioLevel = 1
}
// Update the array and shift values
audioLevels.removeFirst()
audioLevels.append(newLevel)
}
}
}
#Preview {
WaveformView(audioLevel: 0, isBotReady: false, voiceClientStatus: "idle")
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
public extension Color {
static let backgroundCircle = Color(hex: "#374151")
static let backgroundCircleNotConnected = Color(hex: "#D1D5DB")
static let backgroundApp = Color(hex: "#F9FAFB")
static let buttonsBorder = Color(hex: "#E5E7EB")
static let micVolume = Color(hex: "#86EFAC")
static let timer = Color(hex: "#E5E7EB")
static let disabledMic = Color(hex: "#ee6b6e")
static let disabledVision = Color(hex: "#BBF7D0")
init(hex: String) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let red = Double((rgb >> 16) & 0xFF) / 255.0
let green = Double((rgb >> 8) & 0xFF) / 255.0
let blue = Double(rgb & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue)
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
class SettingsManager {
private static let preferencesKey = "settingsPreference"
static func getSettings() -> SettingsPreference {
if let data = UserDefaults.standard.data(forKey: preferencesKey),
let settings = try? JSONDecoder().decode(SettingsPreference.self, from: data) {
return settings
} else {
// default values in case we don't have any settings
return SettingsPreference(enableMic: true, backendURL: "http://YOUR_IP:7860")
}
}
static func updateSettings(settings: SettingsPreference) {
if let data = try? JSONEncoder().encode(settings) {
UserDefaults.standard.set(data, forKey: preferencesKey)
}
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
struct SettingsPreference: Codable {
var selectedMic: String?
var enableMic: Bool
var backendURL: String
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
import RTVIClientIOS
struct SettingsView: View {
@EnvironmentObject private var model: CallContainerModel
@Binding var showingSettings: Bool
@State private var selectedMic: MediaDeviceId? = nil
@State private var isMicEnabled: Bool = true
@State private var backendURL: String = ""
var body: some View {
let microphones = self.model.rtviClientIOS?.getAllMics() ?? []
NavigationView {
Form {
Section(header: Text("Audio Settings")) {
List(microphones, id: \.self.id.id) { mic in
Button(action: {
self.selectMic(mic.id)
}) {
HStack {
Text(mic.name)
Spacer()
if mic.id == self.selectedMic {
Image(systemName: "checkmark")
}
}
}
}
}
Section(header: Text("Start options")) {
Toggle("Enable Microphone", isOn: $isMicEnabled)
}
Section(header: Text("Server")) {
TextField("Backend URL", text: $backendURL)
.keyboardType(.URL)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
self.saveSettings()
self.showingSettings = false
}
}
}
.onAppear {
self.loadSettings()
}
}
}
private func selectMic(_ mic: MediaDeviceId) {
self.selectedMic = mic
self.model.rtviClientIOS?.updateMic(micId: mic, completion: nil)
}
private func saveSettings() {
let newSettings = SettingsPreference(
selectedMic: selectedMic?.id,
enableMic: isMicEnabled,
backendURL: backendURL
)
SettingsManager.updateSettings(settings: newSettings)
}
private func loadSettings() {
let savedSettings = SettingsManager.getSettings()
if let selectedMic = savedSettings.selectedMic {
self.selectedMic = MediaDeviceId(id: selectedMic)
} else {
self.selectedMic = nil
}
self.isMicEnabled = savedSettings.enableMic
self.backendURL = savedSettings.backendURL
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = SettingsView(showingSettings: .constant(true)).environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}