Open66

ねこの人格を持ったAIと会話ができるWebアプリケーションの作成

keitaknkeitakn

概要

https://zenn.dev/keitakn/scraps/fb037ce188cd5a でSlackbotを作ったので今度はOpenAIのAPIを使ったWebアプリケーションの開発を実施していく。

作る物は「ねこの人格を持ったAIと会話できるサービス」。

LangChainを使うとLLMでGoogle検索を使った回答を生成したり、Webページ上のドキュメントをロードした上で回答を生成する等が比較的簡単に実施できる事が分かったのでそのあたりもチャレンジしていく。

利用技術(フロントエンド)

Next.js + Vercelで行く。今回はNext.js 13から有効になった app ディレクトリを使っていく。

バックエンド

Python + FastAPIでLangChainを使って開発を行う。

バックエンドのアプリケーションは https://fly.io でホスティングする。

https://zenn.dev/keitakn/scraps/fb037ce188cd5a で作ったコードをベースにさらに発展させていく。

Pythonはまだあまり慣れていないが、テストコードの作成なども今回は実施していくつもりだ。

keitaknkeitakn

GPT4のAPIが利用出来るようになった。

基本ログインなしで利用出来るサービスだがログインすると利用するモデルをGPT4にする等も検討中。(APIの利用制限がどのくらいかちゃんと調べてないので調べる)

keitaknkeitakn

あと app ディレクトリは AppRouter というのが正式名称になったらしい。

ちなみに今までの pagesPagesRouter というらしい。

https://nextjs.org/docs

keitaknkeitakn

UI側の仮実装

Tailwind CSSのTemplateなどを参考に実装。現時点ではただ固定値を入れているだけ。

一応レスポンシブになっている。

Shift + Enterでメッセージを送信させるとかそのあたりのアクセシビリティは現時点では考慮していない。

2023-05-07 0.03.52

対応時のPRのURLも貼っておく。

https://github.com/keitakn/ai-cat-prototype/pull/4

keitaknkeitakn

Layoutの追加

対応時のPRを貼っておく。

https://github.com/keitakn/ai-cat-prototype/pull/11

今回初めてNext.jsのappディレクトリを使う訳だが、なるべくServerComponentで作るのが推奨されているようだ。

なのでLayoutの部分をServerComponentとして分離した。

ついでにデザインを色々変更した。(黄色をテーマカラーとした)

Componentの設計はこの時点で全く定まっていない。

下記のような疑問が頭に浮かんでいる状態。

  • ServerComponentとClientComponentはディレクトリ分けたほうが良いのか?
  • なるべくServerComponentが推奨されているようだが、そもそもどうやってComponentを分ける?
  • formからメッセージが送信された際の動作はServerAction使って実装出来るのか?

これらは段階的に解決していく。

2023-05-11 0.03.26

keitaknkeitakn

ちなみに現時点では以下のような警告が出ている。

これも詳しく調べていないが解消する必要がありそう。

