🖋️

Github CopilotみたいなAIアシスト日記エディターを作る

2024/07/25に公開

はじめに

Github Copilotって便利ですよね。コードを一部分だけ打っても、内容を予測して続きを予測して補完してくれます。
マークダウンファイル内では文字列についても使えます。

VScode + Github Copilotっぽいエディタを自分でも作成してみました。

技術的な学びについて解説を加えていきます。
なお、Claudeを使いながら作成しているため不自然なコードが含まれている可能性があります。ご注意ください。

作ったもの

文字を入力すると、予測した続きの文章がグレーで表示されます。Tabキーを押すと確定します。

解説している技術的要素

  • useRef
  • 日本語IMEの入力状態判定
  • デバウンス処理
  • useEffectのクリーンアップ関数
  • イベントリスナー(onKeyDown)を使用したキーボード入力の処理
  • カーソル位置の移動

コード

コード
src/app/api/route.tsx
import { NextResponse } from "next/server";
import axios from "axios";

// システムプロンプトの定義
// このプロンプトはAIの振る舞いを指定します
const systemPrompt = `あなたは日記作成を支援するAIアシスタントです。
1. ユーザーの入力を分析し、その文体、トーン、内容に合わせて、自然に続く文章を生成してください。
2. 返答や説明ではなく、あくまでもユーザーが書いているかのような文章の続きを提案してください。
`;

// POSTリクエストを処理する非同期関数
export async function POST(req: Request) {
  try {
    // リクエストボディからプロンプトを抽出
    const { prompt } = await req.json();

    // OpenAI APIキーが設定されているか確認
    if (!process.env.OPENAI_API_KEY) {
      console.error("OPENAI_API_KEY is not set");
      throw new Error("OPENAI_API_KEY is not set");
    }

    console.log("Sending request to OpenAI API");
    // OpenAI APIにリクエストを送信
    const response = await axios.post(
      "https://api.openai.com/v1/chat/completions",
      {
        model: "gpt-4o-mini", // 使用するモデル
        messages: [
          { role: "system", content: systemPrompt }, // システムメッセージ
          { role: "user", content: prompt }, // ユーザーのプロンプト
        ],
        max_tokens: 10, // 生成するトークンの最大数
        n: 1, // 生成する回答の数
        stop: null, // 生成を停止する条件(ここではnull)
        temperature: 0.7, // 生成の多様性(0.0-1.0)
      },
      {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, // 認証ヘッダー
        },
      }
    );

    console.log("Received response from OpenAI API");
    // APIレスポンスから予測テキストを抽出し、JSONとして返す
    return NextResponse.json({
      prediction: response.data.choices[0].message.content.trim(),
    });
  } catch (error) {
    console.error("Error in API route:", error);

    // エラーハンドリング
    if (axios.isAxiosError(error)) {
      // Axiosのエラーの場合
      console.error("Axios error details:", error.response?.data);
      const status = error.response?.status || 500;
      const message = error.response?.data?.error?.message || error.message;
      return NextResponse.json({ error: message }, { status });
    } else if (error instanceof Error) {
      // 一般的なエラーの場合
      return NextResponse.json({ error: error.message }, { status: 500 });
    }

    // 予期せぬエラーの場合
    return NextResponse.json(
      { error: "An unexpected error occurred" },
      { status: 500 }
    );
  }
}
src/app/page.tsx
"use client";

import React, { useState, useCallback, useEffect, useRef } from "react";
import { getPrediction } from "../utils/api";

