Azure Durable Functions で遊んでみた Part2 - チャット風クイズアプリを作る(クライアント編)

# はじめに

この記事では、Durable Functionsのタイマー処理や外部イベント、永続的オーケストレーションを使用して、チャット風クイズアプリを作る方法を紹介します。
前回の記事ではバックエンド部分を実装したので、今回はそれを呼び出すクライアント部分の実装をしていきます。

:::note
この記事では**クライアント部分**のみを扱っています。
バックエンド部分についてはバックエンド編にて記載しています。
:::

**バックエンド編：**

https://qiita.com/fsdg-nagumo/private/397f7820f700ac36e456

# バックエンド編のおさらい

## ざっくりの仕様

- クイズのお題を入力してクイズ開始すると、サーバーでクイズを作成しクライアントに送信する
    - クイズは選択式問題にする
- クライアントから回答が送信されたら、答え合わせしてクライアントに解答を送信する
- 回答の制限時間を設け、制限時間が過ぎたら回答を打ち切ってクライアントに解答を送信する
- 答え合わせの後、新たなクイズを作成しクライアントに送信する
- 成績の集計や表示はしない

## 全体構成

赤枠内が今回関連する範囲です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/48ec81c2-b2b8-5154-f728-f996ec82f46e.png)

# 完成イメージ

操作の流れ
1. 出題開始
1. 出題 → 回答送信 → 解答表示
1. 次の出題 → 時間切れ (制限時間は10秒にしている) → 解答表示
1. 出題終了

![ai_chat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/36004246-d645-55c0-4250-743fe77bb0f8.gif)

# 使用するフレームワークなど

UI部分はライブラリを使って楽していきます。

