新年なのでLLMとのチャットアプリをイチから作ってみた①画面モックを作成

新年なので（？）、いちからLLMチャットアプリを作りたくなりました。

漠然とこんな条件で検討を始めました。

* LLMはもちろん、Bedrockを使う
* AWSのサーバーレスなサービスだけを使用する（固定費がほぼかからない構成）


いちからと言っても、いい感じのものがないか探したところ、[Skeleton](https://www.skeleton.dev/)にチャットのコンポーネントが用意されていることを知りました。

* https://www.skeleton.dev/elements/chat
    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/111896d5-eb60-9507-92d1-5025c9b4bfd0.png)

https://www.skeleton.dev/

これを使ってチャレンジしました。

:::note
SvelteもSvelteKitもTailwindもほぼ未経験デス。。
:::

## 目次

全3回に分けて投稿しています。

* 新年なのでLLMとのチャットアプリをイチから作ってみた①　←このページ
* [新年なのでLLMとのチャットアプリをイチから作ってみた②](6fa9c5ceb28feac3f741)
* [新年なのでLLMとのチャットアプリをイチから作ってみた③](0b735c6d2fa033f532c3)　（執筆中）


## プロジェクト作成

まずは、[Get Started](https://www.skeleton.dev/docs/get-started)を参考にプロジェクトを作成します。

```shell
npm create skeleton-app@latest my-skeleton-app
```

ウィザード方式になっていますので回答していきます。
よくわからないので、最小構成っぽい形にしました。

```
┌  Create Skeleton App (version 0.0.54)

Welcome to Skeleton 💀! A UI toolkit for Svelte + Tailwind

Problems? Open an issue on https://github.com/skeletonlabs/skeleton/issues if none exists already.
│
◇  Which Skeleton app template?
│  Bare Bones
│
◇  Select a theme (top most selection will be default):
│  Skeleton
│
◇
What other packages would you like to install:
│  none
│
◇  Add type checking with TypeScript?
│  Yes, using TypeScript syntax
│
◇  What would you like setup in your project:
│  none
│
◇  Done installing

Done! You can now:

cd my-skeleton-app
npm install
npm run dev
Need some help or found an issue? Visit us on Discord https://discord.gg/EXqV7W8MtY
```

指示に従い`npm run dev`までやってみます。

```shell
cd my-skeleton-app
npm install
npm run dev
```

ブラウザで`http://localhost:5173/`にアクセスしましょう。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/511ae1ce-5451-f25d-12b0-12b1c5c3276e.png)

表示されました。

:::note
VSCodeで作業を進める場合は、拡張機能「[Svelte for VS Code](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode)」（svelte.svelte-vscode）をインストールすると、コードフォーマットなどができて便利です。
:::

## チャットの画面を構成する

プロジェクトの作成ができたので、[こちら](https://www.skeleton.dev/elements/chat)を参考に、チャット画面を作っていきます。

skeletonのドキュメントはよくできていて、「Preview（目のアイコン）」と「Code（</>のアイコン）」を切り替えることができます。


* Preview

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/5c19ded5-9808-a246-22e6-2a4400ba8031.png)

* Code

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/6b6f563c-ec7d-0e9e-29c7-c5abeca77567.png)

Codeで表示されるものをコピペしながらチャット画面を作っていきましょう。

編集するソースは`src/routes/+page.svelte`です。

:::note
以下のコードが書かれていますが、バッサリ全て消し去りましょう。


```html
<!-- YOU CAN DELETE EVERYTHING IN THIS PAGE -->

<div class="container h-full mx-auto flex justify-center items-center">
	<div class="space-y-5">
		<h1 class="h1">Let's get cracking bones!</h1>
		<p>Start by exploring:</p>
		<ul>
			<li><code class="code">/src/routes/+layout.svelte</code> - barebones layout</li>
			<li><code class="code">/src/app.postcss</code> - app wide css</li>
			<li>
				<code class="code">/src/routes/+page.svelte</code> - this page, you can replace the contents
			</li>
		</ul>
	</div>
</div>
```
:::

1. Layout Columns

    まずは、Layout Columnsのコードを持ってきます。`Two Column Layout`の方を選びました。
    
    ```html:src/routes/+page.svelte
    <div class="w-full grid grid-cols-[auto_1fr] gap-1">
    	<div class="bg-surface-500/30 p-4">(nav)</div>
    	<div class="bg-surface-500/30 p-4">(feed)</div>
    </div>
    ```
    
    devサーバーが起動していれば、ソースを保存すると自動で反映されます。

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/627f7512-50fc-8f3b-12ca-133975042b0c.png)

    2カラムのレイアウトができました。