// テキストエディタコンポーネント
const TextEditor: React.FC = () => {
  // 状態変数の定義
  const [input, setInput] = useState(""); // ユーザーの入力テキスト
  const [prediction, setPrediction] = useState(""); // AIの予測テキスト
  const [error, setError] = useState<string | null>(null); // エラーメッセージ
  const [copySuccess, setCopySuccess] = useState(false); // コピー成功フラグ
  const textareaRef = useRef<HTMLTextAreaElement>(null); // textareaのDOM参照
  const [isComposing, setIsComposing] = useState(false); // IME入力中フラグ

  // ユーザーの入力をstateに反映する関数
  const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInput(e.target.value);
    setError(null);
  };

  // AIの予測を取得する関数
  const fetchPrediction = useCallback(async (text: string) => {
    if (text.trim()) {
      try {
        const result = await getPrediction(text);
        setPrediction(result);
      } catch (error) {
        console.error("Error fetching prediction:", error);
        setError(
          error instanceof Error
            ? error.message
            : "Failed to fetch prediction. Please try again."
        );
        setPrediction("");
      }
    } else {
      setPrediction("");
    }
  }, []);

  // AIの予測をテキストエディタに適用する関数
  const applySuggestion = useCallback(() => {
    if (textareaRef.current && prediction) {
      const textarea = textareaRef.current; // テキストエリアのDOM要素を取得
      const newText = textarea.value + prediction; // 現在のテキストに予測テキストを追加して新しいテキストを作成
      setInput(newText); // Reactの状態を更新してUIと同期
      textarea.focus(); // テキストエリアにフォーカスを当てる
      textarea.setSelectionRange(newText.length, newText.length); // カーソルを新しいテキストの末尾に移動
      setPrediction(""); // 予測テキストをクリアし、次の予測の準備をする
    }
  }, [prediction]); // predictionが変更されたときのみ関数を再作成

  // キーボードイベントを処理する関数(Tabキーで予測を適用)
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Tab") {
      e.preventDefault();
      applySuggestion();
    }
  };

  // テキストをクリップボードにコピーする関数
  const handleCopy = () => {
    if (textareaRef.current) {
      const text = textareaRef.current.value;
      navigator.clipboard.writeText(text).then(
        () => {
          setCopySuccess(true);
          setTimeout(() => setCopySuccess(false), 2000);
        },
        (err) => {
          console.error("Failed to copy text: ", err);
        }
      );
    }
  };

  // IME入力の開始と終了を処理するイベントリスナーを設定
  useEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      const handleCompositionStart = () => setIsComposing(true);
      const handleCompositionEnd = () => setIsComposing(false);

      textarea.addEventListener("compositionstart", handleCompositionStart);
      textarea.addEventListener("compositionend", handleCompositionEnd);

      // クリーンアップ関数
      return () => {
        textarea.removeEventListener(
          "compositionstart",
          handleCompositionStart
        );
        textarea.removeEventListener("compositionend", handleCompositionEnd);
      };
    }
  }, [fetchPrediction]);

  // 入力が変更されたときにAIの予測を取得(デバウンス処理付き)
  useEffect(() => {
    if (!isComposing) {
      const timeoutId = setTimeout(() => fetchPrediction(input), 200);
      return () => clearTimeout(timeoutId);
    }
  }, [input, isComposing, fetchPrediction]);

  // UIのレンダリング
  return (
    <div className="bg-transparent flex items-top justify-center">
      <div className="w-full max-w-2xl bg-white rounded-lg shadow-md p-6">
        <div className="flex justify-between items-center mb-4">
          <p className="text-slate-300 text-sm">
            Press Tab to apply the suggestion.
          </p>
          <button
            onClick={handleCopy}
            className="px-4 py-2 bg-slate-300 text-white rounded hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">
            {copySuccess ? "Copied!" : "Copy Text"}
          </button>
        </div>
        <div className="relative w-full">
          {/* AIの予測テキストを表示するオーバーレイ */}
          <div className="absolute top-0 left-0 p-3 pointer-events-none whitespace-pre-wrap break-words z-10 text-transparent">
            {input}
            {prediction && <span className="text-gray-400">{prediction}</span>}
          </div>
          {/* 編集可能なテキストエリア */}
          <textarea
            ref={textareaRef}
            className="w-full p-3 border border-gray-300 rounded min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent whitespace-pre-wrap break-words bg-white text-gray-800"
            value={input}
            onChange={handleInput}
            onKeyDown={handleKeyDown}
          />
        </div>
        {/* エラーメッセージの表示 */}
        {error && <div className="text-red-500 mt-2">{error}</div>}
      </div>
    </div>
  );
};

export default TextEditor;


src/utils/api.ts
// axios ライブラリをインポート(HTTPリクエストを簡単に行うため)
import axios from "axios";

