Closed74

居酒屋の注文アプリを音声入力にしたい

hajimismhajimism

別に居酒屋じゃなくてもいいんですけど、各テーブルのタッチパネルで注文するやつ、「注文」という体験をうまくimitateできていない気がしていて、音声入力にしてみたらマシになるかなっていう思いつき。

hajimismhajimism
  • メニューの一覧がズラーッと見れて
  • 音声で注文することができて
  • (音声認識がいまいちなこともあるから)手でも操作できて

みたいなのをとりあえず作りたいな

hajimismhajimism

ざっくり

  • 音声認識:ブラウザのやつ?
  • 注文理解:AI

で作れると思っている

hajimismhajimism

わたしのしごと

  1. AIにメニュー一覧を作ってもらう(カテゴリもあるといいね)
  2. メニュー一覧を表示する
  3. メニューごとに注文数をカートに保持できるようにする
  4. ブラウザの音声認識でtextをgetする
  5. textをAIに渡せるようにする
  6. 注文textを引数にとり、メニュー.jsonを見て、注文内容.jsonを返すようなfunctionをAI側に作る
  7. ↑のfunctionの結果をカートに反映できるようにする
hajimismhajimism

これだけだと「今の注文やっぱなしで」は、一息で言ってもらわないと無理だな

hajimismhajimism

データ構造一層深くすればいけるか、order.add..../order.remove....みたいなイメージで

hajimismhajimism

そんでもって、AIに知らんメニューを返されても困るんよな。うまいこと例外処理しなければ。
理想的にはAI側で処理できたらええけども、できるんかな。

hajimismhajimism

せっかくならプログラミングもほとんどAIにやってもらうチャレンジをしたいなー

hajimismhajimism

AIにメニューを作ってもらう

token長あれで最初英語でお願いして作ってたんやけども、なーんかイマイチで、試しに日本語でお願いし直してみたら、格段に精度上がった。英語で頼むときは英語のプールしか参照してない?

hajimismhajimism

英語ver

  1. 酒類 (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)
  2. おつまみ (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)
  3. 刺身 (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)
  4. 揚げ物 (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)
  5. 焼き物 (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)
  6. 麺類 (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)
  7. ごはんもの (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)
  8. デザート (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)
hajimismhajimism

日本語

もちろんです。以下に各ジャンルごとに8つのメニュータイトルをご提案します。

酒類

  1. 日本酒フライト(季節の選りすぐり)
  2. 山廃純米の生一本
  3. 本格焼酎の水割りセット
  4. 季節のフルーツカクテル
  5. クラフトビール(地元の醸造所より)
  6. 梅酒とゆず酒の二種盛り
  7. 熟成日本酒のお燗
  8. スパークリング日本酒

おつまみ

  1. 枝豆の塩ゆで
  2. たたききゅうりのごま油和え
  3. チーズとサラミの盛り合わせ
  4. レモンとハーブのマリネサラダ
  5. 季節の野菜のピクルス
  6. 和風ポテトサラダ
  7. 居酒屋特製唐揚げ
  8. 自家製冷製トマトスープ

刺身

  1. まぐろの三色丼(赤身、中トロ、大トロ)
  2. 季節のお刺身5点盛り
  3. ブリの照り焼き刺身
  4. 新鮮イカとウニの刺身
  5. たこときゅうりの酢の物
  6. 刺身のサラダ(季節の魚を使用)
  7. 白身魚のカルパッチョ
  8. 鮮度抜群!サーモンとアボカドの刺身

揚げ物

  1. 天ぷら盛り合わせ(季節の野菜と海鮮)
  2. 鶏の唐揚げ(自家製ソース付き)
  3. かぼちゃのコロッケ
  4. 季節の魚のフライ
  5. エビフライ(自家製タルタルソース付き)
  6. 自家製豆腐の揚げ出し
  7. チーズとハムの春巻き
  8. ポテトフライ(自家製ソース付き)

焼き物

  1. 鳥の焼き鳥盛り合わせ
  2. 真ダコのバター焼き
  3. 季節の野菜の串焼き
  4. 特選和牛の石焼き
  5. 骨付きラムのローズマリー焼き
  6. 海鮮焼き(季節の魚と貝)
  7. 銀ダラの西京焼き
  8. フォアグラの鉄板焼き

麺類

  1. 居酒屋特製冷やし中華
  2. 和風パスタ(季節の野菜と海鮮)
  3. 自家製つけ麺(チャーシュー付き)
  4. ウニとイクラのクリームパスタ
  5. 五目焼きそば
  6. 豚肉とキムチの炒麺
  7. 海老と青菜の塩ラーメン
  8. あっさり鶏ガララーメン

ごはんもの

  1. 海鮮丼(季節の海鮮)
  2. 居酒屋特製焼き鳥丼
  3. うなぎの蒲焼丼
  4. 牛すじの煮込みごはん
  5. ちらし寿司(季節のネタ)
  6. 炙りサーモンのおにぎり二個
  7. 筍と鶏肉の炊き込みごはん
  8. 五目おこわ

デザート

  1. 抹茶のパンナコッタ
  2. 黒ごまのアイスクリーム
  3. 季節のフルーツパフェ
  4. さくらんぼのヨーグルトムース
  5. 練乳キャラメルのプリン
  6. レモンとハチミツのゼリー
  7. マンゴーとココナッツのタピオカドリンク
  8. ゆずと白桃のソルベ
hajimismhajimism

英語がイマイチってことはないか。日本語verがいやに凝っているw

hajimismhajimism

プロンプトはどっちも大体これ

居酒屋のメニューを作成します。下記の各ジャンルそれぞれにおいて、8つずつメニュータイトルを作成してください。

酒類、おつまみ、刺身、揚げ物、焼き物、麺類、ごはんもの、デザート
hajimismhajimism

日本語ver、いやに「自家製」ってついてるなw
日本人は自家製が好き

hajimismhajimism

日本語の方を使おうと思ってJSONにしてもらった。いまいちなデータ構造な気がするけど、表示に関しては便利なので一旦OK。困ったらまた別なふうに整形してもらえばよし。

{
  "menu": {
    "酒類": [
      "日本酒フライト(季節の選りすぐり)",
      "山廃純米の生一本",
      "本格焼酎の水割りセット",
      "季節のフルーツカクテル",
      "クラフトビール(地元の醸造所より)",
      "梅酒とゆず酒の二種盛り",
      "熟成日本酒のお燗",
      "スパークリング日本酒"
    ],
    "おつまみ": [
      "枝豆の塩ゆで",
      "たたききゅうりのごま油和え",
      "チーズとサラミの盛り合わせ",
      "レモンとハーブのマリネサラダ",
      "季節の野菜のピクルス",
      "和風ポテトサラダ",
      "居酒屋特製唐揚げ",
      "自家製冷製トマトスープ"
    ],
    "刺身": [
      "まぐろの三色丼(赤身、中トロ、大トロ)",
      "季節のお刺身5点盛り",
      "ブリの照り焼き刺身",
      "新鮮イカとウニの刺身",
      "たこときゅうりの酢の物",
      "刺身のサラダ(季節の魚を使用)",
      "白身魚のカルパッチョ",
      "鮮度抜群!サーモンとアボカドの刺身"
    ],
    "揚げ物": [
      "天ぷら盛り合わせ(季節の野菜と海鮮)",
      "鶏の唐揚げ(自家製ソース付き)",
      "かぼちゃのコロッケ",
      "季節の魚のフライ",
      "エビフライ(自家製タルタルソース付き)",
      "自家製豆腐の揚げ出し",
      "チーズとハムの春巻き",
      "ポテトフライ(自家製ソース付き)"
    ],
    "焼き物": [
      "鳥の焼き鳥盛り合わせ",
      "真ダコのバター焼き",
      "季節の野菜の串焼き",
      "特選和牛の石焼き",
      "骨付きラムのローズマリー焼き",
      "海鮮焼き(季節の魚と貝)",
      "銀ダラの西京焼き",
      "フォアグラの鉄板焼き"
    ],
    "麺類": [
      "居酒屋特製冷やし中華",
      "和風パスタ(季節の野菜と海鮮)",
      "自家製つけ麺(チャーシュー付き)",
      "ウニとイクラのクリームパスタ",
      "五目焼きそば",
      "豚肉とキムチの炒麺",
      "海老と青菜の塩ラーメン",
      "あっさり鶏ガララーメン"
    ],
    "ごはんもの": [
      "海鮮丼(季節の海鮮)",
      "居酒屋特製焼き鳥丼",
      "うなぎの蒲焼丼",
      "牛すじの煮込みごはん",
      "ちらし寿司(季節のネタ)",
      "炙りサーモンのおにぎり二個",
      "筍と鶏肉の炊き込みごはん",
      "五目おこわ"
    ],
    "デザート": [
      "抹茶のパンナコッタ",
      "黒ごまのアイスクリーム",
      "季節のフルーツパフェ",
      "さくらんぼのヨーグルトムース",
      "練乳キャラメルのプリン",
      "レモンとハチミツのゼリー",
      "マンゴーとココナッツのタピオカドリンク",
      "ゆずと白桃のソルベ"
    ]
  }
}

hajimismhajimism

メニュー一覧を表示する

こういうときにテンプレートリポジトリがあると便利ですよね
https://github.com/hajimism/izakaya-menu-app/tree/main

hajimismhajimism

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>
  );
}

hajimismhajimism

自分で書いたほうがコントロールしやすくてはえーなとは思いつつ、でも考えることは少なくて楽だった

hajimismhajimism

ところで、これだけだとスムーズスクロールにならない?
<html lang="ja" className="scroll-smooth">

hajimismhajimism

メニューごとに注文数をカートに保持できるようにする

JotaiいれてAIにお願いしたら簡単にやってくれた

hajimismhajimism

何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;

hajimismhajimism

ここでAPIの回数/時間制限が来る
時間が解決してくれるだろうとはいえ、GPT-4はここがつらいね
せっかく生産性あがっても途中で途切れる

hajimismhajimism

ブラウザの音声認識でtextをgetする

https://github.com/JamesBrill/react-speech-recognition
これでやればすぐできそう

hajimismhajimism

なんか上手く動かなかったので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,
  };
};

hajimismhajimism

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;

hajimismhajimism

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,
  };
};

hajimismhajimism

うまいこと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;

hajimismhajimism

これでonStopのときにAIにtranscript送って、返ってきた結果をカートに反映させればOK

hajimismhajimism

動くけどエラー吐いてるわ

error ReferenceError: window is not defined
    at useSpeech (./app/useSpeech.ts:14:79)
hajimismhajimism

解決策、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,
  };
};

hajimismhajimism

注文textから注文内容.jsonを返すfunctionをAI側に作る

ようやっとAI
まずはこれを理解するところからだ
https://openai.com/blog/function-calling-and-other-api-updates

hajimismhajimism

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あざす

hajimismhajimism

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.

この例どんぴしゃだなあ

hajimismhajimism

わからんけど多分useChatにfunctionCallHandlerを渡しつつサーバー側にFunction定義書いておいたらmessage.function_call.argumentsで取れるんじゃないかな
https://sdk.vercel.ai/docs/guides/functions

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)
}
hajimismhajimism

でもなー、これメニュー一覧読ませるのどこでやるんやろ

hajimismhajimism

わかんねーーー
Vercelに隠蔽されすぎているのがいけないのか

hajimismhajimism

結局何もわからない、ふつうにプログラミングが下手

hajimismhajimism

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>
  );
}

hajimismhajimism

おおおお?

良さげ、だけどこれってparseできるんけ?

"{"arguments":"{\n  \"orders\": [\n    {\n      \"title\": \"唐揚げ\",\n      \"count\": 1\n    },\n    {\n      \"title\": \"枝豆\",\n      \"count\": 1\n    }\n  ]\n}"}"
hajimismhajimism

"スパークリング日本酒と、揚げ出し豆腐2つ"でこれが返ってくる

"{"arguments":"{\n  \"orders\": [\n    {\n      \"title\": \"スパークリング日本酒\",\n      \"count\": 1\n    },\n    {\n      \"title\": \"揚げ出し豆腐\",\n      \"count\": 2\n    }\n  ]\n}"}"

メニュー的には「自家製豆腐の揚げ出し」なのでイマイチ

hajimismhajimism

何日か離れてようやくわかったけれども、Function callingって自然言語を関数呼び出しに変えてくれるところが強いのか。だから

  1. 注文音声聞き取り
  2. AIによる解釈
  3. addOrder関数呼び出し

でいけるじゃん。

hajimismhajimism

だから、functionCallHandlerの内部でargsを使って注文関数を呼び出せばいいんだな。

hajimismhajimism
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)
hajimismhajimism

とりあえずconsole.logで適切にcallされていることはわかった。めちゃくちゃ良い。

hajimismhajimism

普通にこうすればよかった

 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));
    }
  };
hajimismhajimism

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>
    </>
  );
};
hajimismhajimism

とはいいつつ、JSONにない注文が入ってくる可能性は全然あるので、そこのバリデーションは敷いておきたいな...。もうちょいpromptの精度上げれるかもっていうのもあるし。

hajimismhajimism


メニューでは「新鮮イカとウニの刺身」なのに、orderはイカとウニの刺身となっている

hajimismhajimism

一旦0→1はできたので、残る課題を別スクラップとして切り出すためにあげつらう

  • AIが返してくれたJSONの検証:元メニューとの照合
  • ↑弾くのか、一部でも一致してたらメニューに入れとくのか
  • プロンプトエンジニアリング(の精度をどうやって確かめるの問題。手作業で何回もやるしか無い?)
  • vercel/aiの使い方これであってるのか問題(ドキュメントちゃんと読め問題)

あとはインターフェイスそのものの問題として

  • 〇〇の何味、みたいな構造的なメニューをどう表現する?
  • この画面なら素直に+押して注文したほうが早いでしょ問題(上と関連)
  • loading状態を表現したい(これはやればできそう)

などがある

このスクラップは2023/07/31にクローズされました