ChatGPTでDalle(画像生成)用のプロンプトを作るアプリ②(Amplify等技術面紹介)

# はじめに
本記事は[#AWSAmplifyJP Advent Calendar 2023](https://qiita.com/advent-calendar/2023/amplify)の13日目の記事です。
[クソアプリ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/kuso-app)に参加するために、ChatGPTでDalle(画像生成)用のプロンプトを作りました。
尚、アプリの機能は下記記事で紹介しています。

https://qiita.com/tanosugi/private/927add61c89565cd5448

# Demo

https://easy-image.tanosugi.com/

# 使った技術
この記事では使った技術を紹介したいと思います。

# Amplify
Amplifyの機能を多く使ったので、コード等とともに紹介します。

# Figma to Code
まず、FigmaでLPや、Webアプリのコンポーネントを作り、Figma to Code機能でReactのコードを得ました。アプリの種類にもよりますが、自分でゼロからコード書く場合の数分の一から半分のコーディングで済みます。AIにコードを書いてもらうvercelのv0等が最近登場しましたが、個人的にはプロンプトで指示するよりGUIで書いたものがコードになる方が好きです。

![スクリーンショット 2023-12-10 172216.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/639092/ff142256-2b49-2269-0c2e-88aa6ffd8dfc.png)

<details><summary>自動生成されたReactコードの一例</summary>

```typescript:HeroView.jsx
/***************************************************************************
 * The contents of this file were generated with Amplify Studio.           *
 * Please refrain from making any modifications to this file.              *
 * Any changes to this file will be overwritten when running amplify pull. *
 **************************************************************************/

/* eslint-disable */
import * as React from "react";
import { getOverrideProps } from "./utils";
import { Button, Flex, Image, Text } from "@aws-amplify/ui-react";
export default function HeroView(props) {
  const { overrides, ...rest } = props;
  return (
    <Flex
      gap="0"
      direction="row"
      width="1490px"
      height="unset"
      justifyContent="center"
      alignItems="center"
      position="relative"
      padding="0px 0px 0px 0px"
      {...getOverrideProps(overrides, "HeroView")}
      {...rest}
    >
      <Flex
        gap="10px"
        direction="column"
        width="720px"
        height="392px"
        justifyContent="space-between"
        alignItems="center"
        shrink="0"
        position="relative"
        padding="0px 0px 0px 0px"
        {...getOverrideProps(overrides, "Frame 3")}
      >
        <Flex
          gap="24px"
          direction="column"
          width="316px"
          height="unset"
          justifyContent="center"
          alignItems="center"
          grow="1"
          shrink="1"
          basis="0"
          position="relative"
          padding="0px 0px 0px 0px"
          {...getOverrideProps(overrides, "HeroMessage")}
        >
          <Flex
            gap="16px"
            direction="column"
            width="unset"
            height="unset"
            justifyContent="center"
            alignItems="center"
            shrink="0"
            alignSelf="stretch"
            position="relative"
            padding="0px 0px 0px 0px"
            {...getOverrideProps(overrides, "Message")}
          >
            <Text
              fontFamily="Inter"
              fontSize="16px"
              fontWeight="700"
              color="rgba(64,170,191,1)"
              lineHeight="24px"
              textAlign="center"
              display="block"
              direction="column"
              justifyContent="unset"
              width="unset"
              height="unset"
              gap="unset"
              alignItems="unset"
              shrink="0"
              alignSelf="stretch"
              position="relative"
              padding="0px 0px 0px 0px"
              whiteSpace="pre-wrap"
              children="Easy Image"
              {...getOverrideProps(overrides, "Eyebrow")}
            ></Text>
            <Text
              fontFamily="Inter"
              fontSize="24px"
              fontWeight="600"
              color="rgba(13,26,38,1)"
              lineHeight="30px"
              textAlign="center"
              display="block"
              direction="column"
              justifyContent="unset"
              width="unset"
              height="unset"
              gap="unset"
              alignItems="unset"
              shrink="0"
              alignSelf="stretch"
              position="relative"
              padding="0px 0px 0px 0px"
              whiteSpace="pre-wrap"
              children="画像を簡単に生成"
              {...getOverrideProps(overrides, "Heading")}
            ></Text>
            <Text
              fontFamily="Inter"
              fontSize="16px"
              fontWeight="400"
              color="rgba(48,64,80,1)"
              lineHeight="24px"
              textAlign="center"
              display="block"
              direction="column"
              justifyContent="unset"
              letterSpacing="0.01px"
              width="unset"
              height="unset"
              gap="unset"
              alignItems="unset"
              shrink="0"
              alignSelf="stretch"
              position="relative"
              padding="0px 0px 0px 0px"
              whiteSpace="pre-wrap"
              children="生成したい画像について簡単に伝えるだけで、自動で画像生成用のプロンプトを作り、実行できます。"
              {...getOverrideProps(overrides, "Body")}
            ></Text>
          </Flex>
          <Button
            width="unset"
            height="unset"
            shrink="0"
            size="large"
            isDisabled={false}
            variation="primary"
            children="試してみる"
            {...getOverrideProps(overrides, "Button")}
          ></Button>
        </Flex>
      </Flex>
      <Image
        width="720px"
        height="392px"
        display="block"
        gap="unset"
        alignItems="unset"
        justifyContent="unset"
        shrink="0"
        position="relative"
        padding="0px 0px 0px 0px"
        objectFit="cover"
        {...getOverrideProps(overrides, "1700652527621-cat 1")}
      ></Image>
    </Flex>
  );
}
```
</details>

# Amplify Form Builder, react-modal
入力フォームはForm Builderで作りました。react-modalで、一つのフォームが入力終わって送信されたら次のモーダルが開くようにしました。

![スクリーンショット 2023-11-23 174823.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/639092/4fa3d2cc-b663-fe30-2672-38e6924e41c1.png)


<details><summary>コード</summary>

```typescript:createSaveImageModal.tsx

"use client";
import Modal from "react-modal";
import { CreatedImage } from "@/models";
import {
  createFromDalleAndSaveToS3,
  getSignedS3UrlFromKey,
} from "@/utils/createSaveImage";
import { Button, Flex, Loader } from "@aws-amplify/ui-react";
import { DataStore } from "aws-amplify/datastore";
import React, {
  ChangeEvent,
  useState,
} from "react";
import PromptCreateForm from "@/ui-components/PromptCreateForm";
import PromptEditForm from "@/ui-components/PromptEditForm";
import createPrompt from "@/utils/chat";
import ImageCreateSuccessView from "@/ui-components/ImageCreateSuccessView";

export default function CreateSaveImageModal({
  openApiKey,
}: {
  openApiKey: string;
}) {
  const [modalToOpen, setModalToOpen] = useState("");
  const [generationTarget, setGenerationTarget] = useState("");
  const [adjective, setAdjective] = useState("");
  const [languageOfPrompt, setLanguageOfPrompt] = useState("");
  const [numberOfWordsPrompt, setNumberOfWordsPrompt] = useState("");
  const [prompt, setPrompt] = useState("");
  const [imageUrl, setImageUrl] = useState("");
  const [loadingDalle, setLoadingDalle] = useState(false);
  const createImage = async (prompt: string) => {
    setLoadingDalle(true);
    const key = await createFromDalleAndSaveToS3(openApiKey, prompt);
    const url = await getSignedS3UrlFromKey(key);
    setImageUrl(url);
    DataStore.save(new CreatedImage({ title: prompt, s3Url: key }));
    setLoadingDalle(false);
  };
  const onSubmitPromptCreateForm = async () => {
    setModalToOpen("CreatingPrompt");
    const resp = await createPrompt({
      openApiKey: openApiKey,
      generationTarget: generationTarget,
      adjective: adjective,
      languageOfPrompt: languageOfPrompt,
      numberOfWordsPrompt: numberOfWordsPrompt,
    });
    setPrompt(resp);
    setModalToOpen("PromptEditForm");
  };
  const onSubmitPromptEditForm = async () => {
    setModalToOpen("CreatingImage");
    await createImage(prompt);
    setModalToOpen("ImageCreateSuccessView");
  };
  return (
    <>
      {loadingDalle ? (
        "Creating Image..."
      ) : (
        <Button
          variation="primary"
          onClick={() => setModalToOpen("PromptCreateForm")}
        >
          プロンプトを作って画像を生成する
        </Button>
      )}
      <Modal isOpen={modalToOpen == "PromptCreateForm"}>
        <PromptCreateForm
          onSubmit={onSubmitPromptCreateForm}
          overrides={{
            generationTarget: {
              value: generationTarget,
              onChange: (event: ChangeEvent<HTMLInputElement>) =>
                setGenerationTarget(event.target.value),
            },
            adjective: {
              value: adjective,
              onChange: (event: ChangeEvent<HTMLInputElement>) =>
                setAdjective(event.target.value),
            },
            languageOfPrompt: {
              value: languageOfPrompt,
              onChange: (event: any) => {
                setLanguageOfPrompt(event.target.value);
                // console.log("languageOfPrompt:", languageOfPrompt);
              },
            },
            numberOfWordsPrompt: {
              value: numberOfWordsPrompt,
              onChange: (event: any) => {
                setNumberOfWordsPrompt(event.target.value);
                console.log("numberOfWordsPrompt:", numberOfWordsPrompt);
              },
            },
          }}
        />
      </Modal>
      <Modal isOpen={modalToOpen == "CreatingPrompt"}>
        <Flex
          height="100%"
          direction="column"
          alignItems="center"
          justifyContent={"center"}
        >
          {"プロンプトを生成しています。。。"}
          <Loader variation="linear" />
        </Flex>
      </Modal>
      <Modal isOpen={modalToOpen == "PromptEditForm"}>
        <PromptEditForm
          onSubmit={onSubmitPromptEditForm}
          overrides={{
            Field0: {
              defaultValue: prompt,
              value: prompt,
              onChange: (event: any) => {
                let { value } = event.target;
                setPrompt(value);
              },
            },
          }}
        />
      </Modal>
      <Modal isOpen={modalToOpen == "CreatingImage"}>
        <Flex
          height="100%"
          direction="column"
          alignItems="center"
          justifyContent={"center"}
        >
          {"画像を生成しています。。。"}
          <Loader variation="linear" />
        </Flex>
      </Modal>
      <Modal isOpen={modalToOpen == "ImageCreateSuccessView"}>
        {imageUrl != "" && (
          <ImageCreateSuccessView
            overrides={{
              "1700652527621-cat 1": { src: imageUrl },
              Button: {
                onClick: () => {
                  setModalToOpen("");
                  setImageUrl("");
                },
              },
              prompt: { children: prompt },
            }}
          />
        )}
      </Modal>
    </>
  );
}
```

</details>

# Datastore, Figma to CodeのCollection
生成したプロンプトや画像の一覧はDatastoreに保存し、Figma to Codeで作ったコンポーネントを使って表示するようにしました。Collectionという、Figmaで作ったコンポーネントを複数表示する機能を使いました。

<details><summary>コード</summary>

```typescript:showImageCollection.tsx
"use client";
import { CreatedImage } from "@/models";
import ImageCardViewCollection from "@/ui-components/ImageCardViewCollection";
import { getSignedS3UrlFromKey } from "@/utils/createSaveImage";
import { DataStore, Predicates, SortDirection } from "aws-amplify/datastore";

import { useEffect, useState } from "react";

export default function App() {
  const [url, setUrl] = useState("");
  const [createdImages, setCreatedImages] = useState<CreatedImage[]>([]);

  const fetchCreatedImage = async () => {
    const resp = await DataStore.query(CreatedImage, Predicates.ALL, {
      sort: (s) => s.createdAt(SortDirection.DESCENDING),
    });
    setCreatedImages([]);
    const imgs: CreatedImage[] = [];
    resp.forEach(async (item) => {
      const urlResp = await getSignedS3UrlFromKey(item.s3Url);
      await setUrl(urlResp);
      const img: CreatedImage = new CreatedImage({
        title: item.title,
        s3Url: urlResp,
      });
      imgs.push(img);
    });
    setCreatedImages(imgs);
  };
  useEffect(() => {
    fetchCreatedImage();
    const subscription =
      DataStore.observe(CreatedImage).subscribe(fetchCreatedImage);
    return () => {
      subscription.unsubscribe();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return (
    <>
      <ImageCardViewCollection
        items={createdImages}
        overrideItems={({ item, index }) => ({
          overrides: { ImageCardView: { width: "100%" } },
        })}
      />
    </>
  );
}
```
</details>

# S3
生成した画像はAmplify経由でS3に格納し、一覧表示するときはSignedURLを取得するようにしました。
<details><summary>コード</summary>

```typescript:createSaveImage.ts
export async function uploadBlobToS3(blob: Blob) {
  const key = `${Date.now()}-cat.png`;
  try {
    const result = await uploadData({
      key: key,
      data: blob,
      options: { accessLevel: "guest", contentType: "image/png" },
    }).result;
    console.log("Succeeded: ", result);
  } catch (error) {
    console.log("Error : ", error);
  }
  return key;
}

export async function getSignedS3UrlFromKey(key: string) {
  const getUrlResult = await getUrl({
    key: key,
    options: { validateObjectExistence: false, expiresIn: 20 },
  });
  return getUrlResult.url.toString();
}
```
</details>

# 認証(パスワード、Googleログイン)
Amplifyでは認証はfirebase等と同じ感覚でかんたんに導入できます。GoogleログインはGCPとの間でAPI Key等を相互に参照するので、GUIで少し設定が必要です。

<details><summary>コード</summary>

```typescript:
return (
    <Authenticator>
      {children}
    </Authenticator>
  );
```
</details>

# Nextjs 14
Amplify HostingでNextjs 14を使う場合は、Amazon Linux 2023をビルドイメージに使うだけです。Nextjs 14がリリースされてしばらくしてからサポートされました。
https://github.com/aws-amplify/amplify-hosting/issues/3773

# Langchain, ChatGPT API
ここからはAmplifyとは別の今回使った技術です。最初にChatGPTにプロンプトを送って、ChatGPTに画像生成用のプロンプトを作ってもらいますが、ユーザーが入力した単語や数字等をプロンプトに埋め込むのにLangchainのLCEL (LangChain Expression Language）を使いました。
<details><summary>コード</summary>

```typescript:chat.ts
"use server";
import { PromptTemplate } from "langchain/prompts";
import { ChatOpenAI } from "langchain/chat_models/openai";
export default async function createPrompt({
  openApiKey,
  generationTarget,
  adjective,
  languageOfPrompt,
  numberOfWordsPrompt,
}: {
  openApiKey: string;
  generationTarget: string;
  adjective: string;
  languageOfPrompt: string;
  numberOfWordsPrompt: string;
}) {
  const model = new ChatOpenAI({ openAIApiKey: openApiKey });
  console.log("languageOfPrompt:", languageOfPrompt);
  const promptTemplate = PromptTemplate.fromTemplate(
    `下記条件でDalleに出力させるためのプロンプトを考えてください。箇条書きではなく文章で

生成したいもの: {generationTarget}
どんな画像にしたいか: {adjective}
制約1:プロンプトのみを返してください。プロンプトの説明は不要です。
制約2:プロンプトは一つだけ作成してください。
制約3:プロンプトは出力が美しくなるように{numberOfWordsPrompt}単語前後で詳細に作成してください。
プロンプトの言語:{languageOfPrompt}
`
  );

  const chain = promptTemplate.pipe(model);
  console.log(chain);
  const result = await chain.invoke({
    generationTarget: generationTarget,
    adjective: adjective,
    languageOfPrompt: languageOfPrompt,
    numberOfWordsPrompt:
      numberOfWordsPrompt && numberOfWordsPrompt != ""
        ? numberOfWordsPrompt
        : "20",
  });
  console.log(result);
  return String(result.content);
}
```
</details>

# Dalle API
Ddalle APIはresponse_formatにb64_jsonを指定して、画像を取得しました。

<details><summary>コード</summary>

```typescript:createDalleImageAsBase64.ts
"use server";
import OpenAI from "openai";
export async function createDalleImageAsBase64({
  openApiKey,
  prompt,
}: {
  openApiKey: string;
  prompt: string;
}) {
  const openai = new OpenAI({
    apiKey: openApiKey,
  });
  const image = await openai.images.generate({
    model: "dall-e-2",
    prompt: prompt,
    response_format: "b64_json",
    size: "256x256",
  });
  const b64_json: string = image.data[0].b64_json ? image.data[0].b64_json : "";
  return b64_json;
}
```
</details>

# b64-to-blob
取得した画像をS3に格納する前に、blobに変換しました。
<details><summary>コード</summary>

```typescript:createSaveImage.ts
export async function createFromDalleAndSaveToS3(
  openApiKey: string,
  prompt: string
) {
  const b64_json = await createDalleImageAsBase64({
    openApiKey: openApiKey,
    prompt: prompt,
  });
  const blob = b64toblob(b64_json);
  const key = await uploadBlobToS3(blob);
  return key;
}
```
</details>

# まとめ
AmpliifyやChatGPTのAPIを使うとスピーディーにアプリが作れるので、プロトタイピングだけでなくハッカソンにもお勧めです。