JS Gymの開発で使った技術

JS Gymの開発で使った技術

投稿日: 2025年03月10日

Tips
要約
  • JS Gymは300名以上のユーザーに利用されており、OpenAIのAPIを使ったAIによる問題作成機能を活用しています。
  • monaco-editorを用いたコードエディターや、動的OGP画像生成など、多様な技術を駆使しており、特にフロントエンドでの安全なコード実行方法を工夫しています。
  • AIエージェントを利用したダッシュボード作成など、新しい機能の開発も進めており、ユーザーからのフィードバックを求めています。

JS Gymを公開しまして、ありがたいことに、現在300名以上の方に利用いただいております。

この記事では、開発で使った特徴的な技術について、いくつか紹介します。
(ベース機能はほとんど、茜TAが実装した内容です。)

問題の作成(AI)

AIで問題を作成しています。
OpenAIのAPI(chat completion)を用いています。

また、自動で問題が追加されていくように、
Vercelのcron jobs (定期実行処理)で毎日、作成APIを呼び出して、自動で問題が作成されるようになっています。

また、問題情報の作成(Questionテーブルのレコード)には以下の情報が必要です。

  • 問題タイトル

  • 難易度

  • 問題本文

  • 入力例

  • 出力例

  • エディタに初期表示するコメント分

  • タグ

  • 模範回答例

  • 担当レビュワーのID

こういった決まった型でAIからレスポンスを受けるために、OpenAIのAPIには、「Structured Outputs」という機能があります。
(元々、OpenAIにはJSONモードという機能があったのですが、それのパワーアップ版として最近でた機能です。)

フォームのバリデーションで有名なzodを用いたhelperライブラリをOpenAIが公開していて、それを用いることで、レスポンスの型を指定してOpenAIにリクエストを送ることができます。

import { zodResponseFormat } from "openai/helpers/zod";
  const Question = z.object({
    title: z.string(),
    inputCode: z.string(),
    outputCode: z.string(),
    template: z.string(),
    content: z.string(),
    level: z.enum(["BASIC", "ADVANCED", "REAL_WORLD"]),
    exampleAnswer: z.string(),
    tags: z.array(z.enum(["VALUE", "ARRAY", "OBJECT", "FUNCTION", "CLASS"])),
  });

  const response = await this.openai.beta.chat.completions.parse({
      model: GPT_4_5,
      messages: [
        {
          role: "developer",
          content: "事前情報。ここにレビュワーのキャラなどをテキストで入れる。",
        },
        {
          role: "user",
          content: ”ここに依頼文”,
        },
      ],
      temperature: 1,
      max_tokens: 16384,
      top_p: 1,
      frequency_penalty: 0,
      presence_penalty: 0,
      response_format: zodResponseFormat(Question, "event"), // ここで型指定
    });

enumなども利用することができて、DBのschemaとあわせておくことで、レスポンスデータをそのままDBに保存でき、とても便利でした。

解答のコードレビュー(AI)

こちらもAIです。

コードレビュー結果もさまざまな情報が含まれるので、以下のように型を指定して、AIにリクエストします。

  private static CodeReview = z.object({
    result: z.enum(["APPROVED", "REJECTED"]),
    score: z.enum(scores),
    overview: z.string(),
    comments: z.array(
      z.object({
        targetCode: z.string(),
        level: z.enum(["GOOD", "WARN", "ERROR"]),
        message: z.string(),
      })
    ),
  });

commentsの箇所は、

  • 対象コード(targetCode

  • コメントの種類(level

  • そのコードに対するコメント(message

を、オブジェクトの配列で返していくれ、
実際のコードレビューの構造に近い形で実装することができました。

コードエディタ

JS Gymで使った開発技術|ShiftBブログ

monaco-editorという、VScodeの運営元でもあるMicrosoftのライブラリを用いたので、VScodeで書くのとほぼ同じ使い方ができるようになりました。

個人的に、command + Sでコードをフォーマットする癖が付いているので、
ゆくゆくは対応しようと思っています。

ターミナル

JS Gymで使った開発技術|ShiftBブログ

ユーザーが書いたコードを実行する手段として

  • フロントエンドで実行

  • バックエンドにコードを送って、バックエンドで実行

の2パターンがありますが、今回はフロントエンドで実行する形にしました。

本来、ユーザーが書いたコードをフロントでそのまま実行するのはかなり危ない(いろんな悪意のあるコードを実行できてしまう)のですが、

今回は、iframeを設置して、そのHTMLに書いた<script>タグ上で実行する形として、元サイトとは完全に独立した環境で実行できるようにして、セキュリティ面をカバーできました。

この方法はJavaScriptでしかできないので、JS Gymだからできたみたいなところもあります。

ただ、例えば無限ループ対応などができていないなど完璧ではないので、
ゆくゆくはサーバーサイドで実行する形に移行したいと思っています。

ちなみにコードレビューも、AIを使わない方法として、フロントエンドでjestを動かすなどして、動作確認もできるとのことです。

動的OGP画像生成

アプリの拡散戦略として、OGP画像は重要だと思い、βの段階から実装しました。

HP制作などでは当たり前にやることですが、OGPを各ページに設定しておくと、
問題ページのURLをLINEやSlcakで送ったり、XやThreadsでシェアした時に、問題タイトルや本文が差し込まれた画像が表示されます。

JS Gymで使った開発技術|ShiftBブログ

Next.jsでは、OGPを動的に生成する機能も標準装備されていて、
OGP画像もReact構文でUIを作ることができます。

実際のコードの一部抜粋

src/app/q/[questionId]/opengraph-image.tsx

export const runtime = "edge";

export const alt = "js gym question";
export const size = {
  width: 1200,
  height: 630,
};

export const metadata = buildMetaData({
  title: "JS Gym",
  path: "/q/[questionId]/opengraph-image",
  robots: {
    index: false,
  },
});

export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: Promise<{ questionId: string }>;
}) {
  const { questionId } = await params;
  const data = await api.get<QuestionResponse>(
    `${process.env.NEXT_PUBLIC_APP_BASE_URL}/api/questions/${questionId}`
  );

  return new ImageResponse(
    (
      <OgImage
        title={data.question.title}
        content={data.question.content}
        lessonId={data.question.lesson.id}
        courseId={data.question.lesson.course.id}
        reviewer={data.question.reviewer}
      />
    ),
    {
      ...size,
      fonts: [
        {
          name: "Noto sans jp",
          data: font,
          style: "normal",
          weight: 700,
        },
      ],
    }
  );
}

// 以下、OGP画像のコンポーネント
import React from "react";
import { courseTextMap, lessonStyleMap, lessonTextMap } from "@/app/_constants";

interface Props {
  title: string;
  content: string;
  lessonId: number;
  courseId: number;
  reviewer: {
    name: string;
    bio: string;
    profileImageUrl: string;
  };
}

export const OgImage: React.FC<Props> = ({
  title,
  content,
  lessonId,
  courseId,
  reviewer,
}: Props) => {
  return (
    <div
      style={{
        height: "100%",
        width: "100%",
        display: "flex",
        backgroundColor: "#BBBDC5",
        padding: "32px",
        fontFamily: "'Noto Sans JP', sans-serif",
        color: "#333333",
        position: "relative",
      }}
    >
      <div tw="bg-gray-50 flex rounded-3xl bg-white w-full flex flex-col justify-between pt-[48px] pb-[32px] px-[60px] relative overflow-hidden shadow-lg">
        <img
          src="https://shiftb.dev/images/fv_pc.png"
          alt="bg"
          tw="absolute inseet-0 object-contain w-screen"
        />
        <div tw="flex flex-col gap-4 items-start">
          <div tw="flex items-center justify-between w-full">
            <div tw="flex items-center">
              <div tw="text-[56px] mr-8 pb-4">
                {courseTextMap[courseId as keyof typeof courseTextMap]}
              </div>
              <div
                tw={`text-[48px] font-[700] px-8 rounded-full py-2 mb-4 text-white ${
                  lessonStyleMap[lessonId as keyof typeof lessonStyleMap]
                }`}
              >
                {lessonTextMap[lessonId as keyof typeof lessonTextMap]}
              </div>
            </div>
            <div tw="rounded-full fixed inset-0 flex items-center justify-center relative">
              <img
                src={`https://jsgym.shiftb.dev${reviewer.profileImageUrl}`}
                alt="reviewer"
                tw="w-full h-full rounded-full"
                width={100}
                height={100}
              />
            </div>
          </div>
          <p tw="text-[64px] font-[700]">{title}</p>
          <p tw="text-[32px] font-[400] text-gray-500">{content}</p>
        </div>
      </div>
      <span tw="text-[20px] absolute bottom-1 right-4 font-[700] text-gray-700">
        JS Gym
      </span>
    </div>
  );
};

opengraph-imageはエッジサーバー上で実行されるので、書き方が特殊な部分が多いです。例えば、tailwindを用いる際は、classNameは使えず、twプロパティに指定する必要があったりです。(ここまで対応してくれるNext.jsに感謝。。)

レビュワー(AI)

JS Gymで使った開発技術|ShiftBブログ

Reviewerテーブルに、レビュワーを登録すると、問題作成時にランダムにレビュワーがアサインされるようにしました。

レビューの質は変わらないですが、マーケ的な意味で
「推し活してたら、気づいたらJS書けるようになってた」
となってもらえたら最高だなと思ったためです。

AIではありますが、プロフィールを充実させてリアルな画像を用いていると、人間味を少し感じるなと、個人的には思いました。

「こんなレビュワーがいたら頑張れる」があれば、募集中ですので教えてください。

ダッシュボードページ、レビュワーランキングページ(AIエージェント活用)

ダッシュボードページレビュワーのランキングページも作りました。

重要な要素ではないと思い、デザインや詳細設計もすっ飛ばして、
AIエージェントに指示を出して作ってもらいました。

使ったツールはCursorのComposer機能のみで、プロンプトを投げると、
プロジェクト全体のコードを読み取って(schema.prismaも読み込むのでDB構造も理解)
必要ファイルの作成や、コマンドの実行まで全部やってくれます。

CursorのAI機能は、Cursorに直接課金するか、もしくはOpenAIのAPIキーを入力すれば定額課金0で、APIキーの従量課金のみで使えますが、Composerの機能に関してはCursorへの課金が必須となります。

途中で型エラーなどが発生しても、自動で検知して、自動で修正してくれます。

以下、ランキングページを作った際の、Composerに投げたプロンプトです。こんな感じで、修正0で実装してくれました。

レビュワー(Reviewer)毎の、コードレビューの数(CodeReviewテーブルのレコード数)をランキング表示するページを作成してください。
ページは、app/reviewer_ranking/page.tsx
APIは、app/api/reviewer_ranking
に作成してください。
データフェッチは @useFetch.ts を用いてください。
UIは、Tailwindのみで作成してください。

凝った機能を作ろうとするとやはりまだ難しかったり、エラーだらけで修正まではできなかったりで使えないですが、用途によっては積極的に使っていけそうです。

ぜひ、JS Gymを使ってみていただければと思います。
フィードバックもお待ちしています。

シェア!

Threads
icon
ぶべ
Webの修行中 / 個人開発奮闘中 / ベンチプレス110kg / Reactの先生
Loading...
記事一覧に戻る
Threads
0