居酒屋の注文アプリを音声入力にしたい
別に居酒屋じゃなくてもいいんですけど、各テーブルのタッチパネルで注文するやつ、「注文」という体験をうまくimitateできていない気がしていて、音声入力にしてみたらマシになるかなっていう思いつき。
- メニューの一覧がズラーッと見れて
- 音声で注文することができて
- (音声認識がいまいちなこともあるから)手でも操作できて
みたいなのをとりあえず作りたいな
ざっくり
- 音声認識:ブラウザのやつ?
- 注文理解:AI
で作れると思っている
注文保持はJotaiにしてみるか
せっかくだしこれ使おう
あとこれ使えないとどうにもならんよな、注文されたメニューをJSONで返してほしい
つよい、すでにある
わたしのしごと
- AIにメニュー一覧を作ってもらう(カテゴリもあるといいね)
- メニュー一覧を表示する
- メニューごとに注文数をカートに保持できるようにする
- ブラウザの音声認識でtextをgetする
- textをAIに渡せるようにする
- 注文textを引数にとり、メニュー.jsonを見て、注文内容.jsonを返すようなfunctionをAI側に作る
- ↑のfunctionの結果をカートに反映できるようにする
これだけだと「今の注文やっぱなしで」は、一息で言ってもらわないと無理だな
データ構造一層深くすればいけるか、order.add..../order.remove....みたいなイメージで
そんでもって、AIに知らんメニューを返されても困るんよな。うまいこと例外処理しなければ。
理想的にはAI側で処理できたらええけども、できるんかな。
せっかくならプログラミングもほとんどAIにやってもらうチャレンジをしたいなー
これまだなんかなー
AIにメニューを作ってもらう
token長あれで最初英語でお願いして作ってたんやけども、なーんかイマイチで、試しに日本語でお願いし直してみたら、格段に精度上がった。英語で頼むときは英語のプールしか参照してない?
英語ver
-
酒類 (Sakeryu: Alcohol)
- プレミアム生ビール (Premium Nama Beer: Premium Draft Beer)
- 冷酒 (Reishu: Cold Sake)
- 熱燗 (Atsukan: Hot Sake)
- 梅酒 (Umeshu: Plum Wine)
- 高級ウイスキー (Koukyuu Whiskey: Premium Whiskey)
- 焼酎ロック (Shochu Rock: Shochu on the Rocks)
- 日本酒フライト (Nihonshu Flight: Sake Flight)
- スパークリングワイン (Sparkling Wine)
-
おつまみ (Otsumami: Snacks)
- 塩辛 (Shiokara: Salted Fish Guts)
- 枝豆 (Edamame: Soybeans)
- 海苔巻き (Nori Maki: Seaweed Rolls)
- ピーナッツ (Peanuts)
- 茹でタコ (Yude Tako: Boiled Octopus)
- イカの塩辛 (Ika no Shiokara: Salted Squid)
- お新香盛り合わせ (Oshinko Moriawase: Assorted Pickles)
- チーズ盛り合わせ (Cheese Moriawase: Assorted Cheese)
-
刺身 (Sashimi)
- まぐろ刺身 (Maguro Sashimi: Tuna Sashimi)
- 鮭刺身 (Sake Sashimi: Salmon Sashimi)
- いか刺身 (Ika Sashimi: Squid Sashimi)
- たこ刺身 (Tako Sashimi: Octopus Sashimi)
- ウニ刺身 (Uni Sashimi: Sea Urchin Sashimi)
- ホタテ刺身 (Hotate Sashimi: Scallop Sashimi)
- あじ刺身 (Aji Sashimi: Horse Mackerel Sashimi)
- 刺身盛り合わせ (Sashimi Moriawase: Assorted Sashimi)
-
揚げ物 (Agemono: Fried food)
- 唐揚げ (Karaage: Fried Chicken)
- 海老フライ (Ebi Fry: Fried Shrimp)
- かきフライ (Kaki Fry: Fried Oysters)
- 天ぷら盛り合わせ (Tempura Moriawase: Assorted Tempura)
- とんかつ (Tonkatsu: Pork Cutlet)
- 魚の唐揚げ (Sakana no Karaage: Fried Fish)
- コロッケ (Korokke: Croquette)
- 揚げだし豆腐 (Agedashi Tofu: Fried Tofu in Broth)
-
焼き物 (Yakimono: Grilled food)
- 焼き鳥盛り合わせ (Yakitori Moriawase: Assorted Yakitori)
- 銀鱈の塩焼き (Gindara no Shioyaki: Salt Grilled Cod)
- 牛肉の照り焼き (Gyuniku no Teriyaki: Teriyaki Beef)
- ほっけの開き (Hokke no Hiraki: Grilled Atka Mackerel)
- 焼き鮎 (Yaki Ayu: Grilled Sweetfish)
- さけ皮焼き (Sake Kawa Yaki: Grilled Salmon Skin)
- なすの焼き浸し (Nasu no Yaki-zuke: Grilled Eggplant)
- 鰹のたたき (Katsuo no Tataki: Lightly Grilled Bonito)
-
麺類 (Menrui: Noodle dishes)
- ラーメン (Ramen)
- 冷やし中華 (Hiyashi Chuka: Cold Chinese Noodles)
- ざるそば (Zaru Soba: Cold Buckwheat Noodles)
- うどん (Udon)
- カレーうどん (Curry Udon: Curry Noodles)
- 焼きそば (Yakisoba: Fried Noodles)
- つけ麺 (Tsukemen: Dipping Noodles)
- ちゃんぽん (Champon: Nagasaki-style Noodles)
-
ごはんもの (Gohanmono: Rice dishes)
- おにぎり (Onigiri: Rice Ball)
- 焼き鮭ごはん (Yaki Sake Gohan: Grilled Salmon Rice)
- 海鮮丼 (Kaisendon: Seafood Bowl)
- 牛丼 (Gyudon: Beef Bowl)
- カツ丼 (Katsudon: Pork Cutlet Bowl)
- ちらし寿司 (Chirashi Sushi: Scattered Sushi)
- 五目ごはん (Gomoku Gohan: Mixed Rice)
- 炊き込みごはん (Takikomi Gohan: Seasoned Steamed Rice)
-
デザート (Dezāto: Dessert)
- 抹茶アイスクリーム (Matcha Ice Cream: Green Tea Ice Cream)
- フルーツ盛り合わせ (Fruits Moriawase: Assorted Fruits)
- モチアイス (Mochi Ice: Rice Cake Ice Cream)
- クレープ (Crepe)
- パフェ (Parfait)
- あんみつ (Anmitsu: Traditional Japanese Dessert)
- ダンゴ (Dango: Sweet Dumplings)
- 抹茶プリン (Matcha Pudding: Green Tea Pudding)
日本語
もちろんです。以下に各ジャンルごとに8つのメニュータイトルをご提案します。
酒類
- 日本酒フライト(季節の選りすぐり)
- 山廃純米の生一本
- 本格焼酎の水割りセット
- 季節のフルーツカクテル
- クラフトビール(地元の醸造所より)
- 梅酒とゆず酒の二種盛り
- 熟成日本酒のお燗
- スパークリング日本酒
おつまみ
- 枝豆の塩ゆで
- たたききゅうりのごま油和え
- チーズとサラミの盛り合わせ
- レモンとハーブのマリネサラダ
- 季節の野菜のピクルス
- 和風ポテトサラダ
- 居酒屋特製唐揚げ
- 自家製冷製トマトスープ
刺身
- まぐろの三色丼(赤身、中トロ、大トロ)
- 季節のお刺身5点盛り
- ブリの照り焼き刺身
- 新鮮イカとウニの刺身
- たこときゅうりの酢の物
- 刺身のサラダ(季節の魚を使用)
- 白身魚のカルパッチョ
- 鮮度抜群!サーモンとアボカドの刺身
揚げ物
- 天ぷら盛り合わせ(季節の野菜と海鮮)
- 鶏の唐揚げ(自家製ソース付き)
- かぼちゃのコロッケ
- 季節の魚のフライ
- エビフライ(自家製タルタルソース付き)
- 自家製豆腐の揚げ出し
- チーズとハムの春巻き
- ポテトフライ(自家製ソース付き)
焼き物
- 鳥の焼き鳥盛り合わせ
- 真ダコのバター焼き
- 季節の野菜の串焼き
- 特選和牛の石焼き
- 骨付きラムのローズマリー焼き
- 海鮮焼き(季節の魚と貝)
- 銀ダラの西京焼き
- フォアグラの鉄板焼き
麺類
- 居酒屋特製冷やし中華
- 和風パスタ(季節の野菜と海鮮)
- 自家製つけ麺(チャーシュー付き)
- ウニとイクラのクリームパスタ
- 五目焼きそば
- 豚肉とキムチの炒麺
- 海老と青菜の塩ラーメン
- あっさり鶏ガララーメン
ごはんもの
- 海鮮丼(季節の海鮮)
- 居酒屋特製焼き鳥丼
- うなぎの蒲焼丼
- 牛すじの煮込みごはん
- ちらし寿司(季節のネタ)
- 炙りサーモンのおにぎり二個
- 筍と鶏肉の炊き込みごはん
- 五目おこわ
デザート
- 抹茶のパンナコッタ
- 黒ごまのアイスクリーム
- 季節のフルーツパフェ
- さくらんぼのヨーグルトムース
- 練乳キャラメルのプリン
- レモンとハチミツのゼリー
- マンゴーとココナッツのタピオカドリンク
- ゆずと白桃のソルベ
英語がイマイチってことはないか。日本語verがいやに凝っているw
プロンプトはどっちも大体これ
居酒屋のメニューを作成します。下記の各ジャンルそれぞれにおいて、8つずつメニュータイトルを作成してください。
酒類、おつまみ、刺身、揚げ物、焼き物、麺類、ごはんもの、デザート
事前にジャンルも生成してもらってPickした
日本語ver、いやに「自家製」ってついてるなw
日本人は自家製が好き
日本語の方を使おうと思ってJSONにしてもらった。いまいちなデータ構造な気がするけど、表示に関しては便利なので一旦OK。困ったらまた別なふうに整形してもらえばよし。
{
"menu": {
"酒類": [
"日本酒フライト(季節の選りすぐり)",
"山廃純米の生一本",
"本格焼酎の水割りセット",
"季節のフルーツカクテル",
"クラフトビール(地元の醸造所より)",
"梅酒とゆず酒の二種盛り",
"熟成日本酒のお燗",
"スパークリング日本酒"
],
"おつまみ": [
"枝豆の塩ゆで",
"たたききゅうりのごま油和え",
"チーズとサラミの盛り合わせ",
"レモンとハーブのマリネサラダ",
"季節の野菜のピクルス",
"和風ポテトサラダ",
"居酒屋特製唐揚げ",
"自家製冷製トマトスープ"
],
"刺身": [
"まぐろの三色丼(赤身、中トロ、大トロ)",
"季節のお刺身5点盛り",
"ブリの照り焼き刺身",
"新鮮イカとウニの刺身",
"たこときゅうりの酢の物",
"刺身のサラダ(季節の魚を使用)",
"白身魚のカルパッチョ",
"鮮度抜群!サーモンとアボカドの刺身"
],
"揚げ物": [
"天ぷら盛り合わせ(季節の野菜と海鮮)",
"鶏の唐揚げ(自家製ソース付き)",
"かぼちゃのコロッケ",
"季節の魚のフライ",
"エビフライ(自家製タルタルソース付き)",
"自家製豆腐の揚げ出し",
"チーズとハムの春巻き",
"ポテトフライ(自家製ソース付き)"
],
"焼き物": [
"鳥の焼き鳥盛り合わせ",
"真ダコのバター焼き",
"季節の野菜の串焼き",
"特選和牛の石焼き",
"骨付きラムのローズマリー焼き",
"海鮮焼き(季節の魚と貝)",
"銀ダラの西京焼き",
"フォアグラの鉄板焼き"
],
"麺類": [
"居酒屋特製冷やし中華",
"和風パスタ(季節の野菜と海鮮)",
"自家製つけ麺(チャーシュー付き)",
"ウニとイクラのクリームパスタ",
"五目焼きそば",
"豚肉とキムチの炒麺",
"海老と青菜の塩ラーメン",
"あっさり鶏ガララーメン"
],
"ごはんもの": [
"海鮮丼(季節の海鮮)",
"居酒屋特製焼き鳥丼",
"うなぎの蒲焼丼",
"牛すじの煮込みごはん",
"ちらし寿司(季節のネタ)",
"炙りサーモンのおにぎり二個",
"筍と鶏肉の炊き込みごはん",
"五目おこわ"
],
"デザート": [
"抹茶のパンナコッタ",
"黒ごまのアイスクリーム",
"季節のフルーツパフェ",
"さくらんぼのヨーグルトムース",
"練乳キャラメルのプリン",
"レモンとハチミツのゼリー",
"マンゴーとココナッツのタピオカドリンク",
"ゆずと白桃のソルベ"
]
}
}
メニュー一覧を表示する
こういうときにテンプレートリポジトリがあると便利ですよね
ChatGPTに殆ど書いてもらって、こんな感じになった。
export default function Home() {
return (
<main className="grid grid-cols-12 gap-4">
<div className="col-span-2">
<Sidebar />
</div>
<div className="col-span-7">
<Menu />
</div>
<div className="col-span-3">
<Cart />
</div>
</main>
);
}
自分で書いたほうがコントロールしやすくてはえーなとは思いつつ、でも考えることは少なくて楽だった
ところで、これだけだとスムーズスクロールにならない?
<html lang="ja" className="scroll-smooth">
とりあえずこれ
メニューごとに注文数をカートに保持できるようにする
JotaiいれてAIにお願いしたら簡単にやってくれた
何Stepかやりとり(誘導)したけどこのカスタムフック書いてくれたの嬉しかった
import { useAtom } from "jotai";
import { orderCountsAtom } from "./atom";
const useOrder = () => {
const [order, setOrderCounts] = useAtom(orderCountsAtom);
const add = (item: string) => {
setOrderCounts(
(prevCounts) =>
new Map(prevCounts.set(item, (prevCounts.get(item) || 0) + 1))
);
};
const pop = (item: string) => {
setOrderCounts((prevCounts) => {
const currentCount = prevCounts.get(item) || 0;
const nextCount = currentCount > 0 ? currentCount - 1 : 0;
return nextCount > 0
? new Map(prevCounts.set(item, nextCount))
: new Map([...prevCounts].filter(([key]) => key !== item));
});
};
return {
order,
add,
pop,
};
};
export default useOrder;
ここでAPIの回数/時間制限が来る
時間が解決してくれるだろうとはいえ、GPT-4はここがつらいね
せっかく生産性あがっても途中で途切れる
JotaiとlocalStrageつなげようとしていろいろやってたけど上手く行かなくて断念した
いったんデプロイ
ブラウザの音声認識でtextをgetする
これでやればすぐできそう
なんか上手く動かなかったのでAIにほぼ書いてもらった
import "client-only";
import { useState } from "react";
export const useSpeech = () => {
const [transcript, setTranscript] = useState("");
const [isListening, setIsListening] = useState(false);
const [recognition] = useState(new window.webkitSpeechRecognition());
const startListening = () => {
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "ja";
recognition.onresult = (event) => {
const currentTranscript = Array.from(event.results)
.map((result) => result[0])
.map((result) => result?.transcript)
.join("");
setTranscript(currentTranscript);
};
recognition.onerror = (event) => {
console.error(event.error);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.start();
setIsListening(true);
};
const stopListening = () => {
recognition.stop();
setIsListening(false);
};
return {
transcript,
isListening,
startListening,
stopListening,
};
};
tailwind-variant初めて使うわ。これだけだとcvaと変わらんけども。
"use client";
import { BsFillMicFill } from "react-icons/bs";
import { tv } from "tailwind-variants";
import { useSpeech } from "./useSpeech";
const button = tv({
base: "fixed bottom-10 right-10 flex h-16 w-16 items-center justify-center rounded-full text-2xl shadow",
variants: {
type: {
on: "bg-red-700 text-white",
off: "bg-slate-100 p-2 text-slate-800",
},
},
defaultVariants: {
type: "off",
},
});
const Dictaphone = () => {
const { transcript, isListening, startListening, stopListening } =
useSpeech();
const handleListening = !isListening ? startListening : stopListening;
return (
<div>
<button
className={button({ type: isListening ? "on" : "off" })}
onClick={handleListening}
>
<BsFillMicFill />
</button>
<p>{transcript}</p>
</div>
);
};
export default Dictaphone;
transcriptはダイアログで出したいな
useCallbackで囲ったりresetを出したりしつつ
import "client-only";
import { useCallback, useState } from "react";
export const useSpeech = () => {
const [transcript, setTranscript] = useState("");
const [isListening, setIsListening] = useState(false);
const [recognition] = useState(new window.webkitSpeechRecognition());
const startListening = useCallback(() => {
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "ja";
recognition.onresult = (event) => {
const currentTranscript = Array.from(event.results)
.map((result) => result[0])
.map((result) => result?.transcript)
.join("");
setTranscript(currentTranscript);
};
recognition.onerror = (event) => {
console.error(event.error);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.start();
setIsListening(true);
}, [recognition]);
const stopListening = useCallback(() => {
recognition.stop();
setIsListening(false);
}, [recognition]);
const reset = useCallback(() => {
setTranscript("");
}, []);
return {
transcript,
isListening,
startListening,
stopListening,
reset,
};
};
うまいことModalを出すようにした。CSSのSlotありがてー
"use client";
import * as Dialog from "@radix-ui/react-dialog";
import { BiStopCircle } from "react-icons/bi";
import { BsFillMicFill } from "react-icons/bs";
import { tv } from "tailwind-variants";
import { useSpeech } from "./useSpeech";
const button = tv({
base: "fixed bottom-10 right-10 z-30 flex h-16 w-16 items-center justify-center rounded-full text-2xl shadow",
variants: {
type: {
on: "bg-red-700 text-white",
off: "bg-slate-100 p-2 text-slate-800",
},
},
defaultVariants: {
type: "off",
},
});
const dialog = tv({
slots: {
content:
"fixed left-2 top-[10%] z-50 flex h-4/5 w-[240px] flex-col gap-4 border bg-white p-6 shadow-lg duration-200 sm:rounded-lg",
title: "text-lg font-semibold leading-none tracking-tight",
description: "grow text-sm text-slate-700",
footer: "flex sm:justify-end",
},
});
const { content, title, description, footer } = dialog();
const Dictaphone = () => {
const { transcript, isListening, startListening, stopListening, reset } =
useSpeech();
const handleListening = !isListening ? startListening : stopListening;
const onStop = () => {
stopListening();
reset();
};
return (
<Dialog.Root open={isListening} modal={false}>
<Dialog.Trigger asChild>
<button
className={button({ type: isListening ? "on" : "off" })}
onClick={handleListening}
>
<BsFillMicFill />
</button>
</Dialog.Trigger>
<Dialog.Portal className="z-20">
<Dialog.Content className={content()}>
<Dialog.Title className={title()}>ご注文内容</Dialog.Title>
<Dialog.Description className={description()}>
{transcript}
</Dialog.Description>
<div className={footer()}>
<Dialog.Close asChild>
<button
onClick={onStop}
className="text-2xl text-red-700"
aria-label="Close"
>
<BiStopCircle />
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
export default Dictaphone;
これでonStopのときにAIにtranscript送って、返ってきた結果をカートに反映させればOK
動くけどエラー吐いてるわ
error ReferenceError: window is not defined
at useSpeech (./app/useSpeech.ts:14:79)
解決策、useEffectしかないんか?これ
import "client-only";
import { useCallback, useEffect, useState } from "react";
export const useSpeech = () => {
const [transcript, setTranscript] = useState("");
const [isListening, setIsListening] = useState(false);
const [recognition, setRecognition] = useState<SpeechRecognition>();
useEffect(() => {
if (window) {
const r = new window.webkitSpeechRecognition();
r.continuous = true;
r.interimResults = true;
r.lang = "ja";
r.onresult = (event) => {
const currentTranscript = Array.from(event.results)
.map((result) => result[0])
.map((result) => result?.transcript)
.join("");
setTranscript(currentTranscript);
};
r.onerror = (event) => {
console.error(event.error);
};
r.onend = () => {
setIsListening(false);
};
setRecognition(r);
}
}, []);
const startListening = useCallback(() => {
recognition?.start();
setIsListening(true);
}, [recognition]);
const stopListening = useCallback(() => {
recognition?.stop();
setIsListening(false);
}, [recognition]);
const reset = useCallback(() => {
setTranscript("");
}, []);
return {
transcript,
isListening,
startListening,
stopListening,
reset,
};
};
windowに生えている型定義はこいつをinstallするというdirtyな解決法ですませた
Modal鬱陶しいのでやめた
ページ下部に固定
注文textから注文内容.jsonを返すfunctionをAI側に作る
ようやっとAI
まずはこれを理解するところからだ
Developers can now describe functions to gpt-4-0613 and gpt-3.5-turbo-0613, and have the model intelligently choose to output a JSON object containing arguments to call those functions. This is a new way to more reliably connect GPT's capabilities with external tools and APIs.
JSONあざす
Extract structured data from text
Define a function called extract_people_data(people: [{name: string, birthday: string, location: string}]), to extract all people mentioned in a Wikipedia article.
この例どんぴしゃだなあ
developer docs
いまいち本質が掴めてない気がする...
あー、ぼくがほしいの多分これです
最終的にはJSON.parse(functionCall.arguments)
だな
とりあえずこれのとおりにやってAIをAPI経由で使うところまで来た
わからんけど多分useChatにfunctionCallHandlerを渡しつつサーバー側にFunction定義書いておいたらmessage.function_call.arguments
で取れるんじゃないかな
import { ChatCompletionFunctions } from 'openai-edge/types/api'
const functions: ChatCompletionFunctions[] = [
{
name: 'get_current_weather',
description: 'Get the current weather',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'The city and state, e.g. San Francisco, CA'
},
format: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
description:
'The temperature unit to use. Infer this from the users location.'
}
},
required: ['location', 'format']
}
},
]
export async function POST(req: Request) {
const { messages, function_call } = await req.json()
const response = await openai.createChatCompletion({
model: 'gpt-3.5-turbo-0613',
stream: true,
messages,
functions,
function_call
})
const stream = OpenAIStream(response)
return new StreamingTextResponse(stream)
}
でもなー、これメニュー一覧読ませるのどこでやるんやろ
わかんねーーー
Vercelに隠蔽されすぎているのがいけないのか
これを参考にするか
結局何もわからない、ふつうにプログラミングが下手
Vercelに戻ってこういう実装をしている
"use client";
import { FunctionCallHandler, ChatRequest, nanoid } from "ai";
import { useChat } from "ai/react";
const functionCallHandler: FunctionCallHandler = async (
chatMessages,
functionCall
) => {
if (functionCall.name === "order") {
const functionResponse: ChatRequest = {
messages: [
...chatMessages,
{
id: nanoid(),
name: "get_current_weather",
role: "function" as const,
content: JSON.stringify({
arguments: functionCall.arguments,
}),
},
],
};
return functionResponse;
}
};
export default function SloganGenerator() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
experimental_onFunctionCall: functionCallHandler,
});
console.log(messages);
return (
<div className="fixed left-1/2 top-40 mx-auto flex w-full max-w-md flex-col py-24">
<form onSubmit={handleSubmit}>
<input
className="mb-8 w-full max-w-md rounded border border-gray-300 p-2 shadow-xl"
value={input}
placeholder="Describe your business..."
onChange={handleInputChange}
/>
</form>
<ul>
{messages.map((m) => (
<li key={m.id}>{m.content}</li>
))}
</ul>
</div>
);
}
おおおお?
良さげ、だけどこれってparseできるんけ?
"{"arguments":"{\n \"orders\": [\n {\n \"title\": \"唐揚げ\",\n \"count\": 1\n },\n {\n \"title\": \"枝豆\",\n \"count\": 1\n }\n ]\n}"}"
"スパークリング日本酒と、揚げ出し豆腐2つ"でこれが返ってくる
"{"arguments":"{\n \"orders\": [\n {\n \"title\": \"スパークリング日本酒\",\n \"count\": 1\n },\n {\n \"title\": \"揚げ出し豆腐\",\n \"count\": 2\n }\n ]\n}"}"
メニュー的には「自家製豆腐の揚げ出し」なのでイマイチ
何日か離れてようやくわかったけれども、Function callingって自然言語を関数呼び出しに変えてくれるところが強いのか。だから
- 注文音声聞き取り
- AIによる解釈
- addOrder関数呼び出し
でいけるじゃん。
だから、functionCallHandler
の内部でargsを使って注文関数を呼び出せばいいんだな。
const useHandleOrder: FunctionCallHandler = async (_, functionCall) => {
const { add } = useOrder();
if (functionCall.name === "order") {
const ordersJson = functionCall.arguments;
if (!ordersJson) return;
const orders = JSON.parse(ordersJson) as { title: string; count: number }[];
orders.forEach(({ title, count }) => add(title, count));
}
};
const { messages, input, handleInputChange, handleSubmit } = useChat({
experimental_onFunctionCall: useHandleOrder,
});
こういう感じにしたいんだけれども、良くないHooksの呼び出しっぽくてerrorでる
Uncaught (in promise) Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
at Object.throwInvalidHookError (webpack-internal:///(:3000/app-client)/./node_modules/next/dist/compiled/react-dom/cjs/react-dom.development.js:10539:9)
at useContext (webpack-internal:///(:3000/app-client)/./node_modules/next/dist/compiled/react/cjs/react.development.js:1753:21)
at useStore (webpack-internal:///(:3000/app-client)/./node_modules/jotai/esm/react.mjs:16:66)
at useAtomValue (webpack-internal:///(:3000/app-client)/./node_modules/jotai/esm/react.mjs:60:17)
at useAtom (webpack-internal:///(:3000/app-client)/./node_modules/jotai/esm/react.mjs:109:5)
at useOrder (webpack-internal:///(:3000/app-client)/./app/useOrder.ts:7:83)
at useHandleOrder (webpack-internal:///(:3000/app-client)/./app/AIClient.tsx:14:74)
at eval (webpack-internal:///(:3000/app-client)/./node_modules/ai/react/dist/index.mjs:210:48)
at Generator.next (<anonymous>)
at fulfilled (webpack-internal:///(:3000/app-client)/./node_modules/ai/react/dist/index.mjs:31:24)
とりあえずconsole.logで適切にcallされていることはわかった。めちゃくちゃ良い。
普通にこうすればよかった
const { add } = useOrder();
const functionCallHandler: FunctionCallHandler = async (_, functionCall) => {
if (functionCall.name === "order") {
const ordersJson = functionCall.arguments?.replaceAll("\n", "");
if (!ordersJson) return;
const { orders } = JSON.parse(ordersJson) as {
orders: {
title: string;
count: number;
}[];
};
orders.forEach(({ title, count }) => add(title, count));
}
};
きたきたきたーーーーーー
textをAIに渡せるようにする
結果から言うと音声読み取りのコンポーネントでtranscriptをappendに渡せばいけた
const Dictaphone = () => {
const { transcript, isListening, startListening, stopListening } =
useSpeech();
const { add } = useOrder();
const functionCallHandler: FunctionCallHandler = async (_, functionCall) => {
if (functionCall.name === "order") {
const ordersJson = functionCall.arguments?.replaceAll("\n", "");
if (!ordersJson) return;
const { orders } = JSON.parse(ordersJson) as {
orders: {
title: string;
count: number;
}[];
};
orders.forEach(({ title, count }) => add(title, count));
}
};
const { append } = useChat({
experimental_onFunctionCall: functionCallHandler,
});
const startOrderCall = () => startListening();
const endOrderCall = () => {
stopListening();
append({
content: transcript,
role: "user",
createdAt: new Date(),
});
};
const handleListening = !isListening ? startOrderCall : endOrderCall;
return (
<>
<div className="flex h-full w-full border-t-2 p-4 shadow">
<p className="grow px-4 py-2">{transcript}</p>
<button
className={button({ type: isListening ? "on" : "off" })}
onClick={handleListening}
>
<BsFillMicFill />
</button>
</div>
</>
);
};
一旦0→1はできたので、残る課題を別スクラップとして切り出すためにあげつらう
- AIが返してくれたJSONの検証:元メニューとの照合
- ↑弾くのか、一部でも一致してたらメニューに入れとくのか
- プロンプトエンジニアリング(の精度をどうやって確かめるの問題。手作業で何回もやるしか無い?)
- vercel/aiの使い方これであってるのか問題(ドキュメントちゃんと読め問題)
あとはインターフェイスそのものの問題として
- 〇〇の何味、みたいな構造的なメニューをどう表現する?
- この画面なら素直に+押して注文したほうが早いでしょ問題(上と関連)
- loading状態を表現したい(これはやればできそう)
などがある
リファクタとかしてないし、時間があいたせいで最後最悪のコミットしちゃったけれど、とりあえず成果物