app-index.js:32 Warning: Extra attributes from the server: data-locator-client-url
    at html
    at InnerLayoutRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:225:11)
    at RedirectErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:65:9)
    at RedirectBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:72:11)
    at NotFoundBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:40:11)
    at LoadingBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:330:11)
    at ErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:87:11)
    at InnerScrollAndFocusHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:139:9)
    at ScrollAndFocusHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:211:11)
    at RenderFromTemplateContext (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/render-from-template-context.js:15:44)
    at OuterLayoutRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/layout-router.js:340:11)
    at body
    at html
    at RedirectErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:65:9)
    at RedirectBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/redirect-boundary.js:72:11)
    at NotFoundErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:33:9)
    at NotFoundBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/not-found-boundary.js:40:11)
    at ReactDevOverlay (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/react-dev-overlay/internal/ReactDevOverlay.js:66:9)
    at HotReload (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:276:11)
    at Router (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/app-router.js:92:11)
    at ErrorBoundaryHandler (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:62:9)
    at ErrorBoundary (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/error-boundary.js:87:11)
    at AppRouter (webpack-internal:///(app-client)/./node_modules/next/dist/client/components/app-router.js:333:13)
    at ServerRoot (webpack-internal:///(app-client)/./node_modules/next/dist/client/app-index.js:154:11)
    at RSCComponent
    at Root (webpack-internal:///(app-client)/./node_modules/next/dist/client/app-index.js:171:11)
keitaknkeitakn

ただ一旦Chatが出来る状態まで実装してしまうのが優先なので次はAPIとの繋込みを実装していく。

この時はServerActionとかは一旦無視して作ろうと思う。

keitaknkeitakn

ESLint, Prettierの設定

後回しにしようかとも思ったがコード量が増えてから修正するとそれはそれで大変なので、先にやってしまう事にした。

対応時のPRを貼っておく。

https://github.com/keitakn/ai-cat-prototype/pull/12

ESLintのルールは りあクト! という本をベースに eslint-plugin-tailwindcss を加えた形に落ち着いた。

PRの中でJestやStorybookのルールが設定してあるが、これは後でどうせ追加するので今のうちにやっておこうという考えから。

keitaknkeitakn

そういえばJestを使う前提でいたけど Vitest はどうなんだろう?

いつか試してみたい気持ちはあるが今回は辞めておく。

Next.js 13のappRouterやPython + LangChainなどただでさえ新しい技術を使うので、これ以上新しい事を盛り込むと完成が遅れてしまう可能性が高いからだ。

keitaknkeitakn

ESLintに違反するコードを修正

以下で対応済。

https://github.com/keitakn/ai-cat-prototype/pull/13

eslint-config-nextnext/image を使うように警告を出してくるので next/image を使うようにした。

昔の next/image<img /> タグに <span /> タグをラップした形で出力するのでスタイリングが少々やりにくかったのだが今は素直に <img /> タグを出力してくれるのでCSSの調整などは必要なかった。

この過程で https://zenn.dev/link/comments/b3c7779dac836f のコメントの警告も解消された。

keitaknkeitakn

Chat周りのComponentのリファクタリングを実施

API呼び出し処理を作る前にリファクタリングを実施した。以下はその時のPR。

https://github.com/keitakn/ai-cat-prototype/pull/15

ServerComponentとClientComponent(従来のReactComponent)の使い分けに悩んでいたが 公式ドキュメント の以下の記述にある方針に従ってシンプルに考えるのが良さそう。

以下は公式ドキュメントの抜粋。

Moving Client Components to the Leaves
To improve the performance of your application, we recommend moving Client Components to the leaves of your component tree where possible.

For example, you may have a Layout that has static elements (e.g. logo, links, etc) and an interactive search bar that uses state.

Instead of making the whole layout a Client Component, move the interactive logic to a Client Component (e.g. <SearchBar />) and keep your layout as a Server Component. This means you don't have to send all the component Javascript of the layout to the client.

LeavesというのはComponentツリーの末端を示す、上位で 'use client'; を使ってしまうとその配下が全てClientComponentになってしまうのでダウンロードされるJSファイルのサイズが増えてしまうので、インタラクティブな動きが必要な末端のComponentだけをClientComponentに変えるのが良さそう。

13.4で使えるようになった Server Actions を使えばもしかしたらFormの部分もServerComponentに出来るかもだが、さすがにアルファ版なのでまだ利用はやめておこうと思う。

keitaknkeitakn

ちなみに余談だが今回ChatのForm部分のComponentは結局ClientComponentとして定義したが、ReactのState等で状態管理をしない非制御Componentとして実装した。

理由としては Server Actions が安定版になったらFormは非制御Componentとして実装する必要があるから。

現時点では制御Componentとしても良いのだが、このFormはそれほど複雑にはならないので非制御Componentと実装しても問題ないと判断したのもある。

keitaknkeitakn

実際にAPIへリクエストを行うように改修

メモリ不足への対応

https://fly.io にデプロイしたアプリに対してリクエストを送ったが502エラーが返ってくる。

ログを確認すると以下のようにメモリ不足でサーバーが停止していた。

2023-05-15T07:55:56.305 app[xxxxxxxxxxxxxxxx] nrt [info] [ 13. 1111111] Out of memory: Killed process 518 (gunicorn) total-vm:270936kB, anon-rss:170900kB, file-rss:0kB, shmem-rss:0kB, UID:0 pgtables:536kB oom_score_adj:0

アプリを再起動してみたが状況が改善されない。

flyctl apps restart アプリ名

このアプリは 以前実験で作ったSlackbot 上にAPIのエンドポイントを足した物だ。(https://zenn.dev/link/comments/9b1602cddeca8c を参照)

なので後々は専用のアプリケーションとして独立させる予定だがとりあえずメモリを増やして動くようにした。

fly scale memory 512 -a アプリ名

メモリを512MBまで増やす事でとりあえずAPIサーバーが正常動作するようになった。

APIへのリクエストを実装

PRのURLを貼っておく。

https://github.com/keitakn/ai-cat-prototype/pull/17

このPRの説明にも書いてあるがLLMからの返答はそれなりに時間がかかる。(PR内の動画を見るとどのくらい応答に時間がかかるか感覚が分かると思う)

ローディングメッセージなどを表示させようかと思ったが今のUIだとローディングメッセージを出すとどうしても不自然な形になってしまう。

その為、バックエンド側でストリーミングで返すようにする等の対応が必要かもしれない。

このあたりを読みながらチャレンジしてみようかな。

https://note.com/mahlab/n/n14f1b1878d44

https://zenn.dev/chot/articles/a089c203adad74

https://github.com/shivanshubisht/chatgpt

keitaknkeitakn

サーバー側にストリーミング形式でレスポンスを返すAPIを実装

https://zenn.dev/keitakn/scraps/fb037ce188cd5a で作ったリポジトリはFlaskを利用しているので、専用のリポジトリを作成した。

この新しいリポジトリはサービスがリリースされた後も正式に運用していく。

対応時のPRは下記の通り。

https://github.com/nekochans/ai-cat-api/pull/2

PR内に動画が添付してあるのでリクエストの様子が分かるようになっている。

フロントエンドの実装も以下を参考にすれば問題なく実装出来そう。

https://github.com/shivanshubisht/chatgpt

しかしストリーミング形式なのでエラーハンドリングが複雑そうだったり、LangChainのpredictメソッドがストリーミング形式に対応していないっぽいので、ここが欠点だなと思った。

ただLLMのレスポンスを返すだけなら良いが、後々LangChainのToolsなどを利用してGoogle検索APIから回答を生成したり、エンドユーザーがアップロードしたPDFを文章化して読み込んだ上で回答を生成させるような事をイメージしていたが、このままだとLangChainが使えないので悩みどころ。

LangChainで履歴の管理も割と簡略化出来たりもするので、ストリーミング形式を諦めるという選択肢も頭に浮かんでいる。

keitaknkeitakn

APIから応答が返ってくるまでローディングメッセージを表示するように変更

色々と調べたがやはりLangChainのpredictメソッドがストリーミング形式に対応していないのは痛い。

LangChainは比較的簡単にLLMのカスタマイズが出来るので、ストリーミングに拘るよりもLangChainの恩恵を受けやすい設計にするという意志決定をした。

という訳でUI側でAPI通信中にローディングメッセージを出したり、通信中は連続で送信出来ないように制御を入れたりした。以下は対応時のPR。

https://github.com/keitakn/ai-cat-prototype/pull/20

PR内の動画を見るとローディング中の様子が見れる。

keitaknkeitakn

そろそろフロントエンドも正式版のリポジトリで開発しようと思う。

keitaknkeitakn

初期リリースに必要なissueの作成

一旦必要なissueを洗い出した。

https://github.com/nekochans/ai-cat-frontend/issues

https://github.com/nekochans/ai-cat-api/issues

ZenHubを利用する事でアジャイルボードUIで閲覧出来るようにしてある。

ストーリーポイントの見積もりも行っている。

初期リリースではサービス開始の為、最低限必要な機能に絞り実装する。

ログイン機能や会話履歴の保存などは時間がかかるので後回し。

keitaknkeitakn

フロントエンド側の正式プロジェクトにプロトタイプのコードを移植

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/16

PR内にも書いてある事だが、今回以下の変更をしている。

ReactComponentの書き方

今回 React.FC を使わない形にしてみた。

きっかけは下記の記事を読んだ事。

理由は Generics が使いにくいという理由もあるので今回は React.FC を使わない書き方を試してみようと思った程度。

ただ React.FC の利用が必ずしもBad Practice とは思っていない。

型を明示したほうがTypeScriptのトランスパイルのパフォーマンスが良いので JSX.Element を明示的に指定している。

ディレクトリ構成について

偶然Next.jsの中の人のTweetを知った。

https://twitter.com/d151005/status/1659359491551547392?s=20

Next.jsの中の人いわく以下のように分けるのが良いらしいので今回はこの構成を試してみる事にした。

  • src/app/_components/ (汎用的に利用するComponent)
  • src/app/◯◯/_components/ (対象ページでしか利用しないComponent)
keitaknkeitakn

2023年6月4日 再開

ちょっと仕事で似たような事をやる必要があった事もあり、こちらを一時中断して LGTMeow のCSS Modules 移行を1週間ほどやっていた。

これが昨日終わったので今日から再開。

https://zenn.dev/keitakn/scraps/caf6c2b327a064

とりあえず初期リリースに必要な課題は前回全てissue化したので、それを消化していく。

最初はLinterやFormatterの設定を行った。

以下は対応した際のPR。

https://github.com/nekochans/ai-cat-frontend/pull/18

この手の対応は最初にやっておいたほうがトータルの工数が減るので最初に設定するようにしている。
コードが巨大化した後で直すのは結構キツイ。

基本は https://github.com/keitakn/ai-cat-prototype の設定内容やpackage構成を移植したが prettier-plugin-sort-imports に関しては @ianvs/prettier-plugin-sort-imports を利用する事にした。

こちらのほうが設定内容がシンプルだったり side effect imports の順番を変更によって起こる副作用などの考慮が行われているのでより安全に利用出来るのでこちらを利用する事にした。

keitaknkeitakn

Storybookの導入

こちらを参考に実施していく。

https://storybook.js.org/docs/react/get-started/install/

npx storybook@latest init
keitaknkeitakn

npx storybook@latest init で生成されたファイルはsampleのStoryファイルを除いてコミットする。

次にTailwind CSSを動作させる為の設定を実施していく。

公式で紹介されている以下の手順に従う。

https://storybook.js.org/recipes/tailwindcss

postcss, autoprefixer の2つは既に導入済なので @storybook/addon-styling を導入する。

npm install -D @storybook/addon-styling

.storybook/main.ts@storybook/addon-styling の設定を追加する。

import type { StorybookConfig } from '@storybook/nextjs';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
    // ここから↓を追加
    {
      name: '@storybook/addon-styling',
      options: {
        postCss: true,
      },
    },
    // ここまで
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  docs: {
    autodocs: 'tag',
  },
};
export default config;

次に .storybook/preview.tsimport '../src/app/globals.css'; を追加する。

以下のように正常に表示されるようになった。
globals.css にTailwind CSSの設定以外にcreate-next-appが生成したCSSが入っているので背景が不自然になっているがこれは後で直す)

keitaknkeitakn

@storybook/addon-a11y を追加。

アクセシビリティ対応が義務化される話もあるので、最低限これは導入しておきたい。

自分はアクセシビリティの知識はまだまだなので徐々に LGTMeow やこのサービスでアクセシビリティ対応を進めながら習得していきたい。

keitaknkeitakn

以下を参考に viewport の設定を追加。

これはStorybook上でモバイル端末画面で確認が出来るようになるので個人的には必須の設定。

https://storybook.js.org/docs/react/essentials/viewport

標準で用意されている INITIAL_VIEWPORTS を設定。

iPhone6などの古い機種からiPhone 12 Pro Maxあたりまでの機種が用意されている。

keitaknkeitakn

Storybookのデプロイ

以前自分が書いた以下の記事と同じ事を実施する。

https://zenn.dev/keitakn/articles/storybook-deploy-to-chromatic

Storybookは運用コストが高いという意見もあるが、Chromatic と組み合わせる事でビジュアルリグレッションテストを簡単に実現出来たり、デザイナーとのコミュニケーションにもデプロイしたStorybookは役に立つので自分はStorybooはを書く価値はあると思っている。

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/21

https://github.com/nekochans/ai-cat-frontend/pull/22

keitaknkeitakn

テストを実行出来る環境を構築(フロントエンド)

Jest自体は前回ESLintを設定した時に導入済み(eslint-plugin-jest を設定したかったので)だったが設定はまだだったので実施していく。

テスト環境の動作確認の為Componentのテストを1件追加した。

アクセシビリティも考慮しつつComponentのテストをしっかりと書いていきたい。

このあたりの記事を読んでおく。

https://zenn.dev/tnyo43/scraps/e98109398d66cc

https://zenn.dev/yusukehirao/articles/e3512a58df58fd

https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques

そして以下が対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/23

keitaknkeitakn

CIの設定

CIの設定を忘れていたので GitHubActions を作っていく。

とりあえずLinterに違反しているコードやテストが通っていないコードがマージされないようにする。

CIを後回しにする現場もあるが個人的にはオススメ出来ない。

開発初期の段階でCIを用意したほうが、コードが壊れる事を過度に恐れなくて良くなるので(もちろんテストをちゃんと書いている前提)リファクタリングが捗る。

最初は時間がかかるかもだが、トータルで開発コストを削減出来るし、デプロイ回数も増える傾向がある。
という訳で自分は新規プロジェクトを開始したらなるべく早めにCIの設定をする。

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/25

keitaknkeitakn

API側のコードfly.io にデプロイする

https://zenn.dev/link/comments/190a0d70204b21 に書いたがストリーミングだとLangChainのAgentが利用できなくなり、将来的にやりたい事が実現出来なくなるので、普通にJSONでLLMのレスポンスを返すAPIを実装した。

https://github.com/nekochans/ai-cat-api/pull/6

このコードをベースに fly.io にデプロイしていく。

Slackbotを作った時の手順を参考にする。

https://zenn.dev/link/comments/e4c67f7421f9dc

Dockerfileの作成と動作確認

Dockerfileを元にBuildを実施する。

docker build -t ai-cat-api .

以下で先程Buildしたイメージが存在する事を確認。

docker images

コンテナを起動する。

docker container run -d -p 5002:5000 -e OPENAI_API_KEY=$OPENAI_API_KEY -e API_CREDENTIAL=$API_CREDENTIAL ai-cat-api

コンテナが正常動作するか確認。

curl -v \
-X POST \
-H "Content-Type: application/json" \
-d '
{
  "userId": "user_12345678",
  "message": "こんにちは!"
}' \
http://localhost:5002/cats/moko/messages | jq

fly.io の設定を実施

以下で認証を行う。

flyctl auth login

以下でプロジェクトの初期化を行う。

flyctl launch

対話式のIFになるので以下のように回答。

? Choose an app name (leave blank to generate one): ai-cat-api
? Select Organization: nekochans (nekochans)
? Choose a region for deployment: Tokyo, Japan (nrt)
? Would you like to set up a Postgresql database now? No
? Would you like to set up an Upstash Redis database now? No
? Create .dockerignore from 3 .gitignore files? Yes

基本は前回のSlackbotの時と同じだが今回はアプリをOrganizationに対して作成するので Select Organization で作成したOrganizationを選んでいる。

ここまででアプリの作成は完了。次はSecretsの登録を行う。

以下でSecretsを登録。

flyctl secrets set -a ai-cat-api OPENAI_API_KEY=実際の値を指定
flyctl secrets set -a ai-cat-api API_CREDENTIAL=実際の値を指定

Slackbotの時にメモリエラーでサーバーが停止してしまったので以下でメモリ割り当てを増やしている。

fly scale memory 512 -a ai-cat-api

ここまで出来たらデプロイを実施する。

flyctl deploy

以上でデプロイまでの手順は完了。

ここまでの手順で作成したPRは下記の通り。

https://github.com/nekochans/ai-cat-api/pull/7

keitaknkeitakn

Vercelにデプロイする準備を行う

バックエンドのAPIを正式版に変更する

もうとっくに切り替えていたつもりだったがAPIの参照先が https://github.com/keitakn/chat-gpt-slack-bot で作成したプロトタイプに向いていた。

正式版のAPI に向けるように変更。

https://github.com/nekochans/ai-cat-frontend/pull/30

レートリミットの設定

初期リリースだとログイン機能は実装しない予定。

しかしAPIがノーガードだとDDoS攻撃を受けるとOpenAIの利用上限に一瞬に達成してしまう恐れがあるのでIPによるレートリミットの設定を行う。

非常に安価で利用出来る Upstash が開発している @upstash/ratelimit を使って実現した。

非常に簡単にレートリミットが実装出来た。以下はその時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/29

Vercelへのデプロイ

GitHubリポジトリとVercelを連携してデプロイ。

Vercelはとても簡単に連携が出来る上に自動デプロイはもちろんの事、コミット毎のプレビュー環境URLの発行、プレビュー環境で画面上でレビューコメントを追加出来る機能など非常に開発体験が良い。

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/31

keitaknkeitakn

LLMがユーザー毎に会話履歴を保持するようにする

現状の実装だと、他のユーザーとの会話内容を別のユーザーに対して話してしまう可能性がある。

初期リリースでは履歴の保存などを行う予定はないが、さすがにこれはマズイのでリクエストで受け取った userId 毎に会話履歴を保有するように変更した。

以下はその時のPR。

https://github.com/nekochans/ai-cat-api/pull/14

ただし会話履歴はサーバーのオンメモリ上に保存しているだけなので再起動やデプロイで履歴は消える。

とは言え一旦これで十分。

初期リリース完了後にログイン機能実装時にDBに永続化するようにする。

keitaknkeitakn

LLMのモデルを gpt-3.5-turbo-0613 に変更

LangChainで新しい言語モデルが利用出来るようになったので https://openai.com/blog/function-calling-and-other-api-updates で発表された新しい言語モデル gpt-3.5-turbo-0613 に変更。

目玉は Function calling だがこの機能はまだLangChainでは利用出来ない模様。

しかし単純な応答速度も上がっているので、それだけでも十分な恩恵があると判断した。

以下は対応時のPR。

https://github.com/nekochans/ai-cat-api/pull/15

keitaknkeitakn

トップページのデザインを追加

まずどんな情報の乗せるかでかなり迷ったが、Tailwind CSS公式が出しているシンプルなTemplateがあったのでそれを参考にした。

メニューの実装を楽に終わらせる為に以下のpackageも追加した。

この2つは共にTailwind CSSとの相性も良い点も良いと思う。

雰囲気は↓な感じ。(アイコンとサービス名が仮なので後で直す)

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/37

keitaknkeitakn

/chatのURLを変更、notFound を使った

元々 /chat でChat画面に遷移していたが、後々ねこの種類を増やす予定なので /chat/moko のようにねこのIDをURLに含めるようにした。

初期リリースだと /chat で問題ないがURLを後で変更するとリダイレクト処理等をずっと残さないといけないので可能な限りリリース後にURLは変更したくないので今のうちに対応した。

存在しないIDを指定された際に404ページを出したいがAppRouterだとどうやってやるんだろう?と思っていたところ notFound 関数を利用するのが良さそうなので利用した。

https://nextjs.org/docs/app/api-reference/functions/not-found

以下は対応時のPR。

https://github.com/nekochans/ai-cat-frontend/pull/40

keitaknkeitakn

正式なサービス名を考える

ChatGPTに案を出してもらったりしたが、なかなか思い浮かばない。

もうAI Catでいいかと思ったが以下のサイトで検索したら商標登録されていそうだったので 「AI Meow Cat」とする事にした。

https://www.j-platpat.inpit.go.jp/

という訳でドメインを購入して正式なドメインでアクセス出来るようにする。

keitaknkeitakn

独自ドメインでアクセス出来るようにする

ai-meow-cat.com が欲しいので探す。

料金面や利便性で有利なCloudflareで購入する事にした。

Vercelと連携して独自ドメインでアクセス出来るようにした。

https://www.ai-meow-cat.com/

そんなに難しくはなかったが少しハマりポイント(SSL/TLS 暗号化モードを「フル」に設定しないとリダイレクトループになる)があるのと、今後Cloudflareを使う人が増えると思っているので需要あるかなと思い記事としてまとめた。

https://zenn.dev/keitakn/articles/add-cloudflare-domain-to-vercel

keitaknkeitakn

500エラーページを作成

AppRouterだと error.js(error.tsx) を参考を配置すれば良いので error.tsx を作成。

404ページと同じくねこのイラストは https://www.shigureni.com から拝借した。
(キーボードを占領してる姿が何とも言えないかわいさw)

keitaknkeitakn

エラーの出し分けは今後も使うので、Component化していく。

keitaknkeitakn

再度Streamingに挑戦

やはりユーザーから見るとLLMからの応答はどうしても遅く感じるので、Streamingでレスポンスを返す事にした。

https://zenn.dev/link/comments/190a0d70204b21 でLangChainはStreamingに未対応と書いてあるがこれは誤りで単に predict メソッドがStreamingに未対応なだけで run メソッドを使えば対応する事が出来た。

https://github.com/nekochans/ai-cat-api/pull/17

しかし大きな課題がある。

今の実装だとOpenAI APIでエラーが発生した時に200 OKが返ってきてしまう。

これはPythonの仕様で子スレッドで発生した例外を親スレッドで補足出来ない事が原因。

書き方を工夫するか、LangChainを使わないで実装する等の何らかの工夫が必要。

keitaknkeitakn

ちなみに今は AsyncIteratorCallbackHandler を使って以下のように書けるらしい。

ドキュメントにも詳しく載ってないし、Pythonに慣れていないのもあって難しく感じる。

https://gist.github.com/ninely/88485b2e265d852d3feb8bd115065b1a

いっその事、LangChainを使わないで、普通にOpenAIのAPIを使ったほうが楽なのでは?

と感じてきている。

もちろんToolなどを使う場合はLangChainのほうが楽なので全く使わないというよりは必要な箇所にだけ絞って限定的に使うというのが良さそう。

keitaknkeitakn

とは言え、まずは一旦、フロント側でStreamingで表示するところをやってみる。

keitaknkeitakn

フロントエンド側をStreamingで表示させるように対応

以下のPR内に動画が貼ってあるが、Streamingで動作している事が確認出来る。

https://github.com/nekochans/ai-cat-frontend/pull/52

しかし課題も多い。

まずmsw がSSE(Server-Sent Events)に未対応なので対応するまでテストをスキップするようにした。
この部分のテストが動かないのは結構痛いので、何とかしたいところ。

ちなみに https://github.com/mswjs/msw/issues/156#issuecomment-1050789351 に書いてある通りにやってみたが動作しなかった。

元々公式が非対応でさらに自動生成された mockServiceWorker.js を上書きする必要があるので、別の手段を検討したい。

しかしともかくStreamingで動作するようにはなった。

keitaknkeitakn

API側でエラー時もStreamingで返していたが、エラー時は素直にJSONを返したほうがフロント側で扱いやすい。

また、Streamingが途中で止まった場合の対応もバックエンド側で実施する必要がありそう。

keitaknkeitakn

あとフロント側のStreamからデータを取り出している部分だがもう少し整理した書き方が出来ないかも検討中。

keitaknkeitakn

API側でエラー時もStreamingで返していたが、エラー時は素直にJSONを返したほうがフロント側で扱いやすい。

これはStreamingの途中でエラーが起きた場合、HTTPステータス 200でエラー内容を返すしかないので通常のJSONレスポンスが返ってくるパターンを追加するとかえって複雑になるのでやめた。

keitaknkeitakn

LangChainを辞めた

理由は以下の通り。

  • バージョンアップが激しく使い方がコロコロ変わるのがキツイ
  • このサービスで実現する機能(実現予定の機能も含む)は素のOpenAIライブラリで実現出来る
  • LangChainとStreamingを組み合わせた実装方法に関する情報ほとんど見つからないので今後トラブルが起きた時に解決不能になってしまう可能性がある

以下はLangChainを辞めた時のPR。

https://github.com/nekochans/ai-cat-api/pull/22

会話履歴の引き継ぎとか面倒かなと思ったが以外と何とかなった。

やっぱり新しい事を学ぶ時はいきなり抽象化された便利な物を使うのではなく、シンプルな手段で実装出来る物から触って徐々に基礎を身に着けながら高度な物に手を出していくのが良いなと改めて思った。

keitaknkeitakn

API側のログを実用的な形に変更した

詳しい内容に関しては以下を参照。

これで必要な情報は全て出しているし、エラー時のスタックトレースも出るし、レスポンスHeaderに ai-meow-cat-request-id というuniqueIDも追加したのでログの検索も出来る、Sentryに飛ばしておけば最低限の調査も出来るようになった。

Pythonのログ事情が分からずに調査に結構苦戦した標準のLoggerが割と奥が深いのと、サードパーティのpackageもいくつか見てみたが決定版的なモノがある訳ではなさそうだったので標準の機能を利用する事にした。

https://github.com/nekochans/ai-cat-api/pull/28

keitaknkeitakn

PyCharmの設定メモ

全てのPythonファイルを src に移動したので Settings > Project: [プロジェクト名] > Project Structure を選択。

src ディレクトリを右クリックして Sources をクリックする。

こうしておかないとプロジェクト内の自作packageから同じプロジェクト内の別自作packageをimportした時にPyCharmがパスを解決出来ずにエラー表記になってしまう。(あくまでもPyCharmの表示がエラーになるだけで正常に実行は可能)

keitaknkeitakn

API側のバリデーションを実装

しばらく別件の開発をやっていたが今日から再開。

API側に不正な値が送信された際は以下のようなレスポンスを返すように変更。

< HTTP/1.1 422 Unprocessable Entity
< date: Sun, 27 Aug 2023 14:31:27 GMT
< server: uvicorn
< content-length: 384
< content-type: application/json
<
{ [384 bytes data]
100   496  100   384  100   112  38937  11356 --:--:-- --:--:-- --:--:-- 70857
* Connection #0 to host 0.0.0.0 left intact
{
  "type": "UNPROCESSABLE_ENTITY",
  "title": "validation Error.",
  "invalidParams": [
    {
      "name": "userId",
      "reason": "Value error, 'user_12345678' is not in UUID format"
    },
    {
      "name": "message",
      "reason": "Value error, message must be at least 2 character and no more than 5,000 characters"
    },
    {
      "name": "conversationId",
      "reason": "Value error, '88bdab85-fa77-4676-9e14-c2dcf71900f2--' is not in UUID format"
    }
  ]
}

このエラーフォーマットは RFC7807 を参考にしている。

ちなみに最近自分はこのフォーマットを採用する事が多いのだが、これを知るきっかけは下記の記事だった。

https://zenn.dev/ryamakuchi/articles/d7c932afc57e30

keitaknkeitakn

会話履歴をPlanetScaleのDBに保存

今だとデプロイをする度に会話履歴が削除されてしまう。

なのでDBに保存する事にする。安価で利用できる

最近は PostgreSQL のほうが優勢なので PostgreSQL を利用するのも良いかなと思ったが今回は安価で開発体験の良い PlanetScale を使う。

※ このプロダクトはただでさえ初挑戦の技術が多いのでRDBは慣れた物を使いたいという事情もあり。

keitaknkeitakn

テーブル構造は以下の通り。

CREATE TABLE `guest_users_conversation_histories` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT,
  `conversation_id` CHAR(36) NOT NULL,
  `cat_id` varchar(255) NOT NULL,
  `user_id` CHAR(36) NOT NULL,
  `user_message` LONGTEXT NOT NULL,
  `ai_message` LONGTEXT NOT NULL,
  `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `updated_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`id`),
  KEY `idx_guest_users_conversation_histories_01` (`conversation_id`),
  KEY `idx_guest_users_conversation_histories_02` (`cat_id`),
  KEY `idx_guest_users_conversation_histories_03` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC;

テーブル名を guest_users_conversation_histories としたのは後々ログイン機能を実装予定でログインユーザーの保存領域と分けたかったから。

cat_id はねこの名前が入る、今はもこちゃんしかいないが、後々ねこを増やす予定なので、これも保存しておく。

試しに以下のようなデータを登録してみたが、無事に登録出来たので一旦この構造で進める。

INSERT INTO `guest_users_conversation_histories`
(conversation_id, cat_id, user_id, user_message, ai_message) VALUES('b57af258-e1c8-45af-8993-447a451ad52a', 'moko', '1926e98f-3200-40a9-a7f6-291e2c7f58d9', 'Hello cat!', 'Hello user!');

keitaknkeitakn

SQLAlchemyをインストール

WEB+DB PRESS Vol.136 でも特集が組まれていたが SQLAlchemy が人気らしい。非同期IOにも対応しているので特に問題はなさそう。

poetry add sqlalchemy でpackageを追加する。

keitaknkeitakn

SQLAlchemy は非同期IOに対応しているが、PlanetScale は接続にTSLを必須としている為、非同期IOとTSL接続の両方を満たす方法が分からず一旦以下のPlanetScale公式ブログで紹介されていた aiomysql を利用する事にした。

https://planetscale.com/blog/using-mysql-with-sql-alchemy-hands-on-examples#using-sqlalchemy-orm-to-write-queries

SQLAlchemy を使う方法に関しては後で再チャレンジしてみる予定だが今は機能の完成を優先させる。

その時のPRは下記の通り。

https://github.com/nekochans/ai-cat-api/pull/39

keitaknkeitakn

API側のステージング環境を構築

さすがに本番用だけだと色々やりにくいので、ステージング用のAPIを用意した。

https://github.com/nekochans/ai-cat-api/pull/51

手順に関しては https://zenn.dev/link/comments/5f23ac7869d1d2 に書いてある通りで可能だった。

main にマージされたタイミングでステージングにデプロイして、本番リリースに関してはGitHub上でリリースページが公開されたタイミングでデプロイされるように設定した。