JS Gymの開発で使った技術
投稿日: 2025年03月10日
JS Gymを公開しまして、ありがたいことに、現在300名以上の方に利用いただいております。
この記事では、開発で使った特徴的な技術について、いくつか紹介します。
(ベース機能はほとんど、茜TAが実装した内容です。)
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にリクエストします。
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
)
を、オブジェクトの配列で返していくれ、
実際のコードレビューの構造に近い形で実装することができました。
monaco-editorという、VScodeの運営元でもあるMicrosoftのライブラリを用いたので、VScodeで書くのとほぼ同じ使い方ができるようになりました。
個人的に、command + S
でコードをフォーマットする癖が付いているので、
ゆくゆくは対応しようと思っています。
ユーザーが書いたコードを実行する手段として
フロントエンドで実行
バックエンドにコードを送って、バックエンドで実行
の2パターンがありますが、今回はフロントエンドで実行する形にしました。
本来、ユーザーが書いたコードをフロントでそのまま実行するのはかなり危ない(いろんな悪意のあるコードを実行できてしまう)のですが、
今回は、iframeを設置して、そのHTMLに書いた<script>タグ上で実行する形として、元サイトとは完全に独立した環境で実行できるようにして、セキュリティ面をカバーできました。
この方法はJavaScriptでしかできないので、JS Gymだからできたみたいなところもあります。
ただ、例えば無限ループ対応などができていないなど完璧ではないので、
ゆくゆくはサーバーサイドで実行する形に移行したいと思っています。
ちなみにコードレビューも、AIを使わない方法として、フロントエンドでjestを動かすなどして、動作確認もできるとのことです。
アプリの拡散戦略として、OGP画像は重要だと思い、βの段階から実装しました。
HP制作などでは当たり前にやることですが、OGPを各ページに設定しておくと、
問題ページのURLをLINEやSlcakで送ったり、XやThreadsでシェアした時に、問題タイトルや本文が差し込まれた画像が表示されます。
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に感謝。。)
Reviewerテーブルに、レビュワーを登録すると、問題作成時にランダムにレビュワーがアサインされるようにしました。
レビューの質は変わらないですが、マーケ的な意味で
「推し活してたら、気づいたらJS書けるようになってた」
となってもらえたら最高だなと思ったためです。
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を使ってみていただければと思います。
フィードバックもお待ちしています。