👮

VSCode上のGitHub Copilotのような文章補完をブラウザで実装する

2023/12/12に公開

🎅GMOペパボエンジニア Advent Calendar 2023 12日目の記事です。

GitHub Copilot

みなさん、GitHub Copilot使っていますか?

自分は毎日業務でもお世話になりまくっており、コード補完をされまくっています。

何よりもVSCodeの拡張機能で、エディタ上で入力した内容から続きをいい感じに提案してくれるところが最高に体験が良くて、今回はこれをブラウザ上でも実現しようと思い、ADD(アドベントカレンダー駆動開発)で作ってみました。

完成したコードはこちらです。
https://github.com/nacal/ai-suggestion-textarea

実装

今回補完してもらう入力部分は、textareaでの実装ではなくcontenteditableでtextareaを模したものを作っています。
https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/contenteditable

TwitterのツイートXのポストでもこれが採用されているようでした。

これを採用した経緯としては、textareaのvalueと、copilotのように入力した文章の続きとして出力される文章(以下suggestions)を同時に扱うのが難しかったためです。

そのため、contenteditableのfirstChild.textContentとしてユーザーが入力する文字列を扱い、suggestionはspanで後ろに付与できるようにしました。

const [suggestions, setSuggestions] = useState<string | null>(null)
const textarea = useRef<HTMLDivElement | null>(null)

return (
  <main className="min-h-[100dvh] flex flex-col items-center justify-center">
    <div
      contentEditable
      suppressContentEditableWarning={true}
      ref="textarea"
      role="textbox"
      aria-label="プロフィールを入力"
      onInput={handleInputValue}
      className="w-96 h-48 outline p-4 mt-4"
    >
      <span className="text-gray-400" aria-live="polite">{suggestions}</span>
    </div>
  </main>
)

また、aria-live="polite"でsuggestionsが生成されたタイミングで読み上げがされるようにしています。

次に、onInputでトリガーされるhandleInputValueを作成します。

const timer = useRef<NodeJS.Timeout | null>(null)

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

  if ((e.nativeEvent as InputEvent).data && e.currentTarget.textContent) {
    const text = e.currentTarget.textContent

    timer.current = setTimeout(async () => {
      try {
        const response = await fetchSuggestions(text)
        setSuggestions(response)
      } catch (error) {
        // 何もしない
      }
    }, 1000)
  } else {
    resetSuggestions()
  }
}, [])

ここでは、useRefでsetTimeoutを管理し、Inputから1秒以上入力がなかった場合にOpenAIへのリクエストを発火しています。これにより、文章入力中の無駄なリクエストが飛ばないようにします。

また、文字を削除した際やそもそも文章がない時にも同様にリクエストをしないようにしています。

OpenAIへのリクエストはKeyが公開されないよう、Next.jsのServer Actionsで実装します。

// src/app/openai.ts
'use server'

import OpenAI from 'openai'

const fetchSuggestions = async (text: string) => {
  const openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  })

  const chatCompletion = await openai.chat.completions.create({
    messages: [
      {
        role: 'user',
        content: `自分自身の自己紹介を書きたいです。「${text}」に続く文節を考えてください。`,
      },
      {
        role: 'user',
        content: `${text}」は省略し、続きの文節のみを短く完結に返却してください。日本語のみで生成してください。`,
      },
    ],
    model: 'gpt-3.5-turbo',
  })

  const message = chatCompletion.choices[0].message?.content

  if (message && message.startsWith(text)) {
    return message.slice(text.length)
  }

  return message
}

export default fetchSuggestions

ここではユーザーが入力した文章を受け取り、文章補完のための最低限のプロンプトを与えています。また、続きの文章だけを返せと指示していますが、念の為最初の文章を含めて返してきた場合には取り除くようにしておきます。

OpenAIへのリクエストが成功すると、Copilot風に続きの文章を補完してくれます。
ここからkeydown/clickイベントを監視して取り込むか破棄するかを判別するようにします。

useEffect(() => {
  if (suggestions) {
    addEventListener('keydown', handleSuggestions)
    addEventListener('click', resetSuggestions)
  }

  return () => {
    removeEventListener('keydown', handleSuggestions)
    removeEventListener('click', resetSuggestions)
  }
}, [suggestions])

const resetSuggestions = () => {
  setSuggestions(null)
}

const handleSuggestions = (e: KeyboardEvent) => {
  if (e.key === 'Tab') {
    e.preventDefault()
    commitSuggestions()
  }

  resetSuggestions()
}

const resetSuggestions = () => {
  setSuggestions(null)
}

Tabキーが押下された場合にはpreventDefaultをして規定のイベントであるfocus移動を阻止し、変更を取り込むcommitSuggestionsをトリガーします。続きの文章入力などによってTab以外のキーが押下された場合やクリックイベントが発生した場合には、いずれもsuggestionsを非表示にするようにします。

const commitSuggestions = () => {
  const textareaElement = textarea.current

  if (textareaElement?.firstChild) {
    textareaElement.firstChild.textContent = textareaElement.innerText
    caretToLast(textareaElement)
  }
}

const caretToLast = (element: HTMLElement) => {
  const sel = window.getSelection()
  const range = document.createRange()
  const textLength = element.firstChild?.textContent?.length || 0

  range.setStart(element?.firstChild as Node, textLength)
  range.collapse(true)

  if (sel) {
    sel.removeAllRanges()
    sel.addRange(range)
  }
}

commitSuggestionsでは、textarea.firstChildをtextarea.innerTextに置換します。ここでinnerTextはspan内のsuggestionも含んだ文章になっています。また、補完した後は続きの文章をそのまま入力できるように、キャレットを文章の最後に持っていくようにします。

これで、Copilotっぽくブラウザ上でも文章をAIに補完してもらうことができました。

完成したもの

まとめ

アドカレ公開日の直前に急いで作ったので荒削りの部分もありますが、ブラウザ上でもいい感じに動くものが作れました。(スマホなどの入力デバイスがないものには非対応です。)

実用のために改善できるポイントはまだまだありそうですが、webサービスでもAIによる文章補完の体験が当たり前になるのも遠い未来ではないかなと感じることができました。

https://adventar.org/calendars/8634

明日は我らがdoew先輩の記事です、楽しみ!

Discussion