1. Layout Rows

    続いてLayout Rowsです。Three Row Layoutは左側のカラムの中に、Two Row Layoutを右側のカラムの中にセットします。
    
    左側は(nav)を置換、右側は(feed)を置換します。
    
    ```diff_html
      <div class="w-full grid grid-cols-[auto_1fr] gap-1">
    -   <div class="bg-surface-500/30 p-4">(nav)</div>
    + 	<div class="bg-surface-500/30 p-4">
    + 		<div class="h-full grid grid-rows-[auto_1fr_auto] gap-1">
    + 			<div class="bg-surface-500/30 p-4">(search)</div>
    + 			<div class="bg-surface-500/30 p-4">(list)</div>
    + 			<div class="bg-surface-500/30 p-4">(footer)</div>
    + 		</div>
    + 	</div>
    - 	<div class="bg-surface-500/30 p-4">
    + 		<div class="h-full grid grid-rows-[1fr_auto] gap-1">
    + 			<div class="bg-surface-500/30 p-4 overflow-y-auto">(feed)</div>
    + 			<div class="bg-surface-500/30 p-4">(prompt)</div>
    + 		</div>
    + 	</div>
      </div>
    ```

1. Message Feed
    
    Message FeedはTypeScriptとHTMLで構成されています。
    
    TypeScriptコードは、`src/routes/+page.svelte`の先頭にscriptタグを追加し、その中に記述します。
    
    ```html
    <script lang="ts">
    	let messageFeed = [
    		{
    			id: 0,
    			host: true,
    			avatar: 48,
    			name: "Jane",
    			timestamp: "Yesterday @ 2:30pm",
    			message: "Some message text.",
    			color: "variant-soft-primary",
    		},
    		{
    			id: 1,
    			host: false,
    			avatar: 14,
    			name: "Michael",
    			timestamp: "Yesterday @ 2:45pm",
    			message: "Some message text.",
    			color: "variant-soft-primary",
    		},
    	];
    </script>
    
    (このあと<dvv>タグが続きます)
    ```
    
    HTMLの部分は`(feed)`のところを置換します。
    （ちょっと画面が中途半端な感じですが、このあと修正していきますので続けます。

1. Message Bubbles

    個々のメッセージの部分です。HostとGuestのやり取りなので、
    
    * Hostの場合：左側から発言
    * Guestの場合：右側から発言
    
    の見た目になるようになっています。
    
    1. Scriptタグの先頭に
        `import { Avatar } from "@skeletonlabs/skeleton";`
        を追加
    1. `<!-- Host Message Bubble -->`、
        `<pre>host: {JSON.stringify(bubble, null, 2)}</pre>`
        の2行を消して、「Host Bubble Template」を貼り付ける
    1. `<!-- Guest Message Bubble -->`、
        `<pre>guest: {JSON.stringify(bubble, null, 2)}</pre>`
        の2行を消して、「Guest Bubble Template」を貼り付ける
    
    チャットっぽくなってきました。

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/d213b864-b9a3-b731-58dc-d6a9df3e2a73.png)

1. Prompt

    チャットのテキストを入力するPrompt欄です。
    
    TypeScriptのコードをScriptタグ内の末尾に、HTMLのコードを(prompt)に置換します。
    
    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/42442b41-7949-1f8e-8e8b-488e871584b0.png)

    あれ？若干ダサめですね。。
    
    [Forms](https://www.skeleton.dev/elements/forms)のページを確認すると、Forms用のプラグインを導入する必要があるようです。
    
    一度devサーバーを`Ctrl+c`で停止し、以下のコマンドを実行します。
    
    ```shell
    npm install -D @tailwindcss/forms
    ```
    
    `my-skeleton-app/tailwind.config.ts`に以下の設定を追加します。
    
    ```diff_javascript
      import { join } from 'path'
      import type { Config } from 'tailwindcss'
      import { skeleton } from '@skeletonlabs/tw-plugin'
    + import forms from '@tailwindcss/forms';
      
      export default {
      	darkMode: 'class',
      	content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')],
      	theme: {
      		extend: {},
      	},
      	plugins: [
      		skeleton({
      			themes: {
      				preset: [
      					{
      						name: 'skeleton',
      						enhancements: true,
      					},
      				],
      			},
      		}),
    + 		forms,
      	],
      } satisfies Config;
    ```

    `npm run dev`でdevサーバーを起動して画面を確認するとPrompt部分の違和感がなくなったと思います。（下の余白が気になりますが、後で修正します。）

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/c59cfd6b-4fb4-3a7d-8a99-e357c7015c91.png)

1. Scroll to Bottom

    どんどん行きましょう。メッセージが追加されたときに、自動的に最新メッセージが画面内に表示されるようにスクロールする処理です。

    1. Scriptタグ内に`let elemChat: HTMLElement;`を追加します。
    1. `bind:this={elemChat} class="overflow-y-auto`を39行目辺りにあるsectionタグに追加します。
    
        ```diff_html
        - <section class="w-full max-h-[400px] p-4 overflow-y-auto space-y-4">
        + <section bind:this={elemChat} class="w-full max-h-[400px] p-4 overflow-y-auto space-y-4">
        ```
    
    1. Scriptタグ内にscrollChatBottom関数を追加します。