- [Next.js](https://nextjs.org/)
- [Mantine](https://mantine.dev/) (UI フレームワーク)
- [chat-ui-kit-react](https://chatscope.io/storybook/react/?path=/docs/documentation-introduction--page) (チャットUI)

# さっそく実装！

:::note warn
UI部分の実装については深く触れないので悪しからず。あくまでバックエンドとのやり取りの部分に絞って記載していきます。
:::

## プロジェクトの作成

Next.js のプロジェクトを作成します。
手順は Getting Started の通りです。
対話形式で色々聞かれるので、すべてデフォルトを選択しました。

```bash:bash
npx create-next-app@latest --typescript
```

## 必要なパッケージのインストール

その他使用するパッケージをインストールします。

```bash:bash
npm i -E @mantine/core @mantine/hooks @mantine/notifications @emotion/react @chatscope/chat-ui-kit-react @chatscope/chat-ui-kit-styles @microsoft/signal uuid
npm i -D @types/uuid
```

## 機能の作成

作成する機能は以下の通りです。

| No. | 機能                   | 概要                                          |
| ---: | -------------------- | ------------------------------------------- |
| 1   | Azure SignalR との接続確立 | negotiate 関数を呼び出して Azure SignalR へ接続します。    |
| 2   | 出題開始                 | startChat 関数を呼び出して、サーバー側のオーケストレーションを開始します。  |
| 3   | サーバーからの通知のハンドリング     | サーバーから Azure SignalR に対して送信された通知を受信して処理します。 |
| 4   | サーバーにメッセージ送信         | オーケストレーターに外部イベントをとしてチャットの入力内容を送信します。        |
| 5   | 出題終了                 | オーケストレーターの終了イベントを呼び出します。                    |

### 1. Azure SignalR との接続確立

```tsx:src/hooks/useSignalR.tsx (抜粋)
  /** SignalRの接続オブジェクト */
  const connection = useMemo(
    () =>
      new signalR.HubConnectionBuilder()
        .withUrl(`http://localhost:7071/api?userId=${userId}`)
        .withAutomaticReconnect({
          nextRetryDelayInMilliseconds: () => 5000,
        })
        .configureLogging(signalR.LogLevel.Debug)
        .build(),
    [userId]
  )

  /** 接続の確立 */
  const start = useCallback(async () => {
    try {
      if (connection.state !== 'Disconnected') return
      await connection.start()
      setIsConnected(true)
    } catch (error) {
      console.error(error)
      setTimeout(start, 5000)
    }
  }, [connection])
```

`signalR.HubConnectionBuilder().build()` で接続用のインスタンスを作成します。
`withUrl` にはローカルで起動しているバックエンドのエンドポイントを指定します。
`/api` までで終わっていますが、自動的に `/negotiate` がくっつきます。
そしてクエリパラメータで `userId` を指定しています。
(実際のURL: `http://localhost:7071/api/negotiate?userId=${userId}`)

`connection.start()` で Azure SignalR に接続します。
(negotiate 関数からアクセストークン取得 → アクセストークンを使用して Azure SignalR に接続)

### 2. 出題開始

```tsx:src/hooks/useQuizChat.tsx (抜粋)
  /** 出題開始(Durable Functions 起動) */
  const start = useCallback(async () => {
    setChatStatus('Starting')
    // 直近で実行したオーケストレーターインスタンスのID
    const currentInstanceId = checkStatusResponse?.id

    try {
      const body: StartChatRequestBody = { userId, quizTopic, currentInstanceId }
      const response = await fetch('http://localhost:7071/api/startQuizChat', {
        method: 'POST',
        body: JSON.stringify(body),
      })

      const checkStatusResponse: CheckStatusResponse = await response.json()

      setCheckStatusResponse(checkStatusResponse)
      setChatStatus('Started')
    } catch (error) {
      setChatStatus('Terminated')
      throw error
    }
  }, [userId, quizTopic, checkStatusResponse, setCheckStatusResponse])
```

以下のパラメーターを指定して `startQuizChat` にリクエストすることで、オーケストレーションが開始されます。
|No.|パラメータ名|説明|
|---:|---|---|
|1|userId|Azure SignalR 接続時の userId|
|2|quizTopic|クイズのお題 (画面入力)|
|3|currentInstanceId|直近に実行されたオーケストレーター (orchestrateQuizChat) のインスタンスID<br/>**※後述**|

このリクエストに対して以下の様な、オーケストレーターにアクセスするためのURLを含んだレスポンスが返ってきます。

上記 `currentInstanceId` はここで取得できる `id` のことです。
出題開始 → 画面リロード → 出題開始 という操作をされてインスタンスが2重起動するのを防ぐため、サーバー側では渡されたインスタンスIDが起動中の場合は終了してから新たにインスタンスを開始するようにしています。

<details>
<summary><b>サーバー側の処理</b></summary>

```typescript:startQuizChat/index.ts (抜粋)
  // 起動済みのインスタンスがある場合は停止
  if (currentInstanceId) {
    const status = await client.getStatus(currentInstanceId)
    if (status?.runtimeStatus === df.OrchestrationRuntimeStatus.Running) {
      await client.terminate(currentInstanceId, 'Restart New')
    }
  }
```

</details>

後で使用するので、sessionStorage に格納しておきます。

```bash:bash
{
  "id": "1bc5891ea1ee4a928b0cb3a631555b06",
  "statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "sendEventPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "terminatePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "rewindPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/rewind?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "purgeHistoryDeleteUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "restartPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/restart?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "suspendPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/suspend?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "resumePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/resume?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw=="
}
```

それぞれ以下の様な機能があります。

| No. | プロパティ                 | 説明                 |
| --: | --------------------- | ------------------ |
| 1   | id                    | オーケストレーターのインスタンスID |
| 2   | statusQueryGetUri     | インスタンスの状態を取得する     |
| 3   | **sendEventPostUri**      | **外部イベントを発生させる**       |
| 4   | **terminatePostUri**      | **インスタンスを終了する**        |
| 5   | rewindPostUri         | 失敗した操作を再実行する       |
| 6   | purgeHistoryDeleteUri | 単一インスタンスの履歴を消去する   |
| 7   | restartPostUri        | インスタンスを再実行する       |
| 8   | suspendPostUri        | インスタンスを中断する        |
| 9   | resumePostUri         | 中断したインスタンスを再開する    |

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-http-api#terminate-instance

### 3. サーバーからの通知のハンドリング

#### チャットに表示するメッセージを受信した場合

```tsx:src/hooks/useQuizChat.tsx (抜粋)
  /** メッセージ受信時 */
  useEffect(() => {
    if (isConnected) {
      onReceiveMessage('Reply', async (message: string) => {
        if (chatStatus !== 'Started') return

        setMessages([
          ...messages,
          { message, sender: 'ChatGPT', position: 'normal', direction: 'incoming' },
        ])

        setServerStatus('Idle')
      })
    }
  }, [isConnected, chatStatus, onReceiveMessage, messages])
```

`onReceiveMessage` 関数の中では、`connection.on()` 関数を呼び出して通知受信時の処理を設定しています。(connection は Azure SignalR の接続インスタンス)

チャット欄にメッセージを追加してます。

<details>
<summary><b>onReceiveMessage</b></summary>

```tsx:src/hooks/useSignalR.tsx (抜粋)
  const onReceiveMessage = useCallback(
    (target: string, callback: (...args: any[]) => Promise<void>) => {
      connection.off(target)
      connection.on(target, callback)
    },
    [connection]
  )
```

</details>

#### サーバーのステータス変更を受信した場合

```tsx:src/hooks/useQuizChat.tsx (抜粋)
  /** ステータス変更通知受信時 */
  useEffect(() => {
    if (isConnected) {
      onReceiveMessage('StatusChanged', async (status: ServerStatus) => {
        setServerStatus(status)

        if (status === 'Error') {
          setChatStatus('Terminated')
        }
      })
    }
  }, [isConnected, onReceiveMessage])
```

サーバーから受信したステータスをセットします。
これにより、チャット欄の「入力中...」表示や「時間切れ」等のトーストが表示されるようにしています。

### 4. サーバーにメッセージ送信

```tsx:src/hooks/useQuizChat.tsx (抜粋)
  /** メッセージ送信 */
  const sendMessage = useCallback(
    async (content: string) => {
      if (!checkStatusResponse) return

      // イベント送信用のURLにイベント名を設定
      const sendEventPostUri = checkStatusResponse.sendEventPostUri.replace(
        '{eventName}',
        'NewMessage'
      )

      const message: ChatMessage = { userId, content }
      await fetch(sendEventPostUri, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      })

      setMessages([
        ...messages,
        { message: content, sender: 'You', position: 'normal', direction: 'outgoing' },
      ])
    },
    [userId, messages, checkStatusResponse]
  )
```

出題開始時に取得した `sendEventPostUri` に対してリクエストを送信することで、オーケストレーター関数に外部イベントを発生させることができます。

```text
http://(...中略...)/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==
```

URLを確認すると、送信するイベント名が `{eventName}` というプレースホルダーになっているので、置換しています。

送信後、チャット欄にメッセージを追加してます。

### 5. 出題終了

```tsx:src/hooks/useQuizChat.tsx (抜粋)
  /** 出題終了(Durable Functions 終了) */
  const terminate = useCallback(async () => {
    if (!checkStatusResponse) return

    // Orchestrator終了用のURLに終了理由を設定
    const terminatePostUri = checkStatusResponse.terminatePostUri.replace(
      '{text}',
      'User Operation.'
    )

    await fetch(terminatePostUri, { method: 'POST' })
    setChatStatus('Terminated')
    setServerStatus('Idle')
  }, [checkStatusResponse])
```

出題開始時に取得した `terminatePostUri` に対してリクエストを送信することで、オーケストレーター関数を終了させることができます。

```text
http://(...中略...)/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==
```

URLを確認すると、終了理由が `{text}` というプレースホルダーになっているので、置換しています。

### コードの全容

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

#### 基盤部分

```tsx:src/pages/_app.tsx
import '@/styles/globals.css'
import { MantineProvider } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import type { AppProps } from 'next/app'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <>
      <MantineProvider withGlobalStyles withNormalizeCSS>
        <Notifications position='bottom-center' />
        <Component {...pageProps} />
      </MantineProvider>
    </>
  )
}
```

#### メインページ

```tsx:src/pages/_app.tsx
import '@chatscope/chat-ui-kit-styles/dist/default/styles.min.css'
import Head from 'next/head'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuizChat } from '@/hooks/useQuizChat'
import QuizChat from '@/components/QuizChat'
import { Box, Button, Container, Group, SimpleGrid, TextInput } from '@mantine/core'
import { useNotifications } from '@/hooks/useNotifications'

const Home = () => {
  const [quizTopic, setQuizTopic] = useState('')
  const [chatBoxHeight, setChatBoxHeight] = useState('0px')
  const { notifyInformation, notifyError } = useNotifications()
  const { chatStatus, serverStatus, start, terminate, messages, sendMessage } =
    useQuizChat(quizTopic)
  const inputBox = useRef<HTMLDivElement>(null)

  const terminateButtonDisabled = useMemo(() => chatStatus !== 'Started', [chatStatus])

  const restartButtonDisabled = useMemo(
    () => chatStatus !== 'Terminated' || !quizTopic,
    [chatStatus, quizTopic]
  )

  /** クイズ開始 */
  const startChat = useCallback(async () => {
    try {
      await start()
      notifyInformation('クイズ開始！')
    } catch (error) {
      console.error(error)
      notifyError('エラーが発生しました。')
    }
  }, [start, notifyInformation, notifyError])

  /** クイズ終了 */
  const terminateChat = useCallback(async () => {
    try {
      await terminate()
      notifyInformation('クイズ終了！')
    } catch (error) {
      console.error(error)
      notifyError('エラーが発生しました。')
    }
  }, [terminate, notifyInformation, notifyError])

  useEffect(() => {
    // チャット領域の高さを調整
    setChatBoxHeight(`calc(100vh - ${inputBox.current?.getBoundingClientRect().height ?? 0}px)`)
  }, [])

  return (
    <>
      <Head>
        <title>AI Quiz Chat</title>
        <meta name='description' content='AI Quiz Chat' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
      </Head>
      <main>
        <Container sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }} py={8}>
          <Box>
            <SimpleGrid cols={2} ref={inputBox} pb={8}>
              <TextInput
                label='お題'
                value={quizTopic}
                onChange={(e) => setQuizTopic(e.target.value)}
              />
              <Group position='right' align='end'>
                <Button
                  variant='filled'
                  type='button'
                  onClick={startChat}
                  disabled={restartButtonDisabled}
                >
                  出題開始
                </Button>
                <Button
                  variant='filled'
                  type='button'
                  onClick={terminateChat}
                  disabled={terminateButtonDisabled}
                >
                  出題終了
                </Button>
              </Group>
            </SimpleGrid>
          </Box>
          <Box sx={{ height: chatBoxHeight, overflow: 'hidden' }}>
            <QuizChat
              {...{
                chatStatus,
                serverStatus,
                messages,
                sendMessage,
              }}
            />
          </Box>
        </Container>
      </main>
    </>
  )
}

export default memo(Home)
```

#### チャット表示コンポーネント

```tsx:src/components/QuizChat.tsx
import { useNotifications } from '@/hooks/useNotifications'
import { useQuizChat } from '@/hooks/useQuizChat'
import { ChatStatus, ServerStatus } from '@/lib/types'
import {
  Avatar,
  MessageInput,
  MessageInputProps,
  MessageModel,
  TypingIndicator,
} from '@chatscope/chat-ui-kit-react'
import { MainContainer, ChatContainer, MessageList, Message } from '@chatscope/chat-ui-kit-react'
import { Text } from '@mantine/core'
import { memo, useCallback, useEffect, useMemo } from 'react'

const QuizChat = ({ chatStatus, serverStatus, messages, sendMessage }: QuizChatProps) => {
  const { notifyWarning, notifyError } = useNotifications()

  const messageInputDisabled = useMemo(
    () =>
      chatStatus !== 'Started' || Array<ServerStatus>('Typing', 'TimeUp').includes(serverStatus),
    [chatStatus, serverStatus]
  )

  /** 「入力中...」の表示 */
  const typingIndicator = useMemo(
    () => (serverStatus === 'Typing' ? <TypingIndicator content='入力中' /> : <></>),
    [serverStatus]
  )

  /** メッセージ送信 */
  const sendNewMessage: MessageInputProps['onSend'] = useCallback(
    async (_: string, content: string) => {
      try {
        await sendMessage(content)
      } catch (error) {
        console.error(error)
        // enqueueSnackbar('エラーが発生しました。', { variant: 'error' })
        notifyError('エラーが発生しました。')
      }
    },
    [sendMessage, notifyError]
  )

  /** サーバーステータス変更時 */
  useEffect(() => {
    // 制限時間切れ
    if (serverStatus === 'TimeUp') {
      notifyWarning('時間切れです。')
    }
    // エラー
    else if (serverStatus === 'Error') {
      notifyError('エラーが発生しました。')
    }
  }, [serverStatus, notifyWarning, notifyError])

  return (
    <MainContainer>
      <ChatContainer>
        <MessageList typingIndicator={typingIndicator}>
          {messages.map((message, index) => (
            <Message key={index} model={message}>
              <Avatar src={avatar[message.sender ?? '']} />
              <Message.Header>
                <Text>{message.sender}</Text>
              </Message.Header>
            </Message>
          ))}
        </MessageList>
        <MessageInput
          placeholder='回答を入力'
          sendButton={true}
          attachButton={false}
          onSend={sendNewMessage}
          disabled={messageInputDisabled}
        />
      </ChatContainer>
    </MainContainer>
  )
}

const avatar: Record<string, string> = {
  ChatGPT: '/images/avatar_chatgpt.png',
  You: '/images/avatar_me.png',
}

type QuizChatProps = {
  chatStatus: ChatStatus
  serverStatus: ServerStatus
  messages: MessageModel[]
  sendMessage: ReturnType<typeof useQuizChat>['sendMessage']
}

export default memo(QuizChat)
```

#### Azure SignalR 関連の処理

```tsx:src/hooks/useSignalR.tsx
import { useSessionStorage } from '@mantine/hooks'
import * as signalR from '@microsoft/signalr'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { v4 as uuid } from 'uuid'

const KEY_USER_ID = 'SignalRUserId'

const useSignalR = () => {
  const [isConnected, setIsConnected] = useState<boolean>(false)
  // SignalRのユーザーID
  const [userId] = useSessionStorage<string>({
    key: KEY_USER_ID,
    defaultValue: uuid(),
    getInitialValueInEffect: true,
  })

  /** SignalRの接続オブジェクト */
  const connection = useMemo(
    () =>
      new signalR.HubConnectionBuilder()
        .withUrl(`http://localhost:7071/api?userId=${userId}`)
        .withAutomaticReconnect({
          nextRetryDelayInMilliseconds: () => 5000,
        })
        .configureLogging(signalR.LogLevel.Debug)
        .build(),
    [userId]
  )

  /** 接続の確立 */
  const start = useCallback(async () => {
    try {
      if (connection.state !== 'Disconnected') return
      await connection.start()
      setIsConnected(true)
    } catch (error) {
      console.error(error)
      setTimeout(start, 5000)
    }
  }, [connection])

  const onReceiveMessage = useCallback(
    (target: string, callback: (...args: any[]) => Promise<void>) => {
      connection.off(target)
      connection.on(target, callback)
    },
    [connection]
  )

  /** SignalRハブに接続 */
  useEffect(() => {
    start()
  }, [start])

  return { isConnected, userId, onReceiveMessage } as const
}

export { useSignalR }
```

#### チャット関連の処理

```tsx:src/hooks/useQuizChat.tsx
import { useCallback, useEffect, useState } from 'react'
import { useSignalR } from './useSignalR'
import { MessageModel } from '@chatscope/chat-ui-kit-react'
import {
  ChatStatus,
  CheckStatusResponse,
  ChatMessage,
  ServerStatus,
  StartChatRequestBody,
} from '@/lib/types'
import { useSessionStorage } from '@mantine/hooks'

const KEY_CHECK_STATUS_RESPONSE = 'StatusCheckResponse'

const useQuizChat = (quizTopic: string) => {
  const { userId, isConnected, onReceiveMessage } = useSignalR()
  const [chatStatus, setChatStatus] = useState<ChatStatus>('Terminated')
  const [serverStatus, setServerStatus] = useState<ServerStatus>('Idle')
  const [messages, setMessages] = useState<MessageModel[]>([])
  const [checkStatusResponse, setCheckStatusResponse] = useSessionStorage<
    CheckStatusResponse | undefined
  >({ key: KEY_CHECK_STATUS_RESPONSE })

  /** 出題開始(Durable Functions 起動) */
  const start = useCallback(async () => {
    setChatStatus('Starting')
    // 直近で実行したオーケストレーターインスタンスのID
    const currentInstanceId = checkStatusResponse?.id

    try {
      const body: StartChatRequestBody = { userId, quizTopic, currentInstanceId }
      const response = await fetch('http://localhost:7071/api/startQuizChat', {
        method: 'POST',
        body: JSON.stringify(body),
      })

      const checkStatusResponse: CheckStatusResponse = await response.json()

      setCheckStatusResponse(checkStatusResponse)
      setChatStatus('Started')
    } catch (error) {
      setChatStatus('Terminated')
      throw error
    }
  }, [userId, quizTopic, checkStatusResponse, setCheckStatusResponse])

  /** 出題終了(Durable Functions 終了) */
  const terminate = useCallback(async () => {
    if (!checkStatusResponse) return

    // Orchestrator終了用のURLに終了理由を設定
    const terminatePostUri = checkStatusResponse.terminatePostUri.replace(
      '{text}',
      'User Operation.'
    )

    await fetch(terminatePostUri, { method: 'POST' })
    setChatStatus('Terminated')
    setServerStatus('Idle')
  }, [checkStatusResponse])

  /** メッセージ送信 */
  const sendMessage = useCallback(
    async (content: string) => {
      if (!checkStatusResponse) return

      // イベント送信用のURLにイベント名を設定
      const sendEventPostUri = checkStatusResponse.sendEventPostUri.replace(
        '{eventName}',
        'NewMessage'
      )

      const message: ChatMessage = { userId, content }
      await fetch(sendEventPostUri, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(message),
      })

      setMessages([
        ...messages,
        { message: content, sender: 'You', position: 'normal', direction: 'outgoing' },
      ])
    },
    [userId, messages, checkStatusResponse]
  )

  /** メッセージ受信時 */
  useEffect(() => {
    if (isConnected) {
      onReceiveMessage('Reply', async (message: string) => {
        if (chatStatus !== 'Started') return

        setMessages([
          ...messages,
          { message, sender: 'ChatGPT', position: 'normal', direction: 'incoming' },
        ])

        setServerStatus('Idle')
      })
    }
  }, [isConnected, chatStatus, onReceiveMessage, messages])

  /** ステータス変更通知受信時 */
  useEffect(() => {
    if (isConnected) {
      onReceiveMessage('StatusChanged', async (status: ServerStatus) => {
        setServerStatus(status)

        if (status === 'Error') {
          setChatStatus('Terminated')
        }
      })
    }
  }, [isConnected, onReceiveMessage])

  return { chatStatus, serverStatus, messages, start, terminate, sendMessage }
}

export { useQuizChat }
```

#### 通知表示

```tsx:src/hooks/useNotifications.tsx
import { notifications } from '@mantine/notifications'
import { useCallback } from 'react'

const useNotifications = () => {
  const notifyInformation = useCallback((message: string) => {
    notifications.show({
      title: 'Info',
      message,
      color: 'blue',
    })
  }, [])

  const notifyWarning = useCallback((message: string) => {
    notifications.show({
      title: 'Warning',
      message,
      color: 'yellow',
    })
  }, [])

  const notifyError = useCallback((message: string) => {
    notifications.show({
      title: 'Error',
      message,
      color: 'red',
      autoClose: false,
      withCloseButton: true,
    })
  }, [])

  return { notifyInformation, notifyWarning, notifyError }
}

export { useNotifications }
```

#### タイプ定義

```ts:src/lib/types.ts
/** チャットUIのステータス */
type ChatStatus = 'Terminated' | 'Starting' | 'Started'

/** サーバー側のステータス */
type ServerStatus = 'Idle' | 'Typing' | 'TimeUp' | 'Error'

/** オーケストレーターへのアクセス情報 */
type CheckStatusResponse = {
  id: string
  statusQueryGetUri: string
  sendEventPostUri: string
  terminatePostUri: string
  rewindPostUri: string
  purgeHistoryDeleteUri: string
  restartPostUri: string
  suspendPostUri: string
  resumePostUri: string
}

/** startChat API のリクエストボディ */
type StartChatRequestBody = {
  userId: string
  quizTopic: string
  currentInstanceId?: string
}

/** 送信メッセージ */
type ChatMessage = {
  userId: string
  content: string
}

export type { ChatStatus, CheckStatusResponse, StartChatRequestBody, ChatMessage, ServerStatus }
```

</details>

<details>
<summary><b>最終的なディレクトリ構成</b></summary>

```text
client/
├── README.md
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   └── images
│       ├── avatar_chatgpt.png
│       └── avatar_me.png
├── src
│   ├── components
│   │   └── QuizChat.tsx
│   ├── hooks
│   │   ├── useNotifications.tsx
│   │   ├── useQuizChat.tsx
│   │   └── useSignalR.tsx
│   ├── lib
│   │   └── types.ts
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   └── index.tsx
│   └── styles
│       └── globals.css
└── tsconfig.json
```

</details>

# 実行

## クライアント側の起動

```bash:bash
npm run dev
```

## バックエンド側の起動

[バックエンド編](https://qiita.com/fsdg-nagumo/private/397f7820f700ac36e456#%E8%B5%B7%E5%8B%95) を参照。

## 動作確認

ブラウザで `http://localhost:3000` にアクセスするとページが表示されます。

![ai_chat_twin.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/3d6129cb-46a1-6ac2-f9cc-742d35761ac1.gif)

サーバー側から Azure SignalR 経由でメッセージ送信する際に `userId` を指定しているので、ユーザー毎にメッセージが届くようになってますね。
出題がAI任せなので、おかしかったり、間違えても正解になったり、先に答えを言ってきたりすることもありますが、そこはご愛嬌🐤

# まとめ

Durable Functions を使用することで複雑なフロー制御も比較的簡単に実装できてしまいます。
今回は Durable Functions のタイマーで回答の制限時間を制御しましたが、秒レベルの正確性は無いため、30秒に設定しても50秒くらいの時があったりもします。
[アプリケーションパターン](https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-overview?tabs=javascript-v3#application-patterns:~:text=%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82-,%E6%89%BF%E8%AA%8D%E3%83%97%E3%83%AD%E3%82%BB%E3%82%B9,-%E3%81%AF%E3%80%81%E4%BA%BA%E3%81%AB%E3%82%88%E3%82%8B)の説明にもあるように、本来は承認プロセスなど、時間・日単位で待機する必要がある処理に向いている機能だと思います。

2本に渡ってダラダラと長くなってしまいましたが、Durable Functions が気になった方は是非遊んでみてください！