メインコンテンツまでスキップ

プロジェクト: AI搭載のToDo管理アプリ

これまで学んできた技術を組み合わせて、AIを活用したToDo管理アプリを作成してみましょう。

ルール

  • タスクのタイトルと期限を入力し、「追加」ボタンをクリックすることで、タスクが追加できます
  • タスクの内容はデータベースに保存されます
  • 「AIで追加」ボタンをクリックすることで、「明日の10時に会議」などの自然言語でタスクを追加できます
  • 「削除」ボタンをクリックすることで、タスクを削除できます

ステップ1: セットアップ

まずは、プロジェクトのセットアップを行います。新しいフォルダを作成し、必要なパッケージのインストールとデータベースの準備をします。

プロジェクトの作成

npm init -y
npm install express @prisma/client
npm install -D prisma
npx prisma init

Prismaスキーマの定義

prisma/schema.prismaファイルを編集し、タスクを保存するためのテーブルを定義します。

prisma/schema.prisma
generator client {
provider = "prisma-client-js"
output = "../generated/prisma"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model Todo {
id Int @id @default(autoincrement())
title String
dueAt DateTime
}
DateTime型

PrismaのDateTime型は、日付と時刻の両方を保存できるデータ型です。PostgreSQLではtimestamp型に対応し、2024-01-21T10:00:00のような日時データを扱えます。JavaScriptではDateオブジェクトとして読み書きします。

.envファイルにデータベースの接続情報を記述した後、テーブルを作成します。

npx prisma db push

ステップ2: ToDoの読み取り

Expressサーバーを作成し、データベースからToDoを取得して一覧表示する機能を実装します。

サーバーの実装

main.mjs
import express from "express";
import { PrismaClient } from "./generated/prisma/index.js";

const app = express();
const client = new PrismaClient();

app.use(express.json());
app.use(express.static("./public"));

// ToDo一覧を取得
app.get("/todos", async (request, response) => {
const todos = await client.todo.findMany({ orderBy: { id: "asc" } });
response.json(todos);
});

app.listen(3000);

フロントエンドの実装

publicフォルダを作成し、HTMLとJavaScriptファイルを配置します。

public/index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>AI ToDo</title>
</head>
<body>
<h1>AI ToDo</h1>
<h2>ToDoリスト</h2>
<ul id="todo-list"></ul>
<script src="./script.js"></script>
</body>
</html>
public/script.js
const todoList = document.getElementById("todo-list");

loadTodos();

async function loadTodos() {
const response = await fetch("/todos");
const todos = await response.json();

todoList.innerHTML = "";

for (const todo of todos) {
const todoListItem = document.createElement("li");

const titleDiv = document.createElement("div");
titleDiv.textContent = todo.title;
todoListItem.appendChild(titleDiv);

const dueAtDiv = document.createElement("div");
dueAtDiv.textContent = new Date(todo.dueAt).toLocaleString();
todoListItem.appendChild(dueAtDiv);

todoList.appendChild(todoListItem);
}
}

node main.mjsサーバーを起動し、ブラウザで http://localhost:3000 にアクセスして、空のToDoリストが表示されることを確認しましょう。

ステップ3: ToDoの追加

ToDoを追加できるようにします。サーバーにPOSTリクエストを処理するエンドポイントを追加し、フロントエンドに入力フォームを実装します。

サーバーの実装

app.listen(3000)の前に、以下のエンドポイントを追加します。

main.mjs に追加
// ToDoを追加
app.post("/todos", async (request, response) => {
const todo = await client.todo.create({
data: {
title: request.body.title,
dueAt: new Date(request.body.dueAt),
},
});
response.json(todo);
});

フロントエンドの更新

HTMLにタスク入力フォームを追加します。

public/index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>AI ToDo</title>
</head>
<body>
<h1>AI ToDo</h1>
<h2>ToDoの追加</h2>
<div>
<input id="title-input" />
<input id="due-at-input" type="datetime-local" />
<button id="add-button" type="button">追加</button>
</div>
<h2>ToDoリスト</h2>
<ul id="todo-list"></ul>
<script src="./script.js"></script>
</body>
</html>

JavaScriptに追加ボタンの処理を実装します。

public/script.js
const titleInput = document.getElementById("title-input");
const dueAtInput = document.getElementById("due-at-input");
const addButton = document.getElementById("add-button");
const todoList = document.getElementById("todo-list");

loadTodos();

addButton.onclick = async () => {
await fetch("/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: titleInput.value, dueAt: dueAtInput.value }),
});
titleInput.value = "";
dueAtInput.value = "";
loadTodos();
};

async function loadTodos() {
const response = await fetch("/todos");
const todos = await response.json();

todoList.innerHTML = "";

for (const todo of todos) {
const todoListItem = document.createElement("li");

const titleDiv = document.createElement("div");
titleDiv.textContent = todo.title;
todoListItem.appendChild(titleDiv);

const dueAtDiv = document.createElement("div");
dueAtDiv.textContent = new Date(todo.dueAt).toLocaleString();
todoListItem.appendChild(dueAtDiv);

todoList.appendChild(todoListItem);
}
}

サーバーを再起動し、ブラウザでToDoを追加できることを確認しましょう。

ステップ4: ToDoの削除

各ToDoに「削除」ボタンを追加し、不要なToDoを削除できるようにします。

DELETEメソッドとルートパラメータ

HTTPには、GETやPOSTの他にDELETEメソッドがあり、リソースの削除に使用されます。Expressではapp.deleteメソッドでDELETEリクエストを処理できます。

パスに:idのようにコロンを付けた部分はルートパラメータと呼ばれ、URLの一部を変数として受け取ることができます。request.params.idでその値を取得できます。

サーバーの実装

main.mjs に追加
// ToDoを削除
app.delete("/todos/:id", async (request, response) => {
await client.todo.delete({
where: { id: parseInt(request.params.id) },
});
response.json({ success: true });
});

フロントエンドの更新

HTMLファイルの変更は不要です。削除ボタンはJavaScriptで動的に生成します。loadTodos関数を更新して、各ToDoに削除ボタンを追加します。

public/script.js の loadTodos 関数を更新
async function loadTodos() {
const response = await fetch("/todos");
const todos = await response.json();

todoList.innerHTML = "";

for (const todo of todos) {
const todoListItem = document.createElement("li");

const titleDiv = document.createElement("div");
titleDiv.textContent = todo.title;
todoListItem.appendChild(titleDiv);

const dueAtDiv = document.createElement("div");
dueAtDiv.textContent = new Date(todo.dueAt).toLocaleString();
todoListItem.appendChild(dueAtDiv);

const deleteButton = document.createElement("button");
deleteButton.textContent = "削除";
deleteButton.onclick = async () => {
await fetch(`/todos/${todo.id}`, { method: "DELETE" });
loadTodos();
};
todoListItem.appendChild(deleteButton);

todoList.appendChild(todoListItem);
}
}

追加したToDoの横に「削除」ボタンが表示され、クリックするとToDoが削除されることを確認しましょう。

OpenRouterへの登録

ここからは、AIを活用して「明日の10時に会議」などの自然言語からToDoを追加できるようにします。このステップでは、外部サービスのAPIを呼び出す方法を学びます。多くのWebサービスは、プログラムからアクセスできるAPIを提供しています。これらのAPIを活用することで、自分で実装するのが難しい高度な機能(AI、地図、決済など)を簡単にアプリケーションに組み込むことができます。

OpenRouterとは

OpenRouterは、様々なAIモデルを統一されたAPIで利用できるサービスです。OpenAIのGPT-4やAnthropicのClaudeなど、複数のモデルを同じ形式で呼び出すことができます。無料で使えるモデルも提供されています。

APIキーの取得

  1. OpenRouterにアクセス
  2. 右上の「Sign In」からGoogleアカウントなどでログイン
  3. ログイン後、Keysページにアクセス
  4. 「Create Key」ボタンをクリック
  5. キーの名前(任意)を入力して作成
  6. 表示されたAPIキー(sk-or-v1-...で始まる文字列)をコピー

APIキーの設定

生成したAPIキーを.envファイルに追加します。

.env
DATABASE_URL="postgresql://..."
OPENROUTER_API_KEY="sk-or-v1-..."
APIキーの管理

APIキーは機密情報です。絶対にGitにコミットしないでください。.gitignore.envが含まれていることを確認しましょう。

ステップ5: AI解析(サーバー側)

なぜサーバー経由で外部APIを呼び出すのか

外部サービスのAPIを利用する際、多くの場合APIキーによる認証が必要です。APIキーをブラウザ(フロントエンド)のJavaScriptに含めると、誰でもブラウザの開発者ツールからキーを取得できてしまいます。そのため、APIキーはサーバー側で管理し、サーバーから外部APIを呼び出すのが一般的なパターンです。

AI解析エンドポイントの実装

サーバー側でOpenRouter APIを呼び出すエンドポイントを追加します。

main.mjs に追加
app.post("/todos/ai", async (request, response) => {
const systemPrompt = `
ユーザーの入力から、ToDoのタイトルと期限を抽出してください。
1行目にタイトル、2行目に期限(ISO8601形式、タイムゾーンは東京)を出力してください。
現在日時: ${new Date().toISOString()}


現在日時: 2026-01-20T12:00:00+09:00
入力: 明日の10時に会議
出力: 会議
2026-01-21T10:00:00+09:00
`;

const result = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openrouter/free",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: request.body.instruction },
],
}),
});
const data = await result.json();
const content = data.choices[0].message.content;
const lines = content.split("\n");
const todo = await client.todo.create({
data: { title: lines[0], dueAt: new Date(lines[1]) },
});
response.json(todo);
});
認証ヘッダー Authorization: Bearer

多くの外部APIでは、AuthorizationヘッダーにBearer <APIキー>の形式でAPIキーを送信します。これはOAuth 2.0で定義された認証方式で、多くのWebサービスで採用されています。

プロンプト設計

AIに期待する出力形式を明確に指示することが重要です。この例では、改行区切りの2行形式(1行目がタイトル、2行目が期限)にすることで、プログラムから解析しやすくしています。

環境変数の読み込み

Node.jsで環境変数を読み込むため、起動コマンドを変更します。package.jsonscriptsを以下のように更新します。

package.json
{
"scripts": {
"start": "node --env-file=.env main.mjs"
}
}

これにより、npm startサーバーを起動すると、.envファイルの内容が環境変数として読み込まれます。

ステップ6: AI解析(フロントエンド)

最後に、フロントエンドにAI解析の入力欄とボタンを追加します。

HTMLの更新

public/index.html
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<title>AI ToDo</title>
</head>
<body>
<h1>AI ToDo</h1>
<h2>ToDoの追加</h2>
<div>
<input id="title-input" />
<input id="due-at-input" type="datetime-local" />
<button id="add-button" type="button">追加</button>
</div>
<h2>AIでToDoを追加</h2>
<div>
<input id="instruction-input" />
<button id="ai-button" type="button">AIで追加</button>
</div>
<h2>ToDoリスト</h2>
<ul id="todo-list"></ul>
<script src="./script.js"></script>
</body>
</html>

JavaScriptの更新

public/script.js
const titleInput = document.getElementById("title-input");
const dueAtInput = document.getElementById("due-at-input");
const addButton = document.getElementById("add-button");
const instructionInput = document.getElementById("instruction-input");
const aiButton = document.getElementById("ai-button");
const todoList = document.getElementById("todo-list");

loadTodos();

addButton.onclick = async () => {
await fetch("/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: titleInput.value, dueAt: dueAtInput.value }),
});
titleInput.value = "";
dueAtInput.value = "";
loadTodos();
};

aiButton.onclick = async () => {
await fetch("/todos/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: instructionInput.value }),
});
instructionInput.value = "";
loadTodos();
};

async function loadTodos() {
const response = await fetch("/todos");
const todos = await response.json();

todoList.innerHTML = "";

for (const todo of todos) {
const todoListItem = document.createElement("li");

const titleDiv = document.createElement("div");
titleDiv.textContent = todo.title;
todoListItem.appendChild(titleDiv);

const dueAtDiv = document.createElement("div");
dueAtDiv.textContent = new Date(todo.dueAt).toLocaleString();
todoListItem.appendChild(dueAtDiv);

const deleteButton = document.createElement("button");
deleteButton.textContent = "削除";
deleteButton.onclick = async () => {
await fetch(`/todos/${todo.id}`, { method: "DELETE" });
loadTodos();
};
todoListItem.appendChild(deleteButton);

todoList.appendChild(todoListItem);
}
}

完成版

全てのステップを統合した完成版です。