49
20

はじめに

こんにちは! yu-Matsuです!

 今回はReactベースのWebフレームワークである Remix に関する記事になります。というのも、今年の6月8日にサイバー攻撃を受けダウンしたニコニコ動画の復旧までの繋ぎとして公開されている ニコニコ動画(Re:仮) というサイトがあるのですが、こちらがなんと、Remixを使って3日で実装された とのことなのです! こんな話を知ってしまったら、エンジニアとしては触らないわけにはいきません!

 とはいえ、Remixのドキュメントを読み込んで勉強するのも時間がかかるので、今回は Anthropic Artifacts を利用して実現した、プログラマーと化したずんだもんに教えてもらいながら、簡単なWebアプリケーションを実装したいと思います!

(イラスト:坂本アヒル様(https://www.pixiv.net/artworks/92641351))

ずんだもん、よろしくね!

スクリーンショット 2024-07-15 11.08.10.png

おお、やる気満々ですね!!

Anthropic Artifacts

 Anthropic Artifactsは、先月にClaude 3.5のリリースに合わせて追加されました。コードスニペット、文書、SVG画像、図表、Reactコンポーネントなど、多様な種類のコンテンツを、Claudeとの会話の中で参照、生成することが出来る画期的な機能になります!

以下はArtifactsを利用して、Claudeとの会話の中でプログラムのコードを生成させた例になります。生成物は画面右下からダウンロードすることが出来ます。

スクリーンショット 2024-07-15 11.34.26.png

 Artifactsは以下のような Project knowledge というところからカスタマイズできます。今回は、ずんだもんとの会話を想定しているため、「Set custom instructons」でずんだもんのキャラクター付けを行なっています。また「Add Content」から、Remixの日本語版ドキュメント をPDF化したものを knowledge として追加しています。

※ もちろんハルシネーションは発生し得るので、都度生成内容とドキュメントを見比べて、問題がある場合は訂正する形で進めます

Remixについて

 それでは本題の Remix についてですが、概要については既にずんだもんが冒頭で説明してくれていますので、再掲します。

スクリーンショット 2024-07-15 11.08.10.png

Reactがベースのため、Reactに慣れ親しんでいる方には馴染みやすいフレームワークのようです。今回は基礎のキソということで、ルーティングloaderactionについて触れたいと思います!

プロジェクト作成

 まずはプロジェクトの作成を実施したいと思います。ドキュメントを読んで進めても良いのですが、せっかくなのでずんだもんに聞いてみます。

ずんだもん、プロジェクトの作り方教えて!
スクリーンショット 2024-07-15 15.29.20.png

手順通りに進めると、プロジェクトを作成することが出来ました! ただし、最後のlocalhostでの起動に関しては、ポートが3000ではなく、5173でした。ブラウザを開いて、以下のようなページが表示されたら成功です!

 プロジェクトのディレクトリ構成は以下のようになっています。基本的には appディレクトリ の中で色々と実装していくことになりそうです。

ディレクトリ構成
Project
├ app
│ └ routes
│   └ _index.tsx
├   entry.client.tsx
├   entry.server.tsx
├   root.tsx
├   tailwind.css
├ public
├   favicon.io
├ eslint.cjs
├ package.json
├ postconfig.css.js
├ tailwind.config.js
├ tsconfig.json
└ vite.config.ts
	

今回作成するWebアプリの概要

 このタイミングで、今回実装するWebアプリについて簡単に説明します。テーマはずんだもんと会話できるチャットアプリです。完成イメージは以下の通りです。
 まずホーム画面ですが、ずんだもんとの会話のスレッド一覧が表示されるので、そのうちどれかを選択すると、スレッド画面に移動できます。この画面でスレッドの作成も出来るようにしています。

スクリーンショット 2024-07-15 15.45.39.png

スレッド画面に移動すると、そのスレッドにおける会話履歴が表示され、ずんだもんと会話を開始することが出来ます。

スクリーンショット 2024-07-15 15.45.47.png

 今回はRemixがメインのため詳細は省きますが、バックエンド処理は AWS の APIGateway + Lambda + Bedrock + DynamoDB で実装しています。

  • ずんだもんのAIは LangChain で実装。 モデルは Claude 3.5 Sonnet を採用している
  • 会話履歴はDynamoDBに保持している。スレッドIDをキーとして、ずんだもんとユーザーのやり取りが格納されている

バックエンドのアーキテクチャ図は以下のような感じです。

Webアプリを実装してみる

ホーム画面

 では準備ができましたので、実施に実装していきたいと思います! とりあえず、まずはホーム画面から入りたいと思います。といっても、まだRemixのルーティングについてよく分かっていないので、このタイミングでずんだもんに聞いてみます。

Remixのルーティングについて

ずんだもん、Remixのルーティングについて教えて!
スクリーンショット 2024-07-15 16.28.11.png

ざっくり言うと、app/routes 配下にファイルを作成すると、自動的にルートが切られます。 Next.js を触ったことがある方は App Router に似ているのでイメージしやすいかと思います。
 しかし、上記説明はRemix V1のもので、Remix V2からは基本的に、routes/ にディレクトリを切るのではなく、ドット分割でルーティングをするようになっているため、注意が必要です!

ドット分割のイメージ
app                       URLパス
└ routes
  ├ _index.tsx       / 
  ├ home.tsx              /home
  └ home.contents.tsx     /home/contents
route.tsx

 ホーム画面は、アプリケーションにアクセスした際に最初に開かれる想定ですので、ルートパスに対応するファイルである _index.tsx を編集して実装していきます。ホーム画面では、ページ読み込みの際にAPIを実行して現在のスレッド一覧を取得し、画面に表示したい のですが、どうすれば良いのでしょうか。
 ここで、ずんだもんがRemixの特徴として紹介してくれた、データ取得機能である loader に注目したいと思います。

loaderについて

ずんだもん、loaderについて教えて!
スクリーンショット 2024-07-15 16.51.56.png

コードの例も出してくれているので分かりやすいです。loaderを使えば、ページ読み込み時のデータ取得を実現出来そうですね! ちなみに、loaderのデータの処理の流れに関して、図示もしてもらいました。

スクリーンショット 2024-07-15 16.57.50.png

loaderを使ってホーム画面を以下のように実装してみました。

routes/_index.tsx
import type { MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export async function loader() {
  const response = await fetch(
    "https://1kqrlqtygk.execute-api.us-east-1.amazonaws.com/dev/api/zunda-get-thread"
  );
  const data = await response.json();

  return json({ posts: data });
}

export default function Home() {
  const { posts: threads } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>ずんだもんとのチャットページなのだ!</h1>
      <p>スレッド一覧</p>
      <div>
        {threads["partition_key_values"].map(
          (thread: string, index: number) => (
            <div key={index}>
              <div
                style={{
                  padding: "10px",
                  border: "1px solid #ccc",
                  borderRadius: "5px",
                  cursor: "pointer",
                }}
                onMouseEnter={(e) =>
                  (e.currentTarget.style.backgroundColor = "#f0f0f0")
                }
                onMouseLeave={(e) =>
                  (e.currentTarget.style.backgroundColor = "transparent")
                }
              >
                {thread}
              </div>
            </div>
          )
        )}
      </div>
    </div>
  );
}

loaderの中でスレッド一覧を取得するAPIを実行して返し、コンポーネント内で useLoaderDataフック で取得、表示しています。
 localhostで起動してブラウザで確認してみると、スレッド一覧に取得したスレッドのIDが表示されていることが分かります!

スクリーンショット 2024-07-15 17.03.38.png

 次に、ホーム画面から、各スレッドIDに対応したスレッドページに遷移させるようにしたいと思います。スレッドIDごとにパスが動的に変わるため、ずんだもんが紹介してくれた、動的ルーティングを利用することになります。ドット分割の命名規則に従って、routes/ に thread.$threadid.tsx というファイルを作成しました。

routes/thread.$thread.tsx
import { useParams } from "@remix-run/react";

export default function ThreadPage() {
  const params = useParams();
  return <h1>Threasd Page {params.threadid}</h1>;
}

useParamsフックを利用して、パスパラメータの情報を取得し、表示するようにしています。
 ホーム画面では、各スレッドIDを押下すると、対応するスレッドページに遷移するように修正します。Linkコンポーネントを利用します。

routes/_index.tsx
- import { useLoaderData } from "@remix-run/react";
+ import { useLoaderData, Link } from "@remix-run/react";

export default function Home() {
  ...

  return (
    ...
+              <Link
+                to={`/thread/${thread}`}
+                style={{ textDecoration: "none", color: "inherit" }}
+              >
                <div
                  style={{
                    padding: "10px",
                    border: "1px solid #ccc",
                    borderRadius: "5px",
                    cursor: "pointer",
                  }}
                  onMouseEnter={(e) =>
                    (e.currentTarget.style.backgroundColor = "#f0f0f0")
                  }
                  onMouseLeave={(e) =>
                    (e.currentTarget.style.backgroundColor = "transparent")
                  }
                >
                  {thread}
                </div>
+              </Link>
    ...

実際に動作を確認してみると、ホーム画面から「0001」を選択して押下すると、対応するスレッドページに遷移することを確認しました。(遷移先のページでちゃんと「0001」が表示されている)

ヘッダー

 次はヘッダーを実装してみたいと思います。ここで気になるのはアプリケーション全体のレイアウトはどこで記述するのでしょうか。ディレクトリ構成を見ると、app/root.tsx が怪しいので、このファイルについてずんだもんに聞いてみます。

root.tsxってどういう役割を果たしているの?
スクリーンショット 2024-07-15 17.35.54.png

スクリーンショット 2024-07-15 17.36.02.png

 ビンゴでした! root.tsxがレイアウトの役割を果たしているようなので、ヘッダーコンポーネントを作成して、ここに配置したいと思います。

routes/header.tsx
import { Link } from "@remix-run/react";

export default function Header() {
  return (
    <header style={{ backgroundColor: "#e0ffe0", padding: "1rem" }}>
      <nav>
        <Link to="/" className="text-3xl font-bold text-green-600 mb-6">
          ずんだもんとのチャットアプリ
        </Link>
      </nav>
    </header>
  );
}
app/root.tsx
import { Links, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import "./tailwind.css";
import Header from "./routes/header";

export default function App() {
  return (
    <html lang="ja">
      <head>
        <Links />
      </head>
      <body>
        <Header />
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

動作確認をしてみたところ、ヘッダーが問題なく表示されていました。

スクリーンショット 2024-07-15 17.54.38.png

スレッドを作成できるようにしてみる

 ここで一旦ホーム画面に戻って、スレッドを作成できるようにしてみたいと思います。仕様としては以下の通りです。

  • フォームにスレッドIDを入力する
  • 作成ボタンを押下するとスレッドIDに対応したページにリダイレクトされる

今度はデータの取得や作成の処理を実装することになります。ずんだもんからloaderと対なる機能として action が紹介されていたので、こちらも詳細を聞いてみます。

actionについて

ずんだもん、actionについて教えて!
スクリーンショット 2024-07-15 18.03.58.png

 少し説明不足な部分があるので補足します。Formコンポーネントを用いて入力フォームを実装するのですが、RemixのFormはactionと密接に紐づいており、例で言うと type="submit" を指定したボタンが押下されると、input要素で入力した情報が、actionの引数として渡され、actionが実行されます。 action内では引数で受け取った情報をもとにAPIを実行するなどの処理を行います。redirectを利用すると、actionの実行後に任意のページにリダイレクトすることも可能です。
 また、loaderと同じようにコンポーネント内でuseActionDataフックを利用することで、actionの実行状況、結果を取得することも出来ます。

こちらもデータの流れに関して図示してもらいました。(外部データベースを操作する場合)

スクリーンショット 2024-07-15 18.08.05.png

 では、actionを利用してスレッド作成処理を実装したいと思います。_index.tsxを以下のように修正しました。

routes/_index.tsx
...

export default function Home() {
  const { posts: threads } = useLoaderData<typeof loader>();

  ...
  return (
    ...
+      <div>
+        <Form method="post">
+          <div>
+            <label htmlFor="body">新しい会話</label>
+            <input type="text" name="body" id="body" />
+          </div>
+          <button type="submit">作成</button>
+        </Form>
+      </div>
    </div>
  );
}

...

+ export async function action({ request }: ActionFunctionArgs) {
+   const formData = await request.formData();
+ 
+   return redirect(`/thread/${formData.get("body")}`);
+ }

スレッドを作成するフォームをFormコンポーネントを追加し、スレッドIDを入力して「作成」ボタンを押下した際にactionが実行され、対応するページにリダイレクトするようになっています。
 動作確認をしてみたいと思います。localhostで起動し、ブラウザで確認すると...

入力フォームが出現しています! フォームに作成したいスレッドのIDを入力し、「作成」を押下すると...

actionが動いて、入力したスレッドIDに対応するページにリダイレクトされたことが分かります! これでスレッド作成機能の実装は完了です。

スタイルの調整

 そろそろ見辛くなってきたので、このタイミングでスタイルを調整したいと思います。が、今回は時短のためにスタイルに関してはずんだもんに一任したいと思います。_index.tsxをAnthropic Artifactsに渡して、スタイルを調整を依頼してみます。

スタイルを調整して、ずんだもん!
スクリーンショット 2024-07-15 18.41.08.png

routes/_index.tsx

export default function Home() {
  const { posts: threads } = useLoaderData<typeof loader>();

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold text-green-600 mb-6">ずんだもんとのチャットアプリなのだ!</h1>
      <p className="text-xl mb-4">スレッド一覧</p>
      <div className="grid gap-4 mb-8">
        {threads["partition_key_values"].map((thread: string, index: number) => (
          <Link
            key={index}
            to={`/thread/${thread}`}
            className="block p-4 border border-green-300 rounded-lg hover:bg-green-50 transition-colors duration-200"
          >
            {thread}
          </Link>
        ))}
      </div>
      <div className="bg-green-100 p-6 rounded-lg">
        <h2 className="text-2xl font-semibold text-green-800 mb-4">新しい会話を始めるのだ!</h2>
        <Form method="post" className="space-y-4">
          <div>
            <label htmlFor="body" className="block text-green-700 mb-2">新しい会話のタイトル</label>
            <input 
              type="text" 
              name="body" 
              id="body" 
              className="w-full p-2 border border-green-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-transparent"
            />
          </div>
          <button 
            type="submit"
            className="bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 transition-colors duration-200"
          >
            作成するのだ!
          </button>
        </Form>
      </div>
    </div>
  );
}

ずんだもんをイメージした緑を基調としたいい感じのスタイルを当ててくれました! すごいぞ、ずんだもん!!

スクリーンショット 2024-07-15 15.45.39.png

スレッドページ

 いよいよメインである、ずんだもんと会話ができるスレッドページの実装になります! 今までの実装でloaderとactionの使い方がわかりましたので、その復習になります。仕様は以下になります。

  • ページ読み込み時に、スレッドIDを指定してデータベースから会話履歴を取得、ページに表示する
  • フォームにメッセージを入力、送信すると、APIが実行され、入力に対応するずんだもんのレスポンスが返ってくる
    • 会話履歴はAPIの実行が成功したタイミングで更新される
  • レスポンスが返ってきたタイミングでページが更新され、ページに表示している会話履歴が更新される

thread.$threadid.tsx を以下のように修正しました! スタイルはずんだもんに調整してもらっています。

routes/thread.$threadid.tsx
import { json } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";

type Post = {
  type: string;
  content: string;
};

export async function loader({ params }: LoaderFunctionArgs) {
  const response = await fetch(
    `https://xxxxxxx.execute-api.us-east-1.amazonaws.com/dev/api/zunda-get-chat?key=${params["threadid"]}`
  );
  const data = await response.json();

  return json({ posts: data });
}

export default function ThreadPage() {
  const { posts } = useLoaderData<typeof loader>();

  console.log(posts);

  return (
    <div className="container mx-auto px-4 py-8">
      <h2 className="text-3xl font-bold text-green-600 mb-6">
        ずんだもんとのおしゃべりなのだ!
      </h2>
      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        {posts.length > 0 ? (
          <ul className="space-y-4">
            {posts.map((post: Post, index: number) => (
              <li
                key={index}
                className={`p-3 rounded-lg ${
                  post.type === "human"
                    ? "bg-blue-100 text-blue-800"
                    : "bg-green-100 text-green-800"
                }`}
              >
                <strong className="font-semibold">
                  {post.type === "human" ? "あなた" : "ずんだもん"}:
                </strong>{" "}
                {post.content}
              </li>
            ))}
          </ul>
        ) : (
          <p className="text-gray-600">
            まだ会話が始まっていないのだ。何か話しかけてみるのだ!🍵
          </p>
        )}
      </div>
      <div className="bg-gray-100 p-6 rounded-lg">
        <Form method="post" className="space-y-4">
          <div>
            <label htmlFor="body" className="block text-gray-700 mb-2">
              ずんだもんに話しかけるのだ
            </label>
            <input
              type="text"
              name="body"
              id="body"
              className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-transparent"
              placeholder="ここにメッセージを入力するのだ..."
            />
          </div>
          <button
            type="submit"
            className="bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 transition-colors duration-200"
          >
            送信するのだ!
          </button>
        </Form>
      </div>
    </div>
  );
}

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();

  const payload = {
    sessionid: params["threadid"],
    message: formData.get("body"),
  };

  const response = await fetch(
    "https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/api/zunda-post",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    }
  );
  
  const data = await response.json();

  return json(data);
}

loaderに関しては、スレッドIDに対応する会話履歴のリストを取得するAPIを実行し、コンポーネントに返しています。APIの実行に必要になるスレッドIDに関しては、引数の params に現在のページのパスパラメータが入っているため、そこから取得しています。

loader
export async function loader({ params }: LoaderFunctionArgs) {
  const response = await fetch(
    `https://xxxxxxx.execute-api.us-east-1.amazonaws.com/dev/api/zunda-get-chat?key=${params["threadid"]}`
  );
  const data = await response.json();

  return json({ posts: data });
}

loaderの実行結果をコンポーネント側で useLoaderDataフックで取得し、ページに表示しています。loaderの実行結果が空であれば、まだ会話が始まっていない旨のメッセージを表示するようにしています。

<div className="bg-white shadow-md rounded-lg p-6 mb-8">
    {posts.length > 0 ? (
      <ul className="space-y-4">
        {posts.map((post: Post, index: number) => (
          <li
            key={index}
            className={`p-3 rounded-lg ${
              post.type === "human"
                ? "bg-blue-100 text-blue-800"
                : "bg-green-100 text-green-800"
            }`}
          >
            <strong className="font-semibold">
              {post.type === "human" ? "あなた" : "ずんだもん"}:
            </strong>{" "}
            {post.content}
          </li>
        ))}
      </ul>
    ) : (
      <p className="text-gray-600">
        まだ会話が始まっていないのだ。何か話しかけてみるのだ!🍵
      </p>
    )}
</div>

actionに関しては、Formコンポーネントでsubmitされた際に渡された情報(request)をもとに、ずんだもんと会話するAPIを実行しています。スレッドIDはloaderと同じく、paramsから取得しています。returnでAPIの実行結果を返していますが、今回はThreadコンポーネント内で useActionDataフックを利用していないので、特に必要なありません。

action
export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();

  const payload = {
    sessionid: params["threadid"],
    message: formData.get("body"),
  };

  const response = await fetch(
    "https://xxxxxx.execute-api.us-east-1.amazonaws.com/dev/api/zunda-post",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    }
  );
  
  const data = await response.json();

  return json(data);
}

 それでは実装が完了しましたので、動作確認をしてみたいと思います! まずは、スレッドを新規作成します。

スクリーンショット 2024-07-15 19.31.08.png

スレッド作成完了後、スレッドページに移動します。まだ会話履歴がないので、その旨のメッセージが表示さています。

スクリーンショット 2024-07-15 19.31.21.png

「こんにちは」 と入力し、送信してみると、ずんだもんからのメッセージが返ってきました!

スクリーンショット 2024-07-15 19.31.53.png

さらに別のメッセージを送信すると、問題なくメッセージが返ってきており、会話履歴も更新されていることが分かります!

スクリーンショット 2024-07-15 19.32.23.png

 以上により、予定していたWebアプリケーションの機能を全て実装することができましたが、最後に、スレッドページにてユーザー入力を送信してから、APIのレスポンスが返ってくるまで(actionの実行が完了するまで) ローディングメッセージが出るように修正したいと思います。
実行中のactionのステータスを取得するにはどうすれば良いか、ずんだもんに聞いてみたいと思います。

ずんだもん、actionの実行状態はどのようにして取得できる?
スクリーンショット 2024-07-15 22.01.56.png

どうやら useNavigationというフックが利用できるようなので、こちらを用いてスレッドページを修正してみたいと思います。

thread.&threadid.tsx

...

export default function ThreadPage() {
  const { posts } = useLoaderData<typeof loader>();
+   const navigation = useNavigation();

+   const isSubmitting = navigation.state === "submitting";

  return (
    <div className="container mx-auto px-4 py-8">
      <h2 className="text-3xl font-bold text-green-600 mb-6">
        ずんだもんとのおしゃべりなのだ!
      </h2>
      <div className="bg-white shadow-md rounded-lg p-6 mb-8">
        {posts.length > 0 ? (
          <ul className="space-y-4">
            {posts.map((post: Post, index: number) => (
              <li
                key={index}
                className={`p-3 rounded-lg ${
                  post.type === "human"
                    ? "bg-blue-100 text-blue-800"
                    : "bg-green-100 text-green-800"
                }`}
              >
                <strong className="font-semibold">
                  {post.type === "human" ? "あなた" : "ずんだもん"}:
                </strong>{" "}
                {post.content}
              </li>
            ))}
          </ul>
        ) : (
          <p className="text-gray-600">
            まだ会話が始まっていないのだ。何か話しかけてみるのだ!🍵
          </p>
        )}
      </div>
      <div className="bg-gray-100 p-6 rounded-lg">
        <Form method="post" className="space-y-4">
          <div>
            <label htmlFor="body" className="block text-gray-700 mb-2">
              ずんだもんに話しかけるのだ
            </label>
            <input
              type="text"
              name="body"
              id="body"
              className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-transparent"
              placeholder="ここにメッセージを入力するのだ..."
+              disabled={isSubmitting}
            />
          </div>
          <button
            type="submit"
            className="bg-green-500 text-white px-4 py-2 rounded-md hover:bg-green-600 transition-colors duration-200"
+            disabled={isSubmitting}
          >
-            送信するのだ!
+            {isSubmitting ? "送信中なのだ..." : "送信するのだ!"}
          </button>
        </Form>
      </div>
+      {isSubmitting && (
+        <div className="fixed top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-50 z-50">
+          <div className="bg-white p-4 rounded-lg shadow-lg">
+            <p className="text-green-600 font-bold">
+              Now Loadingなのだ...
+            </p>
+          </div>
+        </div>
+      )}
    </div>
  );
}

...

useNavigationフックを利用してactionの実行状態を取得、実行状態が submitting であれば、isSubmittingフラグをTrueにします。フラグがTrueである間は、入力フォームと送信ボタンを無効化し、画面にオーバーロードする形でローディングメッセージを表示します。

 それでは最後の動作確認をしてみます。先ほどまで動作確認をしていたスレッドで追加でメッセージを送ってみます。送信ボタンを押すと...

スクリーンショット 2024-07-15 22.14.30.png

いい感じでローディングメッセージが出ています! また、画像左下を見てみると、送信ボタン内の文字が「送信中なのだ...」になっていることも分かります。
 actionの状態取得に関しては、今回利用したuseNavigationとuseFetcherがあるのですが、使い道が異なります。useFetcherに関しては結構ヘビーで今回の記事では触れませんので、またの機会に...

まとめと感想

 最近話題のWebフレームワークである Remix に、簡単なWebアプリケーションの実装を通して触れてみました。SSRに関して、Next.jsを勉強していた際に中々慣れることができず苦戦しましたが、Remixの loaderaction により、サーバーサイド/クライアントサイドをあまり意識せずに、直感的に実装できるのがかなりの利点だと感じました。ニコニコ動画(Re:仮)が3日で実装されたというのも頷けます。今回は基礎のキソということで、ルーティング、loader、actionにのみ触れましたが、まだまだ奥が深そうですので、これから沼にハマっていきたいと思います!

 また、今回はAnthropic Artifactsを利用して実現したプログラマーずんだもんと壁打ちをしながら進めるような感じの記事になりましたが、Anthropic Artifactsが強力であることを改めて実感しました。今回でいうと、特に図を作成してくれたり、コードを渡すことでスタイルの調整をしてくれたりしたのはすごく助かりました。まだまだハルネーションの問題がありますが、うまく付き合っていけば、エンジニアにとってかなり便利なツールになるのではと考えています。

 最後にずんだもんにお礼を言って本記事を締めくくりたいと思います。ご精読いただきありがとうございました!
スクリーンショット 2024-07-15 23.33.56.png

Appendix

 記事本編では、スペースの都合上バックエンドのコードを省略していましたが、Appendixとして掲載しますので、ご興味がある方はご覧ください。

スレッド一覧を取得するLambda

import json
import boto3
from botocore.exceptions import ClientError

# DynamoDBクライアントを初期化
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('zunda-chat-history')

def lambda_handler(event, context):
        response = table.scan(
            ProjectionExpression='SessionId'
        )

        # パーティションキーの値の一覧を取得
        partition_key_values = [item['SessionId'] for item in response['Items']]
        
        # 重複を削除して一意の値のリストにする
        unique_partition_key_values = list(set(partition_key_values))

        return {
            'statusCode': 200,
            'headers': {
                'Content-Type': 'application/json',
                'charset': 'utf-8'
            },
            'body': json.dumps({
                'partition_key_values': unique_partition_key_values
            }, ensure_ascii=False)
        }

会話履歴をデータベースから取得するLambda

import json
import boto3
from botocore.exceptions import ClientError

# DynamoDBクライアントを初期化
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('zunda-chat-history')

def lambda_handler(event, context):
        
        # APIGatewayからのパラメータを取得する
        partition_key = event['queryStringParameters']['key']
        
        # DynamoDBからアイテムを取得する
        response = table.get_item(
            Key={
                'SessionId': partition_key
            }
        )
        
        if 'Item' in response:
            message_list = []
            
            for message in response['Item']['History']:
                message_list.append(
                    {
                        'type': message['data']['type'],
                        'content': message['data']['content']
                    }
                )
            
            return {
                'statusCode': 200,
                'headers': {
                    'Content-Type': 'application/json',
                    'charset': 'utf-8'
                },
                'body': json.dumps(message_list, ensure_ascii=False)
            }

Bedrockのモデルを実行するLambda(会話履歴あり)

import json
import langchain
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

from langchain_community.chat_models import BedrockChat
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
from langchain_core.messages import AIMessage, HumanMessage

def chat(message, session_id):
  
    # システムプロンプト
    system_prompt = '''
    ずんだもんという少女を相手にした対話のシミュレーションを行います。
    明るい性格で、語尾は「〜のだ」、「〜なのだ」です。
    彼女の発言サンプルを以下に列挙します。
    
    こんにちは、僕はずんだもんなのだ。ずんだ餅の精なのだ。
    よろしくお願いしますなのだ!
    ずんだ餅のさらなる普及を夢見ているのだ。
    ずんだ餅は好きなのだ?
    ずんだ餅を食べたことはあるのだ?
    ずんだ餅はおいしいのだ!
    ずんだアローに返信することが出来るのだ!
    ずんだもんの魅力で子どもファンをゲットなのだ!
    
    上記例を参考に、ずんだもんの性格や口調、言葉の作り方を模倣してください。また、回答の際は絵文字も利用して下さい
    '''

    # 会話履歴をデータベースから取得
    message_history = DynamoDBChatMessageHistory(table_name="zunda-chat-history", session_id=session_id)

    # プロンプトテンプレートの作成(会話履歴は"history"に入ることになる)
    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="history"),
        MessagesPlaceholder(variable_name="human_input")
    ])
    
    # モデルにClaude3.5 Sonnetを選択 
    LLM = BedrockChat(
        model_id = "anthropic.claude-3-5-sonnet-20240620-v1:0",
        region_name = "us-east-1"
    )

    # チェーンを作成
    chain = prompt | LLM

    # チェーンの実行
    human_input = [HumanMessage(content=message)]
    resp = chain.invoke(
        {
            "history": message_history.messages,
            "human_input": human_input,
        }
    )

    response = resp.content

    # 会話履歴にユーザーのメッセージとモデルのレスポンスを追加
    message_history.add_user_message(message)
    message_history.add_ai_message(response)

    return response


def lambda_handler(event, context):
    print(event)
    
    message = chat(event['message'], event['sessionid'])
    
    print(message)
    
    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'charset': 'utf-8'
        },
        'body': json.dumps(message, ensure_ascii=False)
    }
49
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
20