1. Add a Message

    メッセージを追加する処理です。
    
    Scriptタグ内にaddMessage関数を追加しますが、ドキュメントのままだとエラーになります。

    [Page Source](https://github.com/skeletonlabs/skeleton/blob/master/sites/skeleton.dev/src/routes/(inner)/elements/chat/+page.svelte)から該当の部分（`getCurrentTimestamp()`と`addMessage()`）を持ってきます。

    ```javascript
    function getCurrentTimestamp(): string {
      return new Date().toLocaleString("en-US", {
        hour: "numeric",
        minute: "numeric",
        hour12: true,
      });
    }
    
    function addMessage(): void {
      const newMessage = {
        id: messageFeed.length,
        host: true,
        avatar: 48,
        name: "Jane",
        timestamp: `Today @ ${getCurrentTimestamp()}`,
        message: currentMessage,
        color: "variant-soft-primary",
      };
      // Update the message feed
      messageFeed = [...messageFeed, newMessage];
      // Clear prompt
      currentMessage = "";
      // Smooth scroll to bottom
      // Timeout prevents race condition
      setTimeout(() => {
        scrollChatBottom("smooth");
      }, 0);
    }
    ```

    SendボタンのクリックイベントでaddMessage関数を呼び出します。

    ```diff_html
    - button class="variant-filled-primary">Send</button>
    + button class="variant-filled-primary" on:click={addMessage}>Send</button>
    ```

    ここまでで、チャットっぽい入力までができました。

    ![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/3076d8a1-f3d7-a1f2-2ae5-91e3747621d1.gif)

## 色々改善

すこし気になるところが出てきたので、改善しました。

1. 左側ペインは使わなそうなので削除

    * ばっさり削除
	* 大本のdivタグから`grid grid-cols-[auto_1fr]`のクラスを削除

1. チャットの見た目（アイコン、色）を調整

	| 項目 | AI側 | ユーザー側 | 備考 | 
	| --- | --- | --- | --- | 
	| アイコン | 🤖 | 😀 | |
	| 色 | variant-glass-primary | variant-glass-tertiary | [Variants](https://www.skeleton.dev/docs/variants) |
	| フォントサイズ　| 320 | 320 | |

1. 初期表示をAI側だけの吹き出しに変更

	`messageFeed`の１つ目の項目を削除。あとはすこし値を変更

1. 画面いっぱいに表示されるように調整

	* sectionタグにある`max-h-[400px]`を削除
	* 大本のdivタグに`h-screen`クラスを追加
	* 大本のdivタグの子のdivタグからsectionタグまで`h-full`クラスを追加

	参考：[Height](https://tailwindcss.com/docs/height)

1. プロンプト入力後、`Ctrl+Enter`でプロンプト入力を確定させられるように調整

    :::note
	Enterのみで確定させたかったけど、日本語変換時のEnter入力との競合が発生したので、Ctrlとの同時押しにしました。
   :::

	* onPromptKeydown関数を追加	

		```javascript
		function onPromptKeydown(event: KeyboardEvent): void {
			if (event.ctrlKey && ["Enter"].includes(event.code)) {
				event.preventDefault();
				addMessage();
			}
		}
		```
	* textareaタグに`on:keydown={onPromptKeydown}`を追加

1. プロンプト入力して1秒後に、AI側がオウム返しするように修正

	* addAiMessage関数を追加

        ```javascript
        function addAiMessage(userMessage: string): void {
            const newMessage = {
                id: messageFeed.length,
                host: false,
                name: "Claude",
                timestamp: `Today @ ${getCurrentTimestamp()}`,
                message: `あなたは「${userMessage}」といいました。`,
                color: "variant-glass-primary",
            };
            // Update the message feed
            messageFeed = [...messageFeed, newMessage];
        
            // Smooth scroll to bottom
            // Timeout prevents race condition
            setTimeout(() => {
                scrollChatBottom("smooth");
            }, 0);
        }
        ```

	* addMessage関数の中でaddAiMessage関数を呼び出す

		```javascript
		const tmpMessage = currentMessage;
			setTimeout(() => {
				addAiMessage(tmpMessage);
			}, 1000);
		```

いい感じになってきました😁😁😁

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/1ba34176-03bc-1f99-b43e-1e584823b0d0.gif)

---

長くなりそうなので、今回はここまでです。次回はAWSと接続していきます。

---


ソースコードの全体はこちらで確認可能です。

https://github.com/moritalous/skeleton-chat/

* 該当のソースコード

https://github.com/moritalous/skeleton-chat/blob/main/src/routes/v1/%2Bpage.svelte