🎃

Remix + OpenAI APIでCopilot入力補助機能を作る

2024/04/26に公開

はじめに

こんにちは、Webサイト「健常者エミュレータ事例集」の管理人をしています、contradiction29です。

健常者エミュレータ事例集はユーザーが暗黙知を投稿し、評価し、コメントできるWebサイトです。2024年4月現在で9000件を超える投稿と20000件を超えるコメントを集めています。

https://healthy-person-emulator.org/readme

健常者エミュレータ事例集はユーザーからの投稿によって成り立っているため、投稿をいかにしてスムーズにするかがポイントになります。ユーザーがスムーズに投稿できるようになれば、より多くの投稿を集めることが可能となり、結果としてサイトの発展につながります。

その一環として、gpt-3.5-turboを利用した入力補助機能を実装してみました。下記の投稿フォームから実装を体験できます。フォームの上から順番に入力することで、経験を整理しながら記事を作成できる仕組みになっています。

https://healthy-person-emulator.org/post

デモ動画は以下のポストから閲覧できます。

https://x.com/contradiction29/status/1783721278333243884

今回は、実際の実装から根幹的な部分だけを抜粋し、極力シンプルな実装にしたものを紹介しようと思います。下記のリポジトリから実装を見ることが可能です。

https://github.com/sora32127/remix-copilot-demo

利用する技術

最小限にとどめます。これ以外は使いません。

  • TypeScript
  • Remix
  • OpenAI API

実装

それでは実装に入っていきます。全体的な印象ですが、バックエンド側は簡単で、フロントエンド側の方が難しかったです。

バックエンド側

app/routes/api.ai.getCompletion.tsx
import { ActionFunctionArgs } from "@remix-run/node";
import { OpenAI } from "openai";

export async function action({ request }: ActionFunctionArgs){
    const formData = await request.formData();
    const text = formData.get("text") as string;
    const completionText = await getCompletion(text);
    return completionText;
}

async function getCompletion(text: string){
    const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
    const openAI = new OpenAI({ apiKey: OPENAI_API_KEY});
    const result = await openAI.chat.completions.create({
        messages: [
            {
                role: "system",
                content: "あなたは優秀な日本語の作文アシスタントです。ユーザーが入力した文章の続きを、自然で文法的に正しい日本語で簡潔に生成してください。与えられた文脈を考慮し、ユーザーの意図を汲み取って適切な文章を生成することを心がけてください。"
            },
            {
                role: "assistant",
                content: `承知しました。ユーザーの入力文に続く自然な日本語の文章を簡潔に生成いたします。以下の文脈情報を参考にします。`,
            },
            {
                role: "user",
                content: `次の文章の続きを、文脈を考慮して自然な日本語で簡潔に生成してください。40文字程度でお願いします。\n「${text}`,
            }
        ],
        model: 'gpt-3.5-turbo'
    });
    const completion = result.choices[0].message.content
    return completion
}

RemixのResource Routesとして実装しています。

https://remix.run/docs/en/main/guides/resource-routes

Resource Routesは通常のRouteとは違い、コンポーネントのレンダリングを行わないRouteです。今回はformDataを経由してテキストを受け取り、補完の結果を返すだけなので、このユースケースではResoue Routesの利用が適していると判断しました。actionを呼び出しているので、POSTリクエストのエンドポイントを作成しているのと感覚は近いです。

全体的な流れは以下の通りです。

  1. formData経由でテキストボックス内に入力されたテキストを受け取る
  2. 受け取ったテキストを利用して補完文章を作成
  3. 作成した文章を返す

フロントエンド側

app/routes/components/AITextInputBox.tsx
import { FormEvent, useCallback, useEffect, useRef, useState } from "react"

export default function AITextInputBox() {
    const [suggestions, setSuggestions] = useState<string | null>(null);
    const textarea = useRef<HTMLTextAreaElement>(null);
    const timer = useRef<NodeJS.Timeout | null>(null);

    const handleInputValue = (e: FormEvent<HTMLTextAreaElement>) => {
        if (timer.current){
            clearTimeout(timer.current);
        }

        if (e.currentTarget.value){
            const text = e.currentTarget.value;
            timer.current = setTimeout(async () => {
                try {
                    const formData = new FormData();
                    formData.append("text", text);
                    const response = await fetch("/api/ai/getCompletion", {
                        method: "POST",
                        body: formData
                    });
                    const suggestion = await response.json();
                    setSuggestions(suggestion);
                } catch (error) {
                    console.error(error);
                }
            }, 1000)
        }
    }

    const commitSuggestions = useCallback(() =>{
        const textareaElement = textarea.current;
        if (textareaElement && suggestions){
            const newValue = textareaElement.value + suggestions;
            textareaElement.value = newValue;
            textareaElement.focus();
            setSuggestions(null);
        }
    }, [suggestions])

    const handleSuggestions = useCallback((e: KeyboardEvent) => {
        if (e.key === "Shift"){
            e.preventDefault();
            commitSuggestions();
        }
    },[commitSuggestions])

    useEffect(() => {
        if (suggestions){
            addEventListener("keydown", handleSuggestions);
        }
        return () => {
            removeEventListener("keydown", handleSuggestions);
        }
    }, [handleSuggestions, suggestions])

    return (
        <>
         <textarea
            ref={textarea}
            onChange={handleInputValue}
         />
            {suggestions && (
                <div>
                    <p>[補完候補]: {suggestions}</p>
                    <p>Shiftキーまたはボタンを押して補完できます</p>
                    <button onClick={commitSuggestions}>補完する</button>   
                </div>
            )}
        </>
    )
}

全体の流れとしては以下のようになっています。

  1. ユーザーがテキストエリアに文章を入力
  2. 入力から一定時間経過後、バックエンドに補完リクエストを送信
  3. バックエンドでOpenAI APIを使って入力文に続く文章を生成し、生成された補完候補をレスポンスとしてフロントエンドに返す(先述)
  4. フロントエンドで補完候補を表示
  5. ユーザーがShiftキーを押すか補完ボタンをクリックすると、補完候補がテキストエリアに反映される

GitHub CoplilotのようにTabキーを押して補完する形だとスマートフォンからの扱いが悪くなるため、ボタンを押して補完できるようにしてあります。また、Tabキーだと次の要素に移動するボタンと被るのでShiftキーにしてあります。Shift+Tabで前の要素に移動するボタンと被ってしまうので、何かいいボタンを考える必要はあります。

実行してみる

実行してみましょう。なお、OpenAIのAPI Keyは自分で取得する必要があります。

touch .env
echo $OPENAI_API_KEY >> .env
npm install
npm run dev

このようになっていたら成功です。

おわりに

全体的に、バックエンド側よりもフロントエンド側の方が考えることが多い印象でした。Webアクセシビリティの基準とうまく折り合いをつけつつ、シームレスな入力体験を提供するにはもう少し作りこむ必要がありそうです。

健常者エミュレータ事例集では常に投稿を求めています。これを読んでいる方の中で、もし興味が出てきた方がいたら、以下の投稿フォームから経験を共有していただけると助かります。

https://healthy-person-emulator.org/post

ちなみにですが、バックエンド側のコードを少し変えるだけでllama-3など別のモデルを使うことができます。

なお、健常者エミュレータ事例集に実装されているコンポーネントはもっと複雑で、プレースホルダーやステートのローカルストレージへの保存機能、コンテキストの読み込み機能、役割に応じたプロンプトの調整を含めたものになっています。興味がある方は以下のコードをご覧ください。

https://github.com/sora32127/healthy-person-emulator-dotorg/blob/main/app/components/SubmitFormComponents/TextInputBoxAI.tsx

https://github.com/sora32127/healthy-person-emulator-dotorg/blob/main/app/components/SubmitFormComponents/StaticTextInput.tsx

https://github.com/sora32127/healthy-person-emulator-dotorg/blob/main/app/routes/_layout.post.tsx

Discussion