はじめに
この記事では、Durable Functionsのタイマー処理や外部イベント、永続的オーケストレーションを使用して、チャット風クイズアプリを作る方法を紹介します。
前回の記事ではバックエンド部分を実装したので、今回はそれを呼び出すクライアント部分の実装をしていきます。
この記事ではクライアント部分のみを扱っています。
バックエンド部分についてはバックエンド編にて記載しています。
バックエンド編:
バックエンド編のおさらい
ざっくりの仕様
- クイズのお題を入力してクイズ開始すると、サーバーでクイズを作成しクライアントに送信する
- クイズは選択式問題にする
- クライアントから回答が送信されたら、答え合わせしてクライアントに解答を送信する
- 回答の制限時間を設け、制限時間が過ぎたら回答を打ち切ってクライアントに解答を送信する
- 答え合わせの後、新たなクイズを作成しクライアントに送信する
- 成績の集計や表示はしない
全体構成
赤枠内が今回関連する範囲です。
完成イメージ
操作の流れ
- 出題開始
- 出題 → 回答送信 → 解答表示
- 次の出題 → 時間切れ (制限時間は10秒にしている) → 解答表示
- 出題終了
使用するフレームワークなど
UI部分はライブラリを使って楽していきます。
- Next.js
- Mantine (UI フレームワーク)
- chat-ui-kit-react (チャットUI)
さっそく実装!
UI部分の実装については深く触れないので悪しからず。あくまでバックエンドとのやり取りの部分に絞って記載していきます。
プロジェクトの作成
Next.js のプロジェクトを作成します。
手順は Getting Started の通りです。
対話形式で色々聞かれるので、すべてデフォルトを選択しました。
npx create-next-app@latest --typescript
必要なパッケージのインストール
その他使用するパッケージをインストールします。
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 との接続確立
/** 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. 出題開始
/** 出題開始(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 ※後述 |
このリクエストに対して以下の様な、オーケストレーターにアクセスするためのURLを含んだレスポンスが返ってきます。
上記 currentInstanceId
はここで取得できる id
のことです。
出題開始 → 画面リロード → 出題開始 という操作をされてインスタンスが2重起動するのを防ぐため、サーバー側では渡されたインスタンスIDが起動中の場合は終了してから新たにインスタンスを開始するようにしています。
サーバー側の処理
// 起動済みのインスタンスがある場合は停止
if (currentInstanceId) {
const status = await client.getStatus(currentInstanceId)
if (status?.runtimeStatus === df.OrchestrationRuntimeStatus.Running) {
await client.terminate(currentInstanceId, 'Restart New')
}
}
後で使用するので、sessionStorage に格納しておきます。
{
"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 | 中断したインスタンスを再開する |
3. サーバーからの通知のハンドリング
チャットに表示するメッセージを受信した場合
/** メッセージ受信時 */
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 の接続インスタンス)
チャット欄にメッセージを追加してます。
onReceiveMessage
const onReceiveMessage = useCallback(
(target: string, callback: (...args: any[]) => Promise<void>) => {
connection.off(target)
connection.on(target, callback)
},
[connection]
)
サーバーのステータス変更を受信した場合
/** ステータス変更通知受信時 */
useEffect(() => {
if (isConnected) {
onReceiveMessage('StatusChanged', async (status: ServerStatus) => {
setServerStatus(status)
if (status === 'Error') {
setChatStatus('Terminated')
}
})
}
}, [isConnected, onReceiveMessage])
サーバーから受信したステータスをセットします。
これにより、チャット欄の「入力中...」表示や「時間切れ」等のトーストが表示されるようにしています。
4. サーバーにメッセージ送信
/** メッセージ送信 */
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
に対してリクエストを送信することで、オーケストレーター関数に外部イベントを発生させることができます。
http://(...中略...)/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==
URLを確認すると、送信するイベント名が {eventName}
というプレースホルダーになっているので、置換しています。
送信後、チャット欄にメッセージを追加してます。
5. 出題終了
/** 出題終了(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
に対してリクエストを送信することで、オーケストレーター関数を終了させることができます。
http://(...中略...)/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==
URLを確認すると、終了理由が {text}
というプレースホルダーになっているので、置換しています。
コードの全容
コード
基盤部分
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>
</>
)
}
メインページ
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)
チャット表示コンポーネント
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 関連の処理
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 }
チャット関連の処理
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 }
通知表示
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 }
タイプ定義
/** チャット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 }
最終的なディレクトリ構成
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
実行
クライアント側の起動
npm run dev
バックエンド側の起動
バックエンド編 を参照。
動作確認
ブラウザで http://localhost:3000
にアクセスするとページが表示されます。
サーバー側から Azure SignalR 経由でメッセージ送信する際に userId
を指定しているので、ユーザー毎にメッセージが届くようになってますね。
出題がAI任せなので、おかしかったり、間違えても正解になったり、先に答えを言ってきたりすることもありますが、そこはご愛嬌🐤
まとめ
Durable Functions を使用することで複雑なフロー制御も比較的簡単に実装できてしまいます。
今回は Durable Functions のタイマーで回答の制限時間を制御しましたが、秒レベルの正確性は無いため、30秒に設定しても50秒くらいの時があったりもします。
アプリケーションパターンの説明にもあるように、本来は承認プロセスなど、時間・日単位で待機する必要がある処理に向いている機能だと思います。
2本に渡ってダラダラと長くなってしまいましたが、Durable Functions が気になった方は是非遊んでみてください!