// アロー関数を使用してgetPrediction関数を定義し、エクスポート
export const getPrediction = async (prompt: string): Promise<string> => {
  try {
    // '/api/prediction' エンドポイントにPOSTリクエストを送信
    // promptをリクエストボディに含める
    const response = await axios.post("/api/prediction", { prompt });

    // レスポンスからpredictionプロパティを取り出して返す
    return response.data.prediction;
  } catch (error) {
    // エラーが発生した場合、コンソールにエラー情報を出力
    console.error("Error fetching prediction:", error);

    // エラー時は空文字列を返す(エラーハンドリングの簡略化)
    return "";
  }
};

解説

項目ごとに解説を入れます。

動作の概要

①ユーザーがテキスト入力欄に文字を入力する
    ↓
②日本語IMEが変換完了状態になる
    ↓
③変換完了から200ms後に現在までの入力データをChatGPTのAPIに送信する
    ↓
④予測テキストとして、10トークン分の続きの文章を生成する
    ↓
⑤入力した文字の上に、予測したテキストを半透明にしてオーバーレイする
上層:予測テキストを表示するオーバーレイ用の div
下層:実際にユーザーがテキストを入力している textarea

    ↓
⑥Tabキーを押すと、現在のテキストに予測テキストを結合する。

テキスト入力欄

textareaを設置して、入力したテキストはvalue={input}を使って、後から取り出せるようにします。
入力が変わるたびにonChangehandleInputが実行され、stateとして入力したテキストを格納します。

  {/* 編集可能なテキストエリア */}
  <textarea
    ref={textareaRef}
    className="w-full p-3 border border-gray-300 rounded min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent whitespace-pre-wrap break-words bg-white text-gray-800"
    value={input}
    onChange={handleInput}
    onKeyDown={handleKeyDown}
  />
// ユーザーの入力をstateに反映する関数
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInput(e.target.value);
    setError(null);
};

useRefについての説明

useRefはDOMや値の参照を保存するために使います。

useStateと異なり、値が変更されても再レンダリングされない性質を持ちます。
(例えば、useRefで値を保持するカウンターアプリを作ったとしたら、内部的にカウントが増えても画面描画は変わりません。)

// 変数名 = useRef(初期値)
const ref = useRef(initialValue)

// 設定した値を取り出す
const value = ref.current;

// 値を変更する
// 値が変更されても再レンダリングされない
const ref.current = 2;

よく使う使い方として、あるDOMを参照したいときに、useRefオブジェクトを作成して、その参照先のHTML要素をref属性を使って指定します。

今回のコードでは、以下のようにtextareaタグにref属性を指定することで、valueの値を参照したり、フォーカスを当てたりできます。

const textareaRef = useRef<HTMLTextAreaElement>(null);
<textarea
  ref={textareaRef}
  // その他のプロパティ...
/>
// AIの予測をテキストエディタに適用する関数
const applySuggestion = useCallback(() => {
if (textareaRef.current && prediction) {
  const textarea = textareaRef.current; // テキストエリアのDOM要素を取得
  const newText = textarea.value + prediction; // 現在のテキストに予測テキストを追加して新しいテキストを作成
  setInput(newText); // Reactの状態を更新してUIと同期
  textarea.focus(); // テキストエリアにフォーカスを当てる
  textarea.setSelectionRange(newText.length, newText.length); // カーソルを新しいテキストの末尾に移動
  setPrediction(""); // 予測テキストをクリアし、次の予測の準備をする
}
}, [prediction]); // predictionが変更されたときのみ関数を再作成

以下の記事を参考にさせていただきました。
https://qiita.com/cheez921/items/9a5659f4b94662b4fd1e

日本語IMEの状態判定

文字入力を行う際、日本語入力ではIMEでの漢字などの変換の工程が入ります。
IMEの変換が終わったら自動で予測を出力させたいので、IMEが入力中か変換終了したかを判定します。

addEventListenerを設定することで、特定のDOM要素のイベントが発生した場合に関数を実行させることができます。
ここ文字入力が開始された瞬間と文字変換が終了したタイミングでそれぞれ発火します。

発火したhandleCompositionStarthandleCompositionEndはIME入力中かどうかのフラグをオンオフします。

// 状態変数の定義
const textareaRef = useRef<HTMLTextAreaElement>(null); // textareaのDOM参照
const [isComposing, setIsComposing] = useState(false); // IME入力中フラグ
  // IME入力の開始と終了を処理するイベントリスナーを設定
  useEffect(() => {
    const textarea = textareaRef.current;
    if (textarea) {
      const handleCompositionStart = () => setIsComposing(true);
      const handleCompositionEnd = () => setIsComposing(false);

      textarea.addEventListener("compositionstart", handleCompositionStart);
      textarea.addEventListener("compositionend", handleCompositionEnd);

デバウンス処理を使って猶予時間を設ける

デバウンス処理とは、連続的に起こるイベントに対して、最後のイベントから一定時間経過した後にのみ処理を行うテクニックです。

今回の事例で考えましょう。
ユーザー1文字入力するごとに予測の処理が走ると、API呼び出しの回数が多くなりすぎてしまいます。
そこでユーザーの入力がひと段落したタイミング、つまり書く内容に詰まったタイミングで予測を行います。

https://qiita.com/KokiSakano/items/b327cbeed020a2872ae6

依存配列にあるinputは毎回の文字入力ごとに変更されます。そのためuseEffect内のコードも文字入力ごとに実行されます。

このコードでは、以下の2つを実行しています。
①200ms後にfetchPrediction(input)を実行するタイマーをセットする
②returnの部分で、次回のuseEffect発火時に動作するクリーンアップ関数を作成する。

つまり、ユーザーの文字入力が連続している場合は、常にuseEffectが発火し続けて、200ms経過する前にクリーンアップ関数によりタイマーが削除されるため、fetchPrediction(input)が実行されません。

  // 入力が変更されたときにAIの予測を取得(デバウンス処理付き)
  useEffect(() => {
    if (!isComposing) {
      const timeoutId = setTimeout(() => fetchPrediction(input), 200);
      return () => clearTimeout(timeoutId);
    }
  }, [input, isComposing, fetchPrediction]);

ChatGPT APIに続きを予測させる

200ms以上入力が止まり、タイマーで設定したfetchPredictionが実行されたとします。
fetchPredictionの引数にこれまで入力した文字列であるinputを渡します。

asyncとawaitを使って、実際にAPIを叩く箇所であるgetPredicton関数が実行が終わるまで待機します。

const timeoutId = setTimeout(() => fetchPrediction(input),
  // AIの予測を取得する関数
  const fetchPrediction = useCallback(async (text: string) => {
    if (text.trim()) {
      try {
        const result = await getPrediction(text);
        setPrediction(result);
      } catch (error) {
        console.error("Error fetching prediction:", error);
        setError(
          error instanceof Error
            ? error.message
            : "Failed to fetch prediction. Please try again."
        );
        setPrediction("");
      }
    } else {
      setPrediction("");
    }
  }, []);

APIを叩く処理は記載部分は別ファイルに切り出しています。

実際に触ってみた結果、予測される文字が多すぎると使いにくいと感じたため、出力の最大トークン数を10に設定しました。

utils/api.ts
import axios from "axios";

// アロー関数を使用してgetPrediction関数を定義し、エクスポート
export const getPrediction = async (prompt: string): Promise<string> => {
  try {
    // '/api/prediction' エンドポイントにPOSTリクエストを送信
    // promptをリクエストボディに含める
    const response = await axios.post("/api/prediction", { prompt });

    // レスポンスからpredictionプロパティを取り出して返す
    return response.data.prediction;
  } catch (error) {
    // エラーが発生した場合、コンソールにエラー情報を出力
    console.error("Error fetching prediction:", error);

    // エラー時は空文字列を返す(エラーハンドリングの簡略化)
    return "";
  }
};

エンドポイントの記載内容です。
システムプロンプトとして日記作成であること、続きを生成することを明記しました。
何も指示しない場合、入力文を質問と捉えてしまい、回答しようとします。

今回は日記にしましたが、システムプロンプトの工夫次第で、ビジネス文章や定型文の入力補完などの利用が期待できます。

src/app/api/route.tsx
import { NextResponse } from "next/server";
import axios from "axios";

// システムプロンプトの定義
// このプロンプトはAIの振る舞いを指定します
const systemPrompt = `あなたは日記作成を支援するAIアシスタントです。
1. ユーザーの入力を分析し、その文体、トーン、内容に合わせて、自然に続く文章を生成してください。
2. 返答や説明ではなく、あくまでもユーザーが書いているかのような文章の続きを提案してください。
`;

(以下省略)

入力した文字の上に、予測したテキストを半透明にしてオーバーレイする

一つのテキストエリアの中で文字が半透明で続いているように見えますが、実際には2層構造になっています。

上層:予測テキストを表示するオーバーレイ用の div
下層:実際にユーザーがテキストを入力している textarea

<div className="relative w-full">
  {/* AIの予測テキストを表示するオーバーレイ */}
  <div className="absolute top-0 left-0 p-3 pointer-events-none whitespace-pre-wrap break-words z-10 text-transparent">
    {input}
    {prediction && <span className="text-gray-400">{prediction}</span>}
  </div>
  {/* 編集可能なテキストエリア */}
  <textarea
    ref={textareaRef}
    className="w-full p-3 border border-gray-300 rounded min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent whitespace-pre-wrap break-words bg-white text-gray-800"
    value={input}
    onChange={handleInput}
    onKeyDown={handleKeyDown}
  />
</div>

以下の3つの設定によって、オーバーレイ部分を作ります。

a. テキストの連結:
{input} {prediction} が連続して配置されています。
これにより入力と予測が隣り合って表示されます。

b. テキストを透明にする:
オーバーレイdiv全体に text-transparent クラスが適用されています。
つまり、divの文字全体が透明になります。

c. 予測テキストをグレーにする:
予測テキストは <span className="text-gray-400"> で囲まれています。
classNameは子要素に直接指定することで上書きすることができます。
これにより予測テキストのみグレーになります。

<div className="absolute top-0 left-0 p-3 pointer-events-none whitespace-pre-wrap break-words z-10 text-transparent">
  {input}
  {prediction && <span className="text-gray-400">{prediction}</span>}
</div>

そして、z-10によって、オーバーレイをテキストエリアと同じ位置に上から重ねます。

このままでは下層のテキストエリアをクリックした際に、判定がオーバーレイに吸われてしまうので、pointer-events-noneを設定して判定を無効化します。

Tabキーを押したら、現在のテキストに予測テキストを結合する。

予測の確定ボタンを作りましょう。

テキストエリアの中で、onKeyDownによって常にキーボードの入力を監視しています。

  {/* 編集可能なテキストエリア */}
  <textarea
    ref={textareaRef}
    className="w-full p-3 border border-gray-300 rounded min-h-[200px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent whitespace-pre-wrap break-words bg-white text-gray-800"
    value={input}
    onChange={handleInput}
    onKeyDown={handleKeyDown}
  />

キーボード入力がTabキーのときのみコードを実行します。
e.preventDefault()は、通常のTabキーのデフォルト動作を無効化しています。

  // キーボードイベントを処理する関数(Tabキーで予測を適用)
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Tab") {
      e.preventDefault();
      applySuggestion();
    }
  };

setInput(newText)によって、inputステート(ユーザーが入力した文字)を更新します。

textarea.setSelectionRange(newText.length, newText.length)によってテキスト選択の開始位置と終了位置をともに最後の文字の位置に設定することでカーソルを末尾に移動させます。

  // AIの予測をテキストエディタに適用する関数
  const applySuggestion = useCallback(() => {
    if (textareaRef.current && prediction) {
      const textarea = textareaRef.current; // テキストエリアのDOM要素を取得
      const newText = textarea.value + prediction; // 現在のテキストに予測テキストを追加して新しいテキストを作成
      setInput(newText); // Reactの状態を更新してUIと同期
      textarea.focus(); // テキストエリアにフォーカスを当てる
      textarea.setSelectionRange(newText.length, newText.length); // カーソルを新しいテキストの末尾に移動
      setPrediction(""); // 予測テキストをクリアし、次の予測の準備をする
    }
  }, [prediction]); // predictionが変更されたときのみ関数を再作成

まとめ

テキスト入力を補完してくれるエディターを作成しました。

今後の改善点としては、以下を考えています。

  • 数文字ごとに予測を確定する
  • 用途に応じてシステムプロンプトを切り替えられるようにする
  • 予測だけでなく「今日は花火大会にいった。」←(誰と行ったのですか?)のような、内容についての質問を出す

Discussion