Skip to content

A native Android app created with React Native on the Expo framework. Chat with assistants from OpenAI, Anthropic and Mistral.

Notifications You must be signed in to change notification settings

jorgenlt/smartbot

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SmartBot - Chat with assistants from OpenAI, Anthropic and Mistral

A native Android app created with React Native on the Expo framework.

Building and Running the App

Follow the steps below to build and run the SmartBot app (requires Android Studio SDK):

  1. Install the app dependencies by running npm install.
  2. Test the app using npx expo start.
  3. Build the .apk file by running eas build -p android --profile preview.
  4. Scan QR code with your phone to download the app
  5. Go to Settings to enter your API key of the providers you wish to use.

Smartbot screenshot Smartbot screenshot

Smartbot screenshot Smartbot screenshot

Smartbot screenshot Smartbot screenshot

Features

  • Chat with assistants from OpenAi, Anthropic and Mistral
  • Switch between providers and models
  • Create, browse and delete conversations
  • Continue an earlier conversation
  • Filter conversations by keywords
  • Copy a message to clipboard
  • Share a message

Technologies

Smartbot is a native mobile app for Android that was built using React Native and the Expo framework. The app connects to one of the AI assistant providers chosen by the user. To manage state and actions across the app, it uses Redux Toolkit. When the app needs to get data asynchronously from the API, it uses the Redux Thunk middleware. The app also utilizes the Axios library for API requests, the Date-fns library for working with dates and times, and Redux Persist to persist some Redux state.

Project structure

├── App.js
├── app.json
├── assets
│   ├── bubbles.mp3
│   ├── click.mp3
│   ├── icon-old.png
│   ├── icon.png
│   ├── splash-old.png
│   ├── splash.png
│   └── typing.mp3
├── babel.config.js
├── eas.json
├── package.json
├── package-lock.json
├── README.md
└── src
    ├── api
    │   ├── anthropicApi.js
    │   ├── mistralApi.js
    │   └── openAiApi.js
    ├── app
    │   └── store.js
    ├── common
    │   └── utils
    │       ├── capitalizeFirstWord.js
    │       ├── findObject.js
    │       ├── formatDate.js
    │       └── turncateString.js
    ├── components
    │   └── CancelButton.js
    ├── features
    │   └── chat
    │       ├── chatSlice.js
    │       └── NewChat.js
    ├── pages
    │   ├── ChatPage
    │   │   ├── ChatInput.js
    │   │   ├── ChatPage.js
    │   │   └── Conversation.js
    │   ├── ConversationsPage
    │   │   └── ConversationsPage.js
    │   └── SettingsPage
    │       ├── ChatSettings.js
    │       ├── ChatSettingsProvider.js
    │       ├── Setting.js
    │       └── SettingsPage.js
    └── styles
        └── colors.js

Technical challenges

The Redux slice

Redux state management and actions.

// src/features/chat/chatSlice.js

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import fetchOpenAiChatCompletion from "../../api/openAiApi";
import fetchAnthropicChatCompletion from "../../api/anthropicApi";
import fetchMistralChatCompletion from "../../api/mistralApi";
import uuid from "react-native-uuid";

const initialState = {
  conversations: {},
  currentId: null,
  status: "idle",
  providers: {
    current: { name: "OpenAI", provider: "openAi", model: "gpt-3.5-turbo" },
    openAi: {
      name: "OpenAI",
      key: null,
      model: "gpt-3.5-turbo",
      models: ["gpt-3.5-turbo", "gpt-4o"],
    },
    anthropic: {
      name: "Anthropic",
      key: null,
      model: "claude-3-sonnet-20240229",
      models: [
        "claude-3-haiku-20240307",
        "claude-3-sonnet-20240229",
        "claude-3-opus-20240229",
      ],
    },
    mistral: {
      name: "Mistral",
      key: null,
      model: "mistral-small-latest",
      models: ["mistral-small-latest", "mistral-large-latest"],
    },
  },
  error: null,
};

// Get chat completion from ChatGPT (OpenAI) using async thunk
export const getChatResponseThunk = createAsyncThunk(
  "chat/getResponse",
  async (message, { getState }) => {
    const {
      chat: { currentId, conversations, providers },
    } = getState();

    if (!currentId) {
      return; // Exit early if currentId is falsy
    }

    const context = conversations[currentId].messages;
    const provider = providers.current.provider;

    try {
      let response;

      switch (provider) {
        case "openAi":
          response = await fetchOpenAiChatCompletion(
            context,
            message,
            providers
          );
          break;
        case "anthropic":
          response = await fetchAnthropicChatCompletion(
            context,
            message,
            providers
          );
          break;
        case "mistral":
          response = await fetchMistralChatCompletion(
            context,
            message,
            providers
          );
          break;
        default:
          throw new Error("Unsupported chat completion provider: " + provider);
      }

      return response;
    } catch (error) {
      return Promise.reject(error.message);
    }
  }
);

// Chat slice of the Redux store
export const chat = createSlice({
  name: "chat",
  initialState,
  reducers: {
    addConversation: (state) => {
      const id = uuid.v4();

      state.currentId = id;
      state.conversations[id] = {
        created: Date.now(),
        messages: [
          { content: "Hello! How can I assist you today?", role: "assistant" },
        ],
      };
    },
    updateMessages: (state, action) => {
      const { currentId } = state;
      const message = action.payload;

      if (currentId) {
        state.conversations[currentId]?.messages.push(message);
      }
    },
    deleteConversation: (state, action) => {
      const id = action.payload;

      delete state.conversations[id];

      state.currentId = null;
    },
    deleteConversations: (state) => {
      state.conversations = {};
      state.keys = {};
      state.currentId = null;
    },
    deleteKey: (state, action) => {
      const { provider } = action.payload;
      state.providers[provider].key = null;
    },
    updateCurrentId: (state, action) => {
      state.currentId = action.payload;
    },
    addKey: (state, action) => {
      const { provider, apiKey } = action.payload;
      state.providers[provider].key = apiKey;
    },
    setProvider: (state, action) => {
      const { provider } = action.payload;
      state.providers.current.name = state.providers[provider].name;
      state.providers.current.provider = provider;
      state.providers.current.model = state.providers[provider].model;
    },
    resetProviders: (state) => {
      // Preserve the current keys
      const openAiKey = state.providers.openAi.key;
      const anthropicKey = state.providers.anthropic.key;
      const mistralKey = state.providers.mistral.key;

      // Reset providers to initial state
      state.providers = {
        ...initialState.providers,
        openAi: {
          ...initialState.providers.openAi,
          key: openAiKey,
        },
        anthropic: {
          ...initialState.providers.anthropic,
          key: anthropicKey,
        },
        mistral: {
          ...initialState.providers.mistral,
          key: mistralKey,
        },
      };
    },
    setModel: (state, action) => {
      const { provider, model } = action.payload;

      const currentProvider = state.providers.current.provider;

      if (provider === currentProvider) {
        state.providers.current.model = model;
        state.providers[provider].model = model;
      } else {
        state.providers[provider].model = model;
      }
    },
  },
  extraReducers: (builder) => {
    builder
      // Case when fetching chat response is pending
      .addCase(getChatResponseThunk.pending, (state) => {
        state.status = "loading";
      })
      // Case where getting chat response is successful (fulfilled)
      .addCase(getChatResponseThunk.fulfilled, (state, action) => {
        state.error = null;
        state.status = "idle";

        const { currentId } = state;
        const { content, role } = action.payload;

        if (currentId && content && role) {
          const message = {
            content,
            role,
          };

          // Push the fetched message into the messages of current conversation
          state.conversations[currentId]?.messages.push(message);
        }
      })
      // Case where getting chat response failed
      .addCase(getChatResponseThunk.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.error.message;
      });
  },
});

// Action creators are generated for each case reducer function
export const {
  updateMessages,
  deleteConversation,
  deleteConversations,
  addConversation,
  updateCurrentId,
  addKey,
  deleteKey,
  setProvider,
  resetProviders,
  setModel,
} = chat.actions;

export default chat.reducer;

API call

Chat completions API call.

// src/api/anthropicApi.js

import Anthropic from "@anthropic-ai/sdk";

async function fetchAnthropicChatCompletion(context, prompt, providers) {
  const { key: API_KEY, model: MODEL } = providers.anthropic;

  const userMessage = {
    role: "user",
    content: prompt,
  };

  const messages = [...context, userMessage].slice(1); // Anthropic API messages must start with a message with the role "user", remove the first item in the array

  const anthropic = new Anthropic({
    apiKey: API_KEY,
  });

  try {
    const msg = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 3500,
      messages: messages,
    });

    const role = msg.role;

    const content = msg.content[0].text;

    return {
      role,
      content,
    };
  } catch (error) {
    console.error(
      "Error in fetchAnthropicChatCompletion:",
      error.message || error.response.data.error?.message
    );
    throw error;
  }
}

export default fetchAnthropicChatCompletion;

Displaying messages

Displaying messages in a conversation.

// src/features/chat/Messages.js

import { useRef, useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import {
  StyleSheet,
  Text,
  View,
  ScrollView,
  Pressable,
  Share,
  Alert
} from 'react-native'
import { Audio } from 'expo-av';
import * as Clipboard from 'expo-clipboard'
import { format } from 'date-fns'
import uuid from 'react-native-uuid'
import { colors, chat, base } from '../../styles/colors'
import { Flow } from 'react-native-animated-spinkit'

const Messages = () => {
  const [typingSound, setTypingSound] = useState();

  const { currentId, conversations, error, status } = useSelector(state => state.chat);
  const messages = conversations[currentId]?.messages;
  const date = conversations[currentId]?.created;

  let formatedDate;

  if (date) {
    formatedDate = format(date, 'LLLL d, y');
  }

  // Function to copy text(messages) to clipboard.
  const handleCopyToClipboard = async text => {
    await Clipboard.setStringAsync(text);
    Alert.alert('', 'Copied to Clipboard.')
  };

  // Share message
  const handleShare = async message => {
    try {
      const result = await Share.share({
        message:
          message,
      });
    } catch (error) {
      Alert.alert(error.message);
    }
  };

  // Creating the message elements to render in the ScrollView.
  let messageElements;

  if (messages) {
    messageElements = messages.map(message => {
      return (
        <View
        style={message.role === 'assistant' ? styles.messageWrapperAssistant : styles.messageWrapperUser}
        key={uuid.v4()}
        >
          <Pressable
            style={message.role === 'assistant' ? styles.messageAssistant : styles.messageUser}
            onLongPress={() => handleShare(message.content)}
            onPress={() => handleCopyToClipboard(message.content)}
          >
            <Text>
              {message.content}
            </Text>
          </Pressable>
        </View>
      )
    });
  }

  // Ref for ScrollView
  const scrollRef = useRef();

  // Sound effects
  // Load sound when component mounts
  useEffect(() => {
    async function loadTypingSound() {
      const { sound } = await Audio.Sound.createAsync(require('../../../assets/typing.mp3'));
      setTypingSound(sound);
    }

    loadTypingSound();

     // Cleanup
     return typingSound ? () => {
      typingSound.unloadAsync();
    } : undefined;
  }, []);

  // Function to play sound
  const playTypingSound = async () => {
    if (typingSound) {
      await typingSound.replayAsync();
    }
  };

  // Play typing sound when status is 'loading'
  useEffect(() => {
    if (status === 'loading') {
      playTypingSound();
    } else if (typingSound && status === 'idle') {
      // Stop typing sound
      typingSound.stopAsync();
    }
  }, [status])

  return (
    <>
      <ScrollView
        contentContainerStyle={styles.messagesWrapper}
        ref={scrollRef}
        onContentSizeChange={() => scrollRef.current.scrollToEnd({ animated: false })}
        showsHorizontalScrollIndicator={false}
      >
        <View style={styles.date}>
          <Text style={styles.dateText}>{formatedDate}</Text>
        </View>

        {messageElements}

        {
          status === 'loading' &&
          <View style={styles.messageWrapperAssistant}>
            <View style={styles.flowLoader}>
              <Flow size={30} color='#202020'  />
            </View>
          </View>
        }
      </ScrollView>
      {error && <Text>{error}</Text>}
    </>
  )
}

export default Messages

const styles = StyleSheet.create({
  messagesWrapper: {
    paddingHorizontal: 5,
    paddingBottom: 20,
    width: '100%'
  },
  messageWrapperUser: {
    flexDirection: 'row',
    justifyContent: 'flex-end',
    alignItems: 'center',
    marginVertical: 10,
    width: '100%'
  },
  messageWrapperAssistant: {
    flexDirection: 'row',
    justifyContent: 'flex-start',
    alignItems: 'center',
    marginVertical: 10,
  },
  messageUser: {
    backgroundColor: chat.messageUserBg,
    color: colors.text,
    borderRadius: 20,
    borderTopRightRadius: 2,
    padding: 10,
    maxWidth: '90%',
  },
  messageAssistant: {
    backgroundColor: chat.messageAssistantBg,
    color: colors.text,
    borderRadius: 20,
    borderTopLeftRadius: 2,
    padding: 10,
    maxWidth: '90%'
  },
  noMessagesWrapper: {
    height: '100%',
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  noMessages: {
    fontSize: 18
  },
  flowLoader: {
    padding: 15,
    alignItems: 'center',
    backgroundColor: chat.messageAssistantBg,
    color: base.loader,
    borderRadius: 20,
    borderTopLeftRadius: 2,
  },
  date: {
    width: '100%',
    alignItems: 'center',
    marginTop: 10
  },
  dateText: {
    color: colors.gray,
  }
})

Upcoming features

  • Dark mode
  • Enter API key in app
  • Switch between models
  • Add Antropic as provider
  • Add Mistral as provider