Table of Contents
Tamagui とは?
Tamaguiは、React Native と Webのどちらにも対応したUI Kitです。
  デザインがとても可愛らしいのですが、その可愛らしさとは裏腹に、多くのUIコンポーネントがあり、とてもパワフルです。
Tamagui を利用することによって、ネイティブアプリとWebアプリで同じコンポーネントを利用することができるので、効率良く開発することができます。
Expo には、Expo Webを利用することによって、Webアプリを作成することも可能ですが、Tamaguiを使うことによって、ネイティブアプリはExpoで実装し、WebアプリをNext.jsやRemix等のフレームワークを利用した柔軟な実装が可能であることがメリットだと思います。
作成したアプリ
create-tamagui というTamaguiが提供しているコマンドを利用すると Web は Next.js、ネイティブは React Native (Expo) を使用した、solito というライブラリによる、モノレポのプロジェクトテンプレートを作成できます。
  今回は、その雛形を利用して、Open AI API を利用したAIチャットアプリのネイティブ版とWebアプリ版を、モノレポで開発してみます。
- ネイティブアプリの画面
 

- Webアプリの画面
 

実装手順
Tamagui でアプリの雛形を作成
Tamaguiのドキュメントを参照しながら、アプリの雛形を作っていきます。
  下記コマンドを実行して、アプリの雛形を作成します。
npm create tamagui@latest
アプリの雛形の構成は下記のようになっており、packagesで実装した機能を、Expo、Nextで呼び出して使うことでコードを共通化しています。
apps/
    expo
    next
packages/
アプリの雛形ができたら、対象のディレクトリに移動して、ネイティブアプリとWebアプリを起動してみます。
ネイティブアプリ(Expo)
npm run native
Webアプリ(Next)
npm run web
ネイティブアプリは、Expoなので、Expo Goアプリで開くことができます。
Expo Goアプリが入っていれば、実機でも見れますし、シュミレータでも確認できます。
実装の確認やデバッグがスムーズにできるのが、Expoの魅力の一つですね。
Webアプリは、http://localhost:3000で確認することが出来るはずです。
※実行時に、URLが表示されます
ネイティブアプリの画面

Webアプリの画面

チャット部分の実装
続いて、作成した雛形を修正しながら、チャット画面を作成していきます。
packages 配下に、チャット画面を実装し、Next.js および Expo から呼び出す方針で実装します。
まずpackages/app/featuresに、chatディレクトリを作成し、下記を実装します。
chat-screen.tsx
import React from 'react'
import { ScrollView, Stack, YStack } from '@my/ui'
import { Chats } from './chats'
import { InputForm } from './input-form'
import { Message } from '../../types/message'
import { generateChatMessage } from 'app/services/openAi/generateChatMessage'
import { CHAT_ROLE } from 'app/constants/chat-role'
import { INITIAL_SYSTEM_PROMPT } from 'app/constants/prompt'
const initialChats: Message[] = [{ role: CHAT_ROLE.SYSTEM, content: INITIAL_SYSTEM_PROMPT }]
export function ChatScreen() {
  const [chats, setChats] = React.useState<Message[]>(initialChats)
  const handleSubmit = async (message: Message) => {
    setChats((prevChats) => [...prevChats, message])
    const newMessageList = chats.concat(message)
    const aiMessages = await generateChatMessage(newMessageList)
    setChats((prevChats) => [...prevChats, aiMessages])
  }
  return (
    <Stack p={'$2'} position="relative" height={'100%'}>
      <Stack height={'88%'}>
        <ScrollView>
          <Stack height="100%" p={'$2'} gap={'$4'}>
            <Chats chats={chats} />
          </Stack>
        </ScrollView>
      </Stack>
      <Stack position={'absolute'} bottom={0} height={'$8'} width={'100%'}>
        <InputForm onSubmit={handleSubmit} />
      </Stack>
    </Stack>
  )
}
chats.tsx
import React from 'react'
import { Chat } from './chat'
import { YStack } from '@my/ui'
import { Message } from '../../types/message'
type Props = {
  chats: Message[]
}
export function Chats({ chats }: Props) {
  return (
    <YStack>
      {chats.slice(1, chats.length).map((chat, index) => {
        return <Chat role={chat.role} content={chat.content} key={index} />
      })}
    </YStack>
  )
}
chat.tsx
import { XStack, Avatar, Text, YStack } from '@my/ui'
import { useEffect, useRef, useState } from 'react'
import { Message } from '../../types/message'
import { CHAT_ROLE } from 'app/constants/chat-role'
export function Chat({ content, role }: Message) {
  const [chatMessage, setChatMessage] = useState('')
  const [currentIndex, setCurrentIndex] = useState(0)
  useEffect(() => {
    if (currentIndex < content.length) {
      const timeoutId = setTimeout(() => {
        setChatMessage((prevText) => prevText + content[currentIndex])
        setCurrentIndex((prevIndex) => prevIndex + 1)
      }, 80)
      return () => {
        clearTimeout(timeoutId)
      }
    }
  }, [content, currentIndex])
  return (
    <YStack
      style={{
        alignSelf: role === CHAT_ROLE.ASSISTANT ? 'flex-start' : 'flex-end',
      }}
      animation="bouncy"
      enterStyle={{
        opacity: 0,
        y: 100,
      }}
      opacity={1}
      y={0}
    >
      <XStack
        gap="$2"
        flexDirection={role === CHAT_ROLE.ASSISTANT ? 'row' : 'row-reverse'}
        marginTop="$4"
        width="80%"
      >
        {role === CHAT_ROLE.ASSISTANT && (
          <Avatar circular size="$3">
            <Avatar.Image src={'https://picsum.photos/id/870/200/300'} />
            <Avatar.Fallback bc="gray" />
          </Avatar>
        )}
        <XStack flexDirection="column">
          {role === CHAT_ROLE.ASSISTANT && (
            <XStack alignSelf="flex-start" opacity={0.4}>
              <Text fontStyle="italic" fontSize={16} fontWeight={'500'}>
                Assistant
              </Text>
            </XStack>
          )}
          <XStack
            borderWidth={1}
            borderColor={'#444'}
            bg={role === CHAT_ROLE.ASSISTANT ? 'rgb(200,200,200)' : 'rgb(92,174,178)'}
            p="$3"
            width="auto"
            marginTop="$2"
            borderRadius={20}
            borderBottomRightRadius={role === CHAT_ROLE.ASSISTANT ? 20 : 0}
            borderTopLeftRadius={role === CHAT_ROLE.ASSISTANT ? 0 : 20}
          >
            <Text
              fontSize={20}
              fontWeight={'500'}
              color={role === CHAT_ROLE.ASSISTANT ? '#222' : '#fff'}
            >
              {role === CHAT_ROLE.ASSISTANT ? content || '' : content || ''}
            </Text>
          </XStack>
        </XStack>
      </XStack>
    </YStack>
  )
}
input-form.tsx
import React, { useRef, useState } from 'react'
import { Message } from '../../types/message'
import { Button, Form, Input, Stack, XStack } from '@my/ui'
import { Send } from '@tamagui/lucide-icons'
import { CHAT_ROLE } from 'app/constants/chat-role'
type InputFormProps = {
  onSubmit: (message: Message) => void
}
export function InputForm({ onSubmit }: InputFormProps) {
  const [value, setValue] = useState('')
  const handleSubmit = () => {
    if (value) {
      onSubmit({
        role: CHAT_ROLE.USER,
        content: value,
      })
      setValue('')
    }
  }
  return (
    <Form onSubmit={handleSubmit}>
      <XStack
        gap={'$2'}
        justifyContent="center"
        paddingLeft={'$8'}
        paddingRight={'$6'}
        // borderWidth={1}
      >
        <Input
          value={value}
          onChangeText={(value) => setValue(value)}
          placeholder="Type something"
          width={'100%'}
        />
        <Form.Trigger asChild>
          <Button
            circular
            icon={
              <Stack alignSelf="center">
                <Send size="$2" color={'#eee'} />
              </Stack>
            }
            aria-label="send"
            size={'$4'}
            bg={'rgb(92,174,178)'}
          />
        </Form.Trigger>
      </XStack>
    </Form>
  )
}
続いて、Next.js、Expoからチャット画面を呼び出す処理を実装します。
Next.js
apps/next/pages/chat/index.tsx
import ChatScreen from 'app/chat/page'
import Head from 'next/head'
export default function Page() {
  return (
    <>
      <Head>
        <title>Chat</title>
      </Head>
      <ChatScreen />
    </>
  )
}
Expo
apps/expo/app/chat/index.tsx
import { ChatScreen } from 'app/features/chat/chat-screen'
import { Stack } from 'expo-router'
export default function Screen() {
  return (
    <>
      <Stack.Screen
        options={{
          title: 'Chat',
        }}
      />
      <ChatScreen />
    </>
  )
}
Open AI API を利用する実装
次に、Open AI API を利用して、Chatの返信を生成する関数を実装します。
今回は、Langchainを利用するので、ライブラリをインストールします。
yarn add langchain
※ Langchainとは、大規模言語モデル(Large Language Model: LLM)を利用してサービスの開発をしたいときに便利に使えるライブラリです。
Next.jsは、これで問題ないのですが、Expoの方は、Polyfillがないとエラーが発生してしまうので、追加でライブラリをインストールします。
yarn add web-streams-polyfill react-native-url-polyfill text-encoding-polyfill
続いて、open APIにリクエストを送る処理を実装します。
packages/app/services/openAi/generateChatMessage.tsに作成しました。
import { CHAT_ROLE } from 'app/constants/chat-role'
import { Message } from 'app/types/message'
import { ChatOpenAI } from 'langchain/chat_models/openai'
import { AIMessage, HumanMessage, SystemMessage } from 'langchain/schema'
export async function generateChatMessage(messages: Message[]): Promise<Message> {
  try {
    const openAiApiKey = <YOUR-OPEN-AI-API-KEY>
    if (openAiApiKey === undefined) {
      throw new Error('OpenAI API key not configured, please follow instructions in README.md')
    }
    const model = new ChatOpenAI({
      openAIApiKey: openAiApiKey,
      modelName: 'gpt-4',
      maxTokens: 100,
    })
    const messageList = messages.map((message) => {
      if (message.role === 'system') {
        return new SystemMessage(message.content)
      } else if (message.role === 'assistant') {
        return new AIMessage(message.content)
      } else if (message.role === 'user') {
        return new HumanMessage(message.content)
      } else {
        throw new Error(`Unknown message role: ${message.role}`)
      }
    })
    const modelMessage = await model.call(messageList)
    return { role: CHAT_ROLE.ASSISTANT, content: modelMessage.content }
  } catch (error: any) {
    throw new Error('An error occurred during your request.')
  }
}
Expo側にのみpolyfillを適用するため、apps/expo/app/_layout.tsx に下記を追加します。
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Provider } from 'app/provider'
import { useFonts } from 'expo-font'
import { Stack } from 'expo-router'
import { useColorScheme } from 'react-native'
+ import 'text-encoding-polyfill'
+ import 'react-native-url-polyfill/auto'
+ import 'web-streams-polyfill/dist/polyfill.min.js'
export default function HomeLayout() {
  const [loaded] = useFonts({
    Inter: require('@tamagui/font-inter/otf/Inter-Medium.otf'),
    InterBold: require('@tamagui/font-inter/otf/Inter-Bold.otf'),
  })
  const scheme = useColorScheme()
  if (!loaded) {
    return null
  }
  return (
    <Provider>
      <ThemeProvider value={scheme === 'dark' ? DarkTheme : DefaultTheme}>
        <Stack />
      </ThemeProvider>
    </Provider>
  )
}
これで、Open AI API を利用したアプリが実装できました。
まとめ
Tamagui を利用して、ネイティブ側はExpo、Web側はNext.js で開発することができました。
モノレポで管理できるだけでなく、共通のソースコードで開発できるのがとても良いですね。
今回は、共通のソースコードで実装しましたが、ネイティブでしか実現できない機能や、Web特有の機能は、分けて実装することも問題なく出来るので、導入しやすいのではないかと思います。
是非、お試しください。
参考記事