90
77

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【図解解説】AIエージェントを0から開発!基礎からできる初心者チュートリアル【VoltAgent/React/TypeScript】

Last updated at Posted at 2025-08-10

名称未設定のデザイン (18).png

はじめに

こんにちは、Watanabe Jin(@Sicut_study)です。

エンジニアをしていると「AI」という言葉を聞かない日はないくらいに日常に浸透し始めて、最近では「AIエージェント」という言葉すら一般的になりつつあります。

そしていま現場の最前線ではどこもAIエージェント開発が始まっており、いかに早くエージェントを作りサービスに利用できるかが今後生き抜く上で重要になってきているように思います。

CursorなどのAgentモードを利用したことある方は多いのかなと思いますが

「AI時代でもエンジニアとしての市場価値を上げていきたい!」

そう思うのであればAIエージェントを開発する経験は有利になります。
そんなAIエージェントをTypeScriptで開発できる「VoltAgent」というAIフレームワークが出たので、チュートリアルとしてじっくり学んでいきます。

 
AIエージェントの開発と聞いてどう思いましたか?

「自分には難しそう」
「AIに詳しくないとできなさそう」

そう思った人も多いのかなと思います。私もそうでした。
しかし、チュートリアルをやってみると魔法のように簡単にできることに驚くはずです。
LLM(生成AI)がうまいこと裏側でやってくれるので、AIエージェントを操るための考え方だけを押さえれば誰でもできるはずです。

このチュートリアルはReactを触ったことある人であれば2時間程度で行うことが可能です。

最後まで行うとエンジニアポートフォリオ自動生成サイトができます。

名称未設定のデザイン (4).gif

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください

対象者

  • AIエージェントに興味がある
  • AIの仕組みを知りたい
  • エンジニアとして市場価値を上げたい
  • モダンな技術を学びたい

このチュートリアルはHTMLとCSSとJavaScriptの経験があれば行うことが可能です

AIエージェントの仕組み

まずはそもそもAIエージェントって何?という人のために解説をしていきます。

AIエージェントとは、ユーザーが設定した目標に向けて自律的に計画を立て、実行し、環境に適応しながら行動するAIシステムです。

例えば、スマートフォンに音声のアシスタント(AIエージェント)があるとします。
「今日の会議の準備をして」と言うと、カレンダーをチェックして会議資料を探し、関連する連絡先に確認メールを送り、必要に応じてリマインダーを設定する。一連の作業を自動で判断して実行します。

image.png

ユーザーはAIエージェントに対して「今日の会議の準備をして」と指示をします。
このAIエージェントの達成すべき目標は「今日の経営会議の資料を全社員に送信すること」とします。

AIエージェントはまず「今日の会議を準備して」という支持を解釈します。
会議の準備をするということは「1. 今日の会議が何かを知る」「2. 会議の資料を取得する」「3. 会議資料を全社員にメールで送る」ことをすれば達成できると自分で考えて知ることになります。

  1. まずは日程エージェントに「今日の会議を教えて」と問い合わせをします。日程エージェントは今日の会議を知るためにGoogle CalendarのAPIを叩いて今日の予定をすべて取得して、会議らしいものを日程エージェントは判断して会議準備エージェントに返却します。

  2. 会議準備エージェントは今日の会議が「2025年4月25日の経営会議」ということをしれたので、次に資料検索エージェントに対して「2025年4月25日の経営会議の資料を探して」と指示します。資料検索エージェントはGoogle Drive APIを叩いて資料を取得しようとします。この会社では会議の資料は「YYYYMMDD_{会議名}資料.pdf」というルールで置かれているので資料検索エージェントは指示から「20250424_経営会議資料.pdf」という資料名を構築してAPIに対して問い合わせてデータを取得します。そして会議準備エージェントに返却します。

  3. 会議準備エージェントは資料を取得できたのでメールエージェントに対して「このPDFを全社員にメールで送信して」と指示します。メールエージェントは全社員というところからgmail APIを使って全員分のメールアドレスを取得します。そして全員に対してメール送信をするAPIを叩いて資料を送信します。
     

それぞれのエージェントには実装時に「そのエージェントがどんなことができるのか」の説明(instructions)を書きます。

const agent = new Agent({
  name: "日程エージェント",
  instructions: "あなたはGoogleカレンダーから今日の予定をすべて取得して最新の会議の情報を返してくれるエージェントです。",
  llm: new VercelAIProvider(),
  model: openai("gpt-4o"),
});

これは例ですが、例えば日程エージェントの場合はGoogleカレンダーから日程が取得できるという説明を書いているので会議準備エージェントが日程を取得したいとなったときには、説明文を読んで日程エージェントに会議の予定を取得することを任せることになります。

私達が直接指示をだす会議準備エージェントをエージェント、会議準備エージェントが指示をするエージェントをサブエージェントとよびます。

Function Calling

AIエージェント開発でFunction Callingの考え方は重要になるので解説をしておきます。
Function CallingとはAIが外部のツールやシステムと連携するための仕組みです。

これまでのLLMはユーザーからの質問に「答えるだけ」でしたが、Function Callingを利用することで「行動する」ことができるようになったのです。

先程の例で「今日の会議の準備をして」という質問から日程エージェントを実行するところまでを考えてみます。

image.png

会議準備エージェントは「今日の会議の準備をして」という質問とサブエージェントのリスト(日程エージェント、資料検索エージェント、メールエージェント)を会議準備エージェントのウラ側にいるLLMに渡します。

LLMはサブエージェントのリストから質問(目標)を達成するために必要なことを考えます。最初に必要なのは今日の会議の情報になるので、「日程エージェントに今日(2025/04/24)の予定から会議の情報を教えて」とエージェントに指示すればいいよとFunction Callingで返ってきます。

そして会議準備エージェントは「2025年4月24日の会議の情報を教えて」と日程エージェントに指示をだします。

このようにLLMに対してサブエージェントの一覧と質問を渡して次の行動を決めさせるのがエージェント開発において重要となってきます。

VoltAgentとは?

image.png

VoltAgentはTypeScriptでAIエージェントを開発できるAIフレームワークです。
簡単にAIエージェントを開発できるだけでなく、ChatGPTやGeminiなどAIを変更したいとしても簡単に切り替えることができるためプロバイダーに依存しない開発が可能です。

個人的にVoltAgentがおすすめできる理由はコンソールを使うことでAIエージェントが何をしているのかを可視化して分析やデバックができることです。AIは中で何を行っているのかがわかりづらく困ることが多いのでエージェント開発にはとても便利な機能です。

image.png

TypeScriptのAIエージェントフレームワークには「Mastra」というのもあります。
しかし、Mastraは自動化に着目しており、VoltAgentは、「ブラックボックス」問題を解決するために構築されています。

今回開発するアプリについて

今回はエージェントやサブエージェント、ツール(後述)を駆使してエンジニアのポートフォリオを自動生成してくれるツールを作成したいと思います。

名称未設定のデザイン (4).gif

ユーザーはQiitaGitHubのユーザーIDを入力します。

image.png

  1. ポートフォリオエージェントはQiitaサブエージェントにユーザーIDを渡して、Qiitaのユーザー情報や記事の情報を取得してJSON形式でポートフォリオエージェントに返します。

  2. 次にポートフォリオエージェントはGitHubサブエージェントにユーザーIDを渡して、GitHubのユーザー情報やリポジトリの情報を取得してJSON形式でポートフォリオエージェントに返します。

  3. 最後にQiitaサブエージェントとGitHubサブエージェントで取得したデータをすべてレポートエージェントになげていい感じにLLMにポートフォリオとして整えてもらってマークダウンでポートフォリオエージェントに返します。

これでポートフォリオエージェントからはQiitaとGitHubの情報から作られたポートフォリオをマークダウンで返せるようになるので、画面でいい感じに表示します。

ここでポイントとなってくるのは、最初の私達が指示するプロンプトから「サブエージェントにアカウントIDを渡して情報を取得すること」と「サブエージェントが集めてきたQiitaとGitHubの情報をレポートエージェントに渡せるか」がポイントになってきます。

ポートフォリオエージェントにこのような流れで指示をだすように設定してあげないといけないので、ここは実装中に意識してやっていきましょう

1. 環境構築

それではVoltAgentを使ってAIエージェントを開発するところから始めていきましょう
まずはNode.jsがインストールするところからチェックしていきます。

$ node -v
v24.3.0

今回は最新版で行っていきます。
Node.jsがインストールできていない人は以下のサイトからご自身のOSにあったものをダウンロードしてください。

VoltAgentの開発環境は以下のドキュメントを読みながら行います。

$ mkdir portfolio-agent
$ cd portforio-agent

$ npm create voltagent-app@latest agent
ℹ Installing base dependencies (this may take a minute)...
✓ Base dependencies installed successfully! 📦
? Which AI provider would you like to use? OpenAI (GPT-4o-mini)

ℹ OpenAI requires an API key to function.
ℹ Get your API key at: https://platform.openai.com/api-keys

? Would you like to enter your OpenAI API key now? No

OpenAIを選んでキー設定はnを選択しました

$ cd agent
$ npm i
$ npm run dev

http://localhost:3141/を開いて以下のような画面がでてきたらVoltAgentの環境構築は完了です。(初回はGitHubでのログインが必要になるので各自してください)

image.png

次にReactの環境構築を行います。Viteを使って行いましょう。

$ cd .. // portfolio-agentにいく
$ npm create vite@latest

◇  Project name:
│  client
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript

$ cd client
$ npm i
$ npm run dev

http://localhost:5173/にアクセスして以下の画面がでればReactの環境構築はできています。

image.png

それではVSCodeでclientを開いてください。
次にスタイリングに利用するTailwindCSSShadcn/uiを入れたいと思います。

$ npm install tailwindcss @tailwindcss/vite

vite.config.tsを以下にします。

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

src/index.cssを以下にします。

src/index.css
@import "tailwindcss";

TailwindCSSが正しく導入できているかをチェックします。src/App.tsxを修正します。

src/App.tsx
function App() {
  return (
    <div>
      <button className="bg-blue-500 text-white px-4 py-2 rounded">
        Click me
      </button>
    </div>
  );
}

export default App;

image.png

ボタンが表示されれば導入は正しくできています。

次にShadcn/uiをインストールしていきます。

まずはtsconfig.jsonを以下にします。

tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

tsconfig.app.jsonを以下にします。

tsconfig.app.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"],

  "paths": {
    "@/*": ["./src/*"]
  }
}

必要な型をインストールしてvite.config.tsをもう一度修正します。

$ npm i -D @types/node
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

それでは初期設定ができたのでshadcn/uiをインストールします。

$ npx --legacy-deps shadcn@latest init
✔ Which color would you like to use as the base color? › Neutral

$ npx --legacy-deps shadcn@latest add button // ボタンを試しに追加

それでは先程のTailwindCSSでスタイリングしたボタンをShadcn/uiのボタンに変更します。

src/App.tsx
import { Button } from "./components/ui/button";

function App() {
  return (
    <div>
      <Button>Click me</Button>
    </div>
  );
}

export default App;

サーバーを再起動させて、このようなボタンが表示されたら導入完了です。

image.png

2. Qiitaエージェントの開発

まずはユーザーがQiitaのIDを渡してQiitaから「ユーザー情報」と「記事情報」を取得するエージェントを作成しましょう。

作成したagentディレクトリをVSCodeなどのエディタで開いてください。
まずはChatGPTを利用するためにAPIキーを取得します。

ChatGPTのAPIを利用するには500円程度の最低限のクレジットが必要になります。
このチュートリアルではほとんど料金は発生しませので、クレジットがない方は最低限のチャージをして先に進んでください。

ここではすでにOpenAIのアカウントがあることを前提に進めていきます。
右上の歯車マークをクリックします。

image.png

左メニューから「API Keys」をクリック

image.png

右上の「Create new secret key」をクリック

image.png

Nameにportforio-agentと入力して「Create secret key」をクリック

image.png

APIキーが発行されるのでコピーしてください

image.png

VSCodeを開いて、agentディレクトリ配下に.envを作成して環境変数として設定しましょう

$ touch .env // agentディレクトリにcdしておく
.env
OPENAI_API_KEY=あなたのAPIキーをはる

ではQiitaからまずは「ユーザー情報」を集めるエージェントを作ってみましょう。
src/index.tsを修正します。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const getQiitaUserInfo = createTool({
  name: "getQiitaUserInfo",
  description: "Qiitaユーザーの情報を取得する",
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
    const response = await fetch(`https://qiita.com/api/v2/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const data = await response.json();
    return data;
  },
});

const agent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、ユーザーの情報を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
  ]
});

new VoltAgent({
  agents: {
    agent,
  },
}); 

解説をしていきます。
まずはQiitaエージェント自体の作成をします。

const agent = new Agent({
  (ここにエージェントの設定を書く)
});

エージェントには以下の設定をする必要があります。

引数 説明
name エージェントの識別名。ここでは"qiita-agent"として、Qiitaユーザー情報を取得するエージェントであることを意味している
instructions エージェントの動作指示。日本語でユーザーからQiitaユーザーIDを受け取り、そのユーザー情報を取得するよう指示
parameters 入力パラメータのスキーマ定義。Zodライブラリを使用してuserId(文字列型)を必須パラメータとして定義
llm 使用するLLM(大規模言語モデル)プロバイダー。Vercel AIのプロバイダーインスタンスを指定
model 具体的なAIモデル。OpenAIのGPT-4o-miniモデルを使用することを指定
tools Arrayエージェントが使用できるツール(関数)の配列。getQiitaUserInfo関数を利用可能なツールとして登録
const agent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、ユーザーの情報を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
  ]
});

ツールに関してはこのあと詳しく説明します。

モデルについて

今回はモデルにChatGPTの4o-miniを利用しています。
4o-miniなどoシリーズと言われるモデルはthinkingモデルと言われます。

thinkingモデルとはモデル自身が質問に答える前に長時間(複数回)自分で考えるChain of Thought(思考の連鎖)を行っています。

モデル自身が深く考えられることは、エージェント開発において「どの順番でサブエージェントを呼び出すか」「どの順番でツールを呼び出すか」「APIなど叩いて失敗したときに代替え手段を考慮する」「複数ツールの結果を統合する際の論理的思考」などが備わっています。

thinkingモデルを利用することはエージェント開発において重要になってきます。

ツールについて

ツールとは、エージェントが外部システムやAPIと連携するための具体的な機能を定義したものです。エージェント自体は言語処理に特化しているため、実際のデータ取得や操作はツールを通じて行います。

今回はgetQiitaUserInfoがツールに当たります。このツールではQiita APIを叩いてユーザー情報を取得してくれます。

const getQiitaUserInfo = createTool({
 (プロパティ)
});

ツールを作成するにはcreateToolを呼び出す必要があります。

プロパティ 説明
name ツールの一意識別子。エージェントがこの名前でツールを呼び出す
description ツールの機能説明。エージェントがいつこのツールを使うべきかを判断する材料
parameters 入力パラメータのスキーマ定義(Zodバリデーション)
execute 実際の処理を行う非同期関数
const getQiitaUserInfo = createTool({
  name: "getQiitaUserInfo",
  description: "Qiitaユーザーの情報を取得する",
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  execute: async ({ userId }) => {
    (ツールの処理)
  }
});

ツールの中ではAPIを叩いてレスポンスを返す単純な処理を行っています。

  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
    const response = await fetch(`https://qiita.com/api/v2/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const data = await response.json();
    return data;
  },

エンドポイントはQiita APIのドキュメントに詳細な説明が書かれています。

このエージェントを動かすにはQiita APIを立たけるようにしないといけないのでAPIキーを取得しましょう

Qiitaにアクセスして歯車マークから「設定」をクリック

image.png

左メニューから「アプリケーション」をクリック
個人用アクセストークンの「新しいトークンを発行する」をクリック

image.png

アクセストークンの説明に「portforio-agent」と入力して「発行する」をクリック

image.png

表示されたアクセストークンをコピーして.envに貼り付けます

image.png

.env
OPENAI_API_KEY=あなたのOPENAI_API_KEY
QIITA_API_KEY=あなたのQIITA_API_KEY

それでは実際にエージェントができたので実行してみましょう。

$ npm run dev // agentディレクトリで

http://localhost:3141/を開いて「Go to VoltOps Platform」をクリック

image.png

今回作成した「qiita-agent」をクリックします。

image.png

するとVoltAgentの特徴であるモニタリング画面が開きます。
ここで実際にプロンプトを実行してエージェントの動きなどをみることができます。
下にある「Test Workflow」というボタンをクリックしてプロンプトを実行します。

Qiita ID: Sicut_study

image.png

IDはご自身のものを入れていただいても大丈夫です。
すると以下のようにQiitaから情報を取得することができました。

image.png

まずは「Qiita ID : Sicut_study」というプロンプトをQiitaエージェントに送りました。
Qiita Agentの裏にいるChatGPTはこのエージェントがツールgetQiitaUserInfoの情報(IDを受け取ればユーザー情報を返せる)とプロンプトを受け取ります。そしてFunction Callingにより、プロンプトにあるユーザーIDをgetQiitaUserInfoのuserIdとして渡してツールを実行すればよいことを教えてくれます。

そしてQiitaエージェントはgetQiitaUserInfoをSicut_studyという引数で実行しました。

AIは勝手に思考して行ってくれるのでこちらは簡単な実装でエージェント開発ができてしまったことがわかります。意図を勝手に汲み取ってよしなに行動してくれるのがエージェントのすごいところです。

3. Qiitaから記事を取得する

それではツールを1つ増やしてQiitaから記事を取得するようにしましょう
toolsの配列に新しいツールを追加するだけなので簡単です!

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const getQiitaUserInfo = createTool({
  name: "getQiitaUserInfo",
  description: "Qiitaユーザーの情報を取得する",
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
    const response = await fetch(`https://qiita.com/api/v2/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const data = await response.json();
    return data;
  },
});

// 追加
const getQiitaUserItems = createTool({
  name: "getQiitaUserItems",
  description: "QiitaユーザーIDを指定して、そのユーザーの投稿記事一覧を取得します。最大30件まで取得可能です。記事のタイトル、URL、いいね数、ストック数、閲覧数、タグ、作成日を返します。",
  parameters: z.object({
    userId: z.string().describe("記事一覧を取得したいQiitaユーザーのID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
      const response = await fetch(`https://qiita.com/api/v2/users/${userId}/items?per_page=30`, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });

    return await response.json();
  },
});


const agent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、Qiitaユーザーの情報と投稿記事一覧を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
    getQiitaUserItems,
  ]
});

new VoltAgent({
  agents: {
    agent,
  },
}); 

QiitaのAPIを叩いてそのままレスポンスを返すようにしました
今回は記事を30個用意するためにper_pageは30に設定しています。

それではエージェントを実行してみましょう

ここからは新しいプロンプトを入力するときTest Workflowを開いて右上にある+Newをクリックしてから行いましょう

Qiita ID: Sicut_study

すると以下のようなエラーが発生します。

image.png

これはOpenAIのAPI(gpt-4o-mini)に送信した「messages」の合計トークン数が、モデルの最大コンテキスト長(128,000トークン)を大幅に超えているために発生しています。

なぜ起きるのかはQiita APIの記事情報取得をCurlで叩くとわかります。

$ curl -H "Authorization: Bearer あなたのトークン"   "https://qiita.com/api/v2/users/qiita/items?per_page=1"
[{"rendered_body":"\u003cp data-sourcepos=\"1:1-1:117\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F35f155bc-68a7-488a-b8d3-fe030ff993b7.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=6c8f7226cb4eb601e8ae3f7298e0284b\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F35f155bc-68a7-488a-b8d3-fe030ff993b7.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=6c8f7226cb4eb601e8ae3f7298e0284b\" alt=\"image.png\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F35f155bc-68a7-488a-b8d3-fe030ff993b7.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=357527f3e2e371295ad71e5ef36d71a6 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/35f155bc-68a7-488a-b8d3-fe030ff993b7.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"4:1-4:19\"\u003e\n\u003cspan id=\"はじめに\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003eはじめに\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"5:1-6:116\"\u003eQiitaでは、エンジニアのみなさんからの声をもとに、日々開発を続けています。\u003cbr\u003e\nこの記事では、2025年5月にリリースした機能や最新のお知らせについてご紹介します。\u003c/p\u003e\n\u003cp data-sourcepos=\"8:1-8:249\"\u003eQiitaでアップデートやバグ修正をリリースしたら、\u003ca href=\"https://qiita.com/release-notes\"\u003eリリースノート\u003c/a\u003e、\u003ca href=\"https://twitter.com/Qiita\" rel=\"nofollow noopener\" target=\"_blank\"\u003eQiita 公式 Twitter\u003c/a\u003e、\u003ca href=\"https://blog.qiita.com/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eQiita Blog\u003c/a\u003eでお知らせしています。\u003c/p\u003e\n\u003cp data-sourcepos=\"10:1-10:170\"\u003eまた、Qiitaへの質問や機能の要望などがありましたら、\u003ca href=\"https://github.com/increments/qiita-discussions\" rel=\"nofollow noopener\" target=\"_blank\"\u003eQiita Discussions\u003c/a\u003e へご投稿ください。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"12:1-12:61\"\u003e\n\u003cspan id=\"markdown-見出しの読み上げを改善しました\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#markdown-%E8%A6%8B%E5%87%BA%E3%81%97%E3%81%AE%E8%AA%AD%E3%81%BF%E4%B8%8A%E3%81%92%E3%82%92%E6%94%B9%E5%96%84%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003eMarkdown 見出しの読み上げを改善しました\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"13:1-15:37\"\u003e\u003cem\u003e2025年5月01日 リリース\u003c/em\u003e\u003cbr\u003e\n\u003cem\u003e2025年5月07日 一部記事が正しく表示されなかったため、一時的にリリース前の状態に戻しました\u003c/em\u003e\u003cbr\u003e\n\u003cem\u003e2025年5月15日 再度リリース\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"17:1-17:155\"\u003eアクセシビリティの向上とユーザーエクスペリエンスを改善するため、Markdownの見出しの読み上げを改善しました。\u003c/p\u003e\n\u003cul data-sourcepos=\"19:1-24:0\"\u003e\n\u003cli data-sourcepos=\"19:1-24:0\"\u003e変更したこと\n\u003cul data-sourcepos=\"20:5-24:0\"\u003e\n\u003cli data-sourcepos=\"20:5-20:69\"\u003e見出しへのリンクをデフォルトで非表示に変更\u003c/li\u003e\n\u003cli data-sourcepos=\"21:5-21:75\"\u003eホバー時のみリンクアイコンを表示するように変更\u003c/li\u003e\n\u003cli data-sourcepos=\"22:5-24:0\"\u003eリンクアイコンのスタイルを改善\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 data-sourcepos=\"25:1-25:88\"\u003e\n\u003cspan id=\"通知一覧にいいねストックカテゴリーを追加しました\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E9%80%9A%E7%9F%A5%E4%B8%80%E8%A6%A7%E3%81%AB%E3%81%84%E3%81%84%E3%81%AD%E3%82%B9%E3%83%88%E3%83%83%E3%82%AF%E3%82%AB%E3%83%86%E3%82%B4%E3%83%AA%E3%83%BC%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003e通知一覧に「いいね・ストック」カテゴリーを追加しました\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"26:1-26:31\"\u003e\u003cem\u003e2025年5月12日 リリース\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"28:1-28:195\"\u003eQiita の通知機能がさらに便利になりました。通知一覧で「いいね」と「ストック」に関する通知のみを絞り込んで表示できるようになりました。\u003c/p\u003e\n\u003cp data-sourcepos=\"30:1-30:99\"\u003e通知一覧は\u003ca href=\"https://qiita.com/notifications\"\u003eこちら\u003c/a\u003eからアクセスいただけます🔔\u003c/p\u003e\n\u003cp data-sourcepos=\"33:1-33:117\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fa7ea0d7c-6666-4e0f-9d41-81cabedd4d63.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=9b5b488a4bcb81787f844d5aeb4dd58a\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fa7ea0d7c-6666-4e0f-9d41-81cabedd4d63.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=9b5b488a4bcb81787f844d5aeb4dd58a\" alt=\"image.png\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fa7ea0d7c-6666-4e0f-9d41-81cabedd4d63.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=77f929c7e17d5045286d1079effdbfbd 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/a7ea0d7c-6666-4e0f-9d41-81cabedd4d63.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"38:1-38:99\"\u003e\n\u003cspan id=\"記事編集履歴ページにmarkdownを閲覧コピーする機能を追加しました\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E8%A8%98%E4%BA%8B%E7%B7%A8%E9%9B%86%E5%B1%A5%E6%AD%B4%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%ABmarkdown%E3%82%92%E9%96%B2%E8%A6%A7%E3%82%B3%E3%83%94%E3%83%BC%E3%81%99%E3%82%8B%E6%A9%9F%E8%83%BD%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003e記事編集履歴ページにMarkdownを閲覧、コピーする機能を追加しました\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"39:1-39:31\"\u003e\u003cem\u003e2025年5月16日 リリース\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"41:1-41:134\"\u003e記事編集履歴から記事を復元しやすくするために、Markdownを閲覧、コピーする機能を追加しました。\u003c/p\u003e\n\u003cp data-sourcepos=\"43:1-43:117\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F9af56c45-5ae1-4a96-865e-e8cc22ed923a.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=3e3d6b466544a085862e26c191a881a6\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F9af56c45-5ae1-4a96-865e-e8cc22ed923a.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=3e3d6b466544a085862e26c191a881a6\" alt=\"image.png\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F9af56c45-5ae1-4a96-865e-e8cc22ed923a.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=3259c81a07165cde04ad5d2c4580b1c0 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/9af56c45-5ae1-4a96-865e-e8cc22ed923a.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"46:1-47:178\"\u003e「Markdown」タブを押すことによりMarkdownを閲覧することができるようになりました。\u003cbr\u003e\n「このバージョンのMarkdownをコピーする」ボタンを押すことで表示しているバージョンのMarkdownのコピーができるようになりました。\u003c/p\u003e\n\u003ch2 data-sourcepos=\"49:1-49:115\"\u003e\n\u003cspan id=\"ストックリスト作成編集フォームでタグ概要を設定できるようになりました\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%B9%E3%83%88%E3%83%83%E3%82%AF%E3%83%AA%E3%82%B9%E3%83%88%E4%BD%9C%E6%88%90%E7%B7%A8%E9%9B%86%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E3%81%A7%E3%82%BF%E3%82%B0%E6%A6%82%E8%A6%81%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%A7%E3%81%8D%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%AA%E3%82%8A%E3%81%BE%E3%81%97%E3%81%9F\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003eストックリスト作成・編集フォームでタグ・概要を設定できるようになりました\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"50:1-50:31\"\u003e\u003cem\u003e2025年5月30日 リリース\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"52:1-52:129\"\u003eストックリストを詳細に説明できるよう、「タグ」と「概要」を設定できるようになりました。\u003c/p\u003e\n\u003cul data-sourcepos=\"54:1-57:0\"\u003e\n\u003cli data-sourcepos=\"54:1-54:84\"\u003eタグは最大5つまで、1つのタグは最大30文字まで設定できます\u003c/li\u003e\n\u003cli data-sourcepos=\"55:1-55:71\"\u003e公開ストックリストの場合はタグの入力が必須です\u003c/li\u003e\n\u003cli data-sourcepos=\"56:1-57:0\"\u003e概要は最大200文字まで入力できます\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"58:1-58:114\"\u003eこれにより、ストックリストの内容や特徴が他のユーザーに伝わりやすくなります!\u003c/p\u003e\n\u003cp data-sourcepos=\"60:1-61:34\"\u003eストックのシェア機能はベータ版として提供中です 📣\u003cbr\u003e\nぜひ、ご活用ください ✨\u003c/p\u003e\n\u003cp data-sourcepos=\"63:1-63:117\"\u003e\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F77b64d54-6894-4613-bb1b-cb3e4b9debc5.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=1cf3ee82fb026c40c166916bf7b9a3ef\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F77b64d54-6894-4613-bb1b-cb3e4b9debc5.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=1cf3ee82fb026c40c166916bf7b9a3ef\" alt=\"image.png\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2F77b64d54-6894-4613-bb1b-cb3e4b9debc5.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=96e1f9157434713f5aacfe27b0726c00 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/77b64d54-6894-4613-bb1b-cb3e4b9debc5.png\" loading=\"lazy\"\u003e\u003c/a\u003e\u003c/p\u003e\n\u003ch2 data-sourcepos=\"67:1-67:76\"\u003e\n\u003cspan id=\"ストックのシェア機能をベータ版として提供中です\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#%E3%82%B9%E3%83%88%E3%83%83%E3%82%AF%E3%81%AE%E3%82%B7%E3%82%A7%E3%82%A2%E6%A9%9F%E8%83%BD%E3%82%92%E3%83%99%E3%83%BC%E3%82%BF%E7%89%88%E3%81%A8%E3%81%97%E3%81%A6%E6%8F%90%E4%BE%9B%E4%B8%AD%E3%81%A7%E3%81%99\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003eストックのシェア機能をベータ版として提供中です\u003c/strong\u003e\n\u003c/h2\u003e\n\u003ca href=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fdf98ac65-6764-40e1-97cd-14b9fd42b36e.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=386adbf3c6cd71d096852688312a3093\" target=\"_blank\" rel=\"nofollow noopener\"\u003e\u003cimg src=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fdf98ac65-6764-40e1-97cd-14b9fd42b36e.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;s=386adbf3c6cd71d096852688312a3093\" width=\"100%\" alt=\"\" srcset=\"https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F88%2Fdf98ac65-6764-40e1-97cd-14b9fd42b36e.png?ixlib=rb-4.0.0\u0026amp;auto=format\u0026amp;gif-q=60\u0026amp;q=75\u0026amp;w=1400\u0026amp;fit=max\u0026amp;s=4627c5d16363accc617939fae4d21bad 1x\" data-canonical-src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/df98ac65-6764-40e1-97cd-14b9fd42b36e.png\" loading=\"lazy\"\u003e\u003c/a\u003e\n\u003cp data-sourcepos=\"70:1-70:30\"\u003e\u003cem\u003e2025年3月27日リリース\u003c/em\u003e\u003c/p\u003e\n\u003cp data-sourcepos=\"72:1-73:292\"\u003eこれまでQiitaのストックは、記事を個人で保存し、活用するための機能として提供してきました。\u003cbr\u003e\nしかし、「ストックを共有したい」「シリーズものの記事をまとめて紹介したい」 といったご要望を、ユーザーヒアリングや\u003ca href=\"https://github.com/increments/qiita-discussions\" rel=\"nofollow noopener\" target=\"_blank\"\u003eQiita Discussions\u003c/a\u003e などを通じて多くいただいていました。\u003c/p\u003e\n\u003cp data-sourcepos=\"75:1-75:105\"\u003eそこで今回、ストックした記事を簡単にシェアできる新機能を追加しました!\u003c/p\u003e\n\u003cp data-sourcepos=\"77:1-77:100\"\u003e「\u003cstrong\u003eストックのシェア機能\u003c/strong\u003e」を使うことで以下のようなことができます。\u003c/p\u003e\n\u003cul data-sourcepos=\"79:1-82:0\"\u003e\n\u003cli data-sourcepos=\"79:1-79:65\"\u003eシリーズものの記事や関連記事をまとめて公開\u003c/li\u003e\n\u003cli data-sourcepos=\"80:1-80:53\"\u003e役立つ記事をストックし、仲間と共有\u003c/li\u003e\n\u003cli data-sourcepos=\"81:1-82:0\"\u003e「これは読んでおくべき!」という記事を、より多くの人に勧められる\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp data-sourcepos=\"83:1-85:87\"\u003e新機能を活用すれば、より多くの人に価値ある記事を届けることができます。\u003cbr\u003e\nQiitaは、記事がより多くの人に届き、知見が広がることで、技術コミュニティがさらに発展すると考えています。\u003cbr\u003e\nまずは気になる記事をストックし、シェアしてみてください!✨\u003c/p\u003e\n\u003cp data-sourcepos=\"87:1-87:138\"\u003eストックのシェア機能の詳しい使い方は、\u003ca href=\"https://blog.qiita.com/stock-share-beta/\" rel=\"nofollow noopener\" target=\"_blank\"\u003eQiita Blog\u003c/a\u003eで紹介しています。\u003c/p\u003e\n\u003cp data-sourcepos=\"89:1-89:40\"\u003e\u003ciframe id=\"qiita-embed-content__17202ecbfcfdb274543e517f3d0e82c5\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__17202ecbfcfdb274543e517f3d0e82c5\" data-content=\"https%3A%2F%2Fblog.qiita.com%2Fstock-share-beta%2F\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n\u003ch2 data-sourcepos=\"92:1-92:41\"\u003e\n\u003cspan id=\"qiita-tech-festa-2025-開催中\" class=\"fragment\"\u003e\u003c/span\u003e\u003ca href=\"#qiita-tech-festa-2025-%E9%96%8B%E5%82%AC%E4%B8%AD\"\u003e\u003ci class=\"fa fa-link\"\u003e\u003c/i\u003e\u003c/a\u003e\u003cstrong\u003eQiita Tech Festa 2025 開催中!\u003c/strong\u003e\n\u003c/h2\u003e\n\u003cp data-sourcepos=\"93:1-93:122\"\u003e毎年夏に開催していた「Qiita Engineer Festa」をリニューアルした「Qiita Tech Festa」が開催中 📣\u003c/p\u003e\n\u003cp data-sourcepos=\"95:1-97:70\"\u003e6月17日から、記事投稿キャンペーンが始まります!\u003cbr\u003e\n様々な記事投稿テーマをご用意しておりますので、まずは参加登録から💁‍♂️\u003cbr\u003e\n素敵な記事された方にはQiitaグッズをGetできるかも!?\u003c/p\u003e\n\u003cp data-sourcepos=\"103:1-103:38\"\u003e投稿テーマ一覧はこちら 🔽\u003c/p\u003e\n\u003cp data-sourcepos=\"105:1-105:45\"\u003e\u003ciframe id=\"qiita-embed-content__c1eb57d34a0a0c64903bc82b22740696\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__c1eb57d34a0a0c64903bc82b22740696\" data-content=\"https%3A%2F%2Fqiita.com%2Ftech-festa%2F2025%2Ftech-sprint\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n\u003cp data-sourcepos=\"108:1-110:99\"\u003e7月25日に開催する「技術に特化した話が聞けるライブ配信イベント」も参加申し込みを受付中 📣\u003cbr\u003e\n基調講演、スポンサーセッション、Qiita ユーザーによるセッションなど必聴です!\u003cbr\u003e\nまた、登壇したい方も募集中です!詳細から応募概要をご確認ください♫\u003c/p\u003e\n\u003cp data-sourcepos=\"112:1-112:24\"\u003e詳細はこちら  🔽\u003c/p\u003e\n\u003cp data-sourcepos=\"114:1-114:44\"\u003e\u003ciframe id=\"qiita-embed-content__d7a215f04893d434236f2414f0f57782\" src=\"https://qiita.com/embed-contents/link-card#qiita-embed-content__d7a215f04893d434236f2414f0f57782\" data-content=\"https%3A%2F%2Fqiita.com%2Ftech-festa%2F2025%2Ftech-spark\" frameborder=\"0\" scrolling=\"no\" loading=\"lazy\" style=\"width:100%;\" height=\"29\"\u003e\n\u003c/iframe\u003e\n\u003c/p\u003e\n","body":"![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/35f155bc-68a7-488a-b8d3-fe030ff993b7.png)\n\n\n## **はじめに**\nQiitaでは、エンジニアのみなさんからの声をもとに、日々開発を続けています。\nこの記事では、2025年5月にリリースした機能や最新のお知らせについてご紹介します。\n\nQiitaでアップデートやバグ修正をリリースしたら、[リリースノート](https://qiita.com/release-notes)、[Qiita 公式 Twitter](https://twitter.com/Qiita)、[Qiita Blog](https://blog.qiita.com/)でお知らせしています。\n\nまた、Qiitaへの質問や機能の要望などがありましたら、[Qiita Discussions](https://github.com/increments/qiita-discussions) へご投稿ください。\n\n## **Markdown 見出しの読み上げを改善しました**\n*2025年5月01日 リリース*\n*2025年5月07日 一部記事が正しく表示されなかったため、一時的にリリース前の状態に戻しました*\n*2025年5月15日 再度リリース*\n\nアクセシビリティの向上とユーザーエクスペリエンスを改善するため、Markdownの見出しの読み上げを改善しました。\n\n- 変更したこと\n    - 見出しへのリンクをデフォルトで非表示に変更\n    - ホバー時のみリンクアイコンを表示するように変更\n    - リンクアイコンのスタイルを改善\n\n\n## **通知一覧に「いいね・ストック」カテゴリーを追加しました**\n*2025年5月12日 リリース*\n\nQiita の通知機能がさらに便利になりました。通知一覧で「いいね」と「ストック」に関する通知のみを絞り込んで表示できるようになりました。\n\n通知一覧は[こちら](https://qiita.com/notifications)からアクセスいただけます🔔\n\n\n![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/a7ea0d7c-6666-4e0f-9d41-81cabedd4d63.png)\n\n\n\n\n## **記事編集履歴ページにMarkdownを閲覧、コピーする機能を追加しました**\n*2025年5月16日 リリース*\n\n記事編集履歴から記事を復元しやすくするために、Markdownを閲覧、コピーする機能を追加しました。\n\n![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/9af56c45-5ae1-4a96-865e-e8cc22ed923a.png)\n\n\n「Markdown」タブを押すことによりMarkdownを閲覧することができるようになりました。\n「このバージョンのMarkdownをコピーする」ボタンを押すことで表示しているバージョンのMarkdownのコピーができるようになりました。\n\n## **ストックリスト作成・編集フォームでタグ・概要を設定できるようになりました**\n*2025年5月30日 リリース*\n\nストックリストを詳細に説明できるよう、「タグ」と「概要」を設定できるようになりました。\n\n- タグは最大5つまで、1つのタグは最大30文字まで設定できます\n- 公開ストックリストの場合はタグの入力が必須です\n- 概要は最大200文字まで入力できます\n\nこれにより、ストックリストの内容や特徴が他のユーザーに伝わりやすくなります!\n\nストックのシェア機能はベータ版として提供中です 📣 \nぜひ、ご活用ください ✨\n\n![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/77b64d54-6894-4613-bb1b-cb3e4b9debc5.png)\n\n\n\n## **ストックのシェア機能をベータ版として提供中です**\n\u003cimg src=\"https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88/df98ac65-6764-40e1-97cd-14b9fd42b36e.png\" width=\"100%\" alt=\"\"/\u003e\n\n*2025年3月27日リリース*\n\nこれまでQiitaのストックは、記事を個人で保存し、活用するための機能として提供してきました。\nしかし、「ストックを共有したい」「シリーズものの記事をまとめて紹介したい」 といったご要望を、ユーザーヒアリングや[Qiita Discussions](https://github.com/increments/qiita-discussions) などを通じて多くいただいていました。\n\nそこで今回、ストックした記事を簡単にシェアできる新機能を追加しました!\n\n「**ストックのシェア機能**」を使うことで以下のようなことができます。\n\n- シリーズものの記事や関連記事をまとめて公開\n- 役立つ記事をストックし、仲間と共有\n- 「これは読んでおくべき!」という記事を、より多くの人に勧められる\n\n新機能を活用すれば、より多くの人に価値ある記事を届けることができます。\nQiitaは、記事がより多くの人に届き、知見が広がることで、技術コミュニティがさらに発展すると考えています。\nまずは気になる記事をストックし、シェアしてみてください!✨\n\nストックのシェア機能の詳しい使い方は、[Qiita Blog](https://blog.qiita.com/stock-share-beta/)で紹介しています。\n\nhttps://blog.qiita.com/stock-share-beta/\n\n\n## **Qiita Tech Festa 2025 開催中!**\n毎年夏に開催していた「Qiita Engineer Festa」をリニューアルした「Qiita Tech Festa」が開催中 📣\n\n6月17日から、記事投稿キャンペーンが始まります!\n様々な記事投稿テーマをご用意しておりますので、まずは参加登録から💁‍♂️\n素敵な記事された方にはQiitaグッズをGetできるかも!?\n\n\n\n\n\n投稿テーマ一覧はこちら 🔽\n\nhttps://qiita.com/tech-festa/2025/tech-sprint\n\n\n7月25日に開催する「技術に特化した話が聞けるライブ配信イベント」も参加申し込みを受付中 📣\n基調講演、スポンサーセッション、Qiita ユーザーによるセッションなど必聴です!\nまた、登壇したい方も募集中です!詳細から応募概要をご確認ください♫\n\n詳細はこちら  🔽\n\nhttps://qiita.com/tech-festa/2025/tech-spark\n","coediting":false,"comments_count":0,"created_at":"2025-06-03T11:10:06+09:00","group":null,"id":"6673009fed8238a9a14e","likes_count":10,"private":false,"reactions_count":0,"stocks_count":2,"tags":[{"name":"Qiita","versions":[]},{"name":"アップデート","versions":[]}],"title":"Qiita アップデートサマリー - 2025年 5月","updated_at":"2025-06-03T11:10:06+09:00","url":"https://qiita.com/Qiita/items/6673009fed8238a9a14e","user":{"description":"Qiita公式アカウントです。Qiitaに関するお問い合わせに反応したり、お知らせなどを発信しています。","facebook_id":"qiita","followees_count":2,"followers_count":732607,"github_login_name":"qiitan","id":"Qiita","items_count":51,"linkedin_id":"","location":"Qiitaの中","name":"Qiita キータ","organization":"Qiita","permanent_id":88,"profile_image_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/88/ccf90b557a406157dbb9d2d7e543dae384dbb561/large.png?1575443439","team_only":false,"twitter_screen_name":"Qiita","website_url":"https://qiita.com"},"page_views_count":null,"team_membership":null,"organization_url_name":"qiita-inc","slide":false}]

このように多くの情報が含まれてしまいます。
そのためそれらをすべてChatGPTに投げるとトークン数が足りなくなります。
なので必要な項目のみを抽出して返すように変更します。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const getQiitaUserInfo = createTool({
  name: "getQiitaUserInfo",
  description: "Qiitaユーザーの情報を取得する",
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
    const response = await fetch(`https://qiita.com/api/v2/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const data = await response.json();
    return data;
  },
});

const getQiitaUserItems = createTool({
  name: "getQiitaUserItems",
  description: "QiitaユーザーIDを指定して、そのユーザーの投稿記事一覧を取得します。最大30件まで取得可能です。記事のタイトル、URL、いいね数、ストック数、閲覧数、タグ、作成日を返します。",
  parameters: z.object({
    userId: z.string().describe("記事一覧を取得したいQiitaユーザーのID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
      const response = await fetch(`https://qiita.com/api/v2/users/${userId}/items?per_page=30`, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });

      // 修正
      const data = await response.json();

      return data.map((item: any) => ({
        title: item.title,
        url: item.url,
        likes: item.likes_count,
        stocks: item.stocks_count,
        views: item.page_views_count,
      }));
  }
});

const agent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、Qiitaユーザーの情報と投稿記事一覧を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
    getQiitaUserItems,
  ]
});

new VoltAgent({
  agents: {
    agent,
  },
}); 

そのままAPIのレスポンスを返すのではなく必要なものをJSONにして返すようにしました。

  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
      const response = await fetch(`https://qiita.com/api/v2/users/${userId}/items?per_page=30`, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });

      // 修正
      const data = await response.json();

      return data.map((item: any) => ({
        title: item.title,
        url: item.url,
        likes: item.likes_count,
        stocks: item.stocks_count,
        views: item.page_views_count,
      }));

それではもう一度プロンプトを実行してみます。

image.png

記事の情報も取得できたようです!

image.png

結果も最新の記事をまとめてくれているのいい感じですね。

4. サブエージェントを作る

ここまでQiita Agentにツールを追加して動かしてきましたが、Qiita Agentをサブエージェントとして実行してみましょう。

まずはQiitaエージェントをサブエージェントにしていきます。(メインとサブはこのあとコードをみるとわかります)

$ mkdir src/tools //agentディレクトリで実行
$ touch src/tools/qiita.ts
src/tools/qiita.ts
import { createTool } from "@voltagent/core";
import { z } from "zod";

export const getQiitaUserInfo = createTool({
  name: "getQiitaUserInfo",
  description: "Qiitaユーザーの情報を取得する",
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
    const response = await fetch(`https://qiita.com/api/v2/users/${userId}`, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    const data = await response.json();
    return data;
  },
});

export const getQiitaUserItems = createTool({
  name: "getQiitaUserItems",
  description: "QiitaユーザーIDを指定して、そのユーザーの投稿記事一覧を取得します。最大30件まで取得可能です。記事のタイトル、URL、いいね数、ストック数、閲覧数、タグ、作成日を返します。",
  parameters: z.object({
    userId: z.string().describe("記事一覧を取得したいQiitaユーザーのID"),
  }),
  execute: async ({ userId }) => {
    const accessToken = process.env.QIITA_API_KEY;
      const response = await fetch(`https://qiita.com/api/v2/users/${userId}/items?per_page=30`, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
        },
      });

      const data = await response.json();

      return data.map((item: any) => ({
        title: item.title,
        url: item.url,
        likes: item.likes_count,
        stocks: item.stocks_count,
        views: item.page_views_count,
      }));
  }
});

toolとして呼び出していた関数を別のファイルに移動しました。
そうするとエージェントはすっきりかけます。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";

const agent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、Qiitaユーザーの情報と投稿記事一覧を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
    getQiitaUserItems,
  ]
});

new VoltAgent({
  agents: {
    agent,
  },
}); 

ここまではリファクタリングなので実際にQiita Agentをサブエージェントにして、エージェントから指示を送れるように修正をしていきましょう。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";

const qiitaAgent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取ったら、Qiitaユーザーの情報と投稿記事一覧を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [
    getQiitaUserInfo,
    getQiitaUserItems,
  ]
});

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `ユーザーから「QiitaユーザーID」を受け取ったら「QiitaユーザーID」を渡して情報をJSONで取得し、Qiitaのユーザー情報と投稿記事一覧をまとめて返してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  subAgents: [
    qiitaAgent,
  ],
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

new VoltAgent({
  agents: {
    mainAgent,
  },
}); 

agentqiitaAgentという名前に変えました。

const qiitaAgent = new Agent({
  name: "qiita-agent",

そして最初に呼び出すエージェントはqiitaAgentではなくmainAgentに変更します。

new VoltAgent({
  agents: {
    mainAgent,
  },
}); 

mainAgentでは「QiitaのユーザーIDを受け取ること」「受け取ったらQiitaから情報を取得すること」を指示しています。

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `ユーザーから「QiitaユーザーID」を受け取ったら「QiitaユーザーID」を渡して情報をJSONで取得し、Qiitaのユーザー情報と投稿記事一覧をまとめて返してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),

そしてエージェントに対してsubAgentsを設定することでQiitaエージェントに指示ができるようになります。

  subAgents: [
    qiitaAgent,
  ],

qiitaAgentをサブエージェントに設定することでエージェントはqiitaAgentがQiitaのユーザーIDから「ユーザー情報」と「記事情報」を取得することがわかるので、「ユーザーから「QiitaユーザーID」を受け取ったら「QiitaユーザーID」を渡して情報をJSONで取得し、Qiitaのユーザー情報と投稿記事一覧をまとめて返してください。」という指示でサブエージェントにユーザーIDを渡して実行することを理解することができます。

それでは先程のプロンプトで実行してみましょう!

サーバーを再起動してAgent選択画面でmain-agentを選択して同じプロンプトをなげてみましょう

image.png

先程と同じようにQiitaの情報からレポートを出すことができました!

5. GitHubサブエージェントを作る

それでは次にGitHubから「ユーザー情報」と「リポジトリ」の情報を取得するようなエージェントを作りましょう。
まずはGitHubを開いてAPIを叩くためのトークンを取得します。

自分のアイコンをクリックするとメニューが表示されるので「Settings」をクリック

image.png

左メニューから「Developer settings」をクリック

image.png

「Personal access tokens」から「Tokens (classic)」をクリック
「Generate new token」から「Generate new token (classic)」をクリック

image.png

Noteに「portfolio-agent」と入力して「repo」にチェックして「Generate Token」をクリック

image.png

するとトークンが発行されるのでコピーしてください
環境変数を設定するので.envにトークンを追加します。

.env
OPENAI_API_KEY=あなたのOPENAI_API_KEY
QIITA_API_KEY=あなたのQIITA_API_KEY
GITHUB_API_KEY=あなたのGITHUB_API_KEY

これでGitHubにアクセスする準備はできたので実際にAPIを叩くツールを作ります。

$ touch src/tools/github.ts
src/tools/github.ts
import { createTool } from "@voltagent/core";
import { z } from "zod";

export const getGithubUserInfo = createTool({
  name: "getGithubUserInfo",
  description: "GitHubユーザー名を指定して、そのユーザーの基本情報(名前、プロフィール、公開リポジトリ数、フォロワー数、Web/SNSリンクなど)を取得します。",
  parameters: z.object({
    username: z.string().describe("GitHubのユーザー名"),
  }),
  execute: async ({ username }) => {
    const token = process.env.GITHUB_API_KEY;
    const res = await fetch(`https://api.github.com/users/${username}`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    const data = await res.json();
    return {
      login: data.login,
      name: data.name,
      bio: data.bio,
      public_repos: data.public_repos,
      followers: data.followers,
      following: data.following,
      blog: data.blog,
      twitter: data.twitter_username,
      html_url: data.html_url,
      avatar_url: data.avatar_url,
      location: data.location,
      company: data.company,
    };
  },
});

export const getGithubRepos = createTool({
  name: "getGithubRepos",
  description: "GitHubユーザー名を指定して、そのユーザーの公開リポジトリ一覧(リポジトリ名、説明、スター数、フォーク数、主要言語、URLなど)を取得します。最大100件。",
  parameters: z.object({
    username: z.string().describe("GitHubのユーザー名"),
  }),
  execute: async ({ username }) => {
    const token = process.env.GITHUB_API_KEY;
    const res = await fetch(`https://api.github.com/users/${username}/repos?per_page=100&type=owner&sort=updated`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    });
    const data = await res.json();
    return data.map((repo: any) => ({
      name: repo.name,
      description: repo.description,
      stargazers_count: repo.stargazers_count,
      forks_count: repo.forks_count,
      language: repo.language,
      html_url: repo.html_url,
      topics: repo.topics,
      updated_at: repo.updated_at,
    }));
  },
}); 

ほとんどQiitaと変わらないのでここでは解説は省略します。
叩いているエンドポイントはこちらを参考にしています。

次に作成したツールを利用するサブエージェントを用意します。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";
import { getGithubRepos, getGithubUserInfo } from "./tools/github.js";

const qiitaAgent = new Agent({
  name: "qiita-agent",
  instructions: `QiitaユーザーIDを指定して、Qiitaユーザーの情報と投稿記事一覧を取得してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getQiitaUserInfo, getQiitaUserItems],
});

// 追加
const githubAgent = new Agent({
  name: "github-agent",
  instructions: `GitHubのユーザー名を指定して、GitHubのユーザー情報と公開リポジトリ情報を取得してください。`,
  parameters: z.object({
    username: z.string().describe("GitHubのユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getGithubUserInfo, getGithubRepos],
});

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `
  「QiitaユーザーID」を受け取ったら「QiitaユーザーID」を渡して情報をJSONで取得し、Qiitaのユーザー情報と投稿記事一覧を取得します。
  「GitHubのユーザー名」を受け取ったら「GitHubのユーザー名」を渡して情報をJSONで取得し、GitHubのユーザー情報と公開リポジトリ情報を取得します。
  それらをまとめて返してください。
  `,  // 修正
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
    username: z.string().describe("GitHubユーザーID"), // 追加
  }),
  subAgents: [qiitaAgent, githubAgent], // 追加
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

new VoltAgent({
  agents: {
    mainAgent,
  },
});

それではプロンプトを投げてみましょう。ここではGitHubのユーザーIDも追加します。

$ npm run dev // 再起動

image.png

GitHubエージェントが増えて、main-agentに2つのサブエージェントが紐付いていることがわかります。
main-agentをクリックします。

image.png

ちゃんとモニタリング画面にもGitHubエージェントと2つのツールが表示されています。
プロンプトを投げてみましょう

Qiita ID: Sicut_study
GitHub ID: jinwatanabe

しかし実行してみると私の環境では以下のような回答が返ってきました
回答が返ってこない人は以下を試します。

QiitaユーザーID「Sicut_study」およびGitHubユーザー名「jinwatanabe」の情報を取得するために確認が必要です。



QiitaユーザーID「Sicut_study」を使用して情報を取得してもよろしいですか?

GitHubユーザー名「jinwatanabe」を使用して情報を取得してもよろしいですか?


ご確認ください。

ここからがエージェント開発の醍醐味のようなものです。

main-agentが「情報を取得してもよろしいですか?」のような確認メッセージを返す理由は、主にLLMの初期プロンプト(instructions)とAIエージェントの安全設計思想に起因しています。

多くのLLMは、ユーザーから個人情報や外部データ取得リクエストがある場合、

「本当に取得してよいか?」
「ユーザーの許可を得てから行動するべきでは?」

という“丁寧な対話”を優先する傾向があります。

「ユーザーIDを受け取ったら情報を取得してください」 のように曖昧な指示だと、LLMは「まずユーザーに本当に取得して良いか確認しよう」と判断してしまうのです。
今回の場合はmain-agentがQiitaやGitHubから個人情報を取得するツールを利用する前に本当に実行していいかを確認してくれているため思ったように動作していませんでした。

今回は実行を確認なしでしてもらいたいのでmain-agentにその旨を書いておきましょう。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";
import { getGithubRepos, getGithubUserInfo } from "./tools/github.js";

const qiitaAgent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取った場合は、絶対に確認や許可を求めたりせず、即座にそのユーザーの情報と投稿記事一覧を取得し、まとめて返してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getQiitaUserInfo, getQiitaUserItems],
});

const githubAgent = new Agent({
  name: "github-agent",
  instructions: `GitHubのユーザー名を指定された場合は、絶対に確認や許可を求めたりせず、即座にそのユーザーの情報と公開リポジトリ情報を取得し、まとめて返してください。`,
  parameters: z.object({
    username: z.string().describe("GitHubのサブエージェントがユーザー名"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getGithubUserInfo, getGithubRepos],
});

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `
  QiitaユーザーIDとGitHubユーザー名が与えられた場合は、絶対に確認や許可を求めたりせず、即座に両方の情報を取得し、Qiitaのユーザー情報・投稿記事一覧とGitHubのユーザー情報・公開リポジトリ情報をまとめて返してください。
  サブエージェントやツールを呼び出す際にも、確認や同意のプロンプトは一切表示せず、ユーザーの追加アクションを求めないでください。
  `, // 修正
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
    username: z.string().describe("GitHubのユーザーID"),
  }),
  subAgents: [qiitaAgent, githubAgent],
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

new VoltAgent({
  agents: {
    mainAgent,
  },
});

サブエージェントを呼び出すときにユーザー操作を求めない旨を追加しました。

  instructions: `
  QiitaユーザーIDとGitHubユーザー名が与えられた場合は、絶対に確認や許可を求めたりせず、即座に両方の情報を取得し、Qiitaのユーザー情報・投稿記事一覧とGitHubのユーザー情報・公開リポジトリ情報をまとめて返してください。
  サブエージェントやツールを呼び出す際にも、確認や同意のプロンプトは一切表示せず、ユーザーの追加アクションを求めないでください。
  `

それではもう一度実行してみましょう

Qiita ID: Sicut_study
GitHub ID: jinwatanabe

image.png

QiitaとGitHubの情報を返すようになりました。

6. レポート形式にまとめる

このままでは取得した情報を並べただけでレポートとしては見にくいので、レポート形式にみやすくフォーマットしてくれるようなエージェントを作ってみましょう

ここでのポイントは
「取得した情報を利用する指示をすること」
「レポートのフォーマットを事前に定義しておくこと」

それではreport-agentを実装していきましょう
report-agentはLLMの機能を利用すればできるため(外部のAPIは使わない)、ツールは不要でプロンプトを書けば作ることが可能です。

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";
import { getGithubRepos, getGithubUserInfo } from "./tools/github.js";

const qiitaAgent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取った場合は、そのユーザーの情報と投稿記事一覧を取得し、まとめて返してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getQiitaUserInfo, getQiitaUserItems],
});

const githubAgent = new Agent({
  name: "github-agent",
  instructions: `ユーザーからGitHubのユーザーIDを受け取った場合は、そのユーザーの情報と公開リポジトリ情報を取得し、まとめて返してください。`,
  parameters: z.object({
    username: z.string().describe("GitHubのユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getGithubUserInfo, getGithubRepos],
});

// 追加
const reportAgent = new Agent({
  name: "report-agent",
  instructions: `
あなたはポートフォリオ自動生成のAIアシスタントです。
受け取ったQiitaのユーザー情報・記事一覧、およびGitHubのユーザー情報・公開リポジトリ情報を、下記のMarkdownテンプレートに沿ってそのまま埋め込んでレポートを作成してください。

- 不足している情報があれば「情報がありません」と記載してください。
- 表やリストは空欄のままでも構いません。
- 余計な説明や補足は一切加えず、Markdownレポートのみを出力してください。

## 1. 基本情報
- **Qiitaユーザー名**:
- **Qiitaプロフィール**:
- **Qiitaフォロワー数**:
- **Qiita記事数**:
- **GitHubユーザー名**:
- **GitHubプロフィール**:
- **GitHubフォロワー数**:
- **GitHub公開リポジトリ数**:

---

## 2. 技術スタック・タグ頻度
- **Qiita主要タグ**:
- **GitHub主要言語**:

---

## 3. Qiita記事一覧(最大10件)
| タイトル | URL | いいね | ストック | タグ | 投稿日 |
|:--|:--|:--|:--|:--|:--|

---

## 4. GitHubリポジトリ一覧(最大10件)
| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |
|:--|:--|:--|:--|:--|:--|:--|

---

## 5. 人気Qiita記事ランキング(いいね順上位3件)
| タイトル | いいね | ストック | URL |
|:--|:--|:--|:--|

---

## 6. 人気GitHubリポジトリランキング(スター順上位3件)
| リポジトリ名 | スター | フォーク | URL |
|:--|:--|:--|:--|

---

## 7. 定量評価
- **Qiita**
    - 記事数:
    - フォロワー数:
    - いいね合計:
    - ストック合計:
- **GitHub**
    - 公開リポジトリ数:
    - フォロワー数:
    - スター合計:
    - フォーク合計:

---

## 8. 代表的なQiita記事・GitHubリポジトリ
- **Qiita記事**:  | [Link]() | 要約:
- **GitHubリポジトリ**:  | [Link]() | 説明:

---

各項目は受け取ったデータをもとに埋めてください。不足している場合は「情報がありません」と記載してください。`,
  parameters: z.object({
    qiitaData: z.string().describe("Qiitaのユーザー情報と投稿記事一覧"),
    githubData: z.string().describe("GitHubのユーザー情報と公開リポジトリ情報"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `
あなたはQiitaとGitHubの情報をまとめてエンジニアのポートフォリオレポートを生成するエージェントです。
  以下の手順に従って、QiitaとGitHubの情報をまとめてポートフォリオレポートを生成してください。

  # 手順
  0. 「Qiita ID: (QiitaのユーザーID) \n GitHub ID: (GitHubのユーザーID)」と入力されたらQiitaユーザーIDとGitHubユーザーIDとして取得する
  1. ユーザーから与えられたQiitaユーザーIDを使って、Qiitaのユーザー情報と投稿記事一覧を取得してください。それを変数qiitaDataに格納してください。
  2. ユーザーから与えられたGitHubユーザー名を使って、GitHubのユーザー情報と公開リポジトリ情報を取得してください。それを変数githubDataに格納してください。
  3. qiitaDataとgithubDataを元に、ポートフォリオレポートを生成してください。

# 厳守事項
- 入力文からQiita IDとGitHub IDを必ず抽出して処理してください。
- サブエージェントやツールを呼び出す際にも、確認や同意のプロンプトは一切表示せず、ユーザーの追加アクションを求めないでください。
- Qiita IDまたはGitHub IDが見つからない場合は「情報がありません」として進めてください。
- 余計な説明や補足は一切加えず、Markdownレポートのみを出力してください。
`, // 修正
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
    username: z.string().describe("GitHubユーザーID"),
  }),
  subAgents: [qiitaAgent, githubAgent, reportAgent],
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

new VoltAgent({
  agents: {
    mainAgent,
  },
});

コードが一気に増えて驚いたかもしれませんが、一つずつ見ていきましょう!

まずはreport-agentの実装からです。
今回は以下のようなレポートを作成したいので事前にマークダウンで雛形をエージェントにもたせます。

const reportAgent = new Agent({
  name: "report-agent",
  instructions: `
あなたはポートフォリオ自動生成のAIアシスタントです。
受け取ったQiitaのユーザー情報・記事一覧、およびGitHubのユーザー情報・公開リポジトリ情報を、下記のMarkdownテンプレートに沿ってそのまま埋め込んでレポートを作成してください。

- 不足している情報があれば「情報がありません」と記載してください。
- 表やリストは空欄のままでも構いません。
- 余計な説明や補足は一切加えず、Markdownレポートのみを出力してください。

## 1. 基本情報
- **Qiitaユーザー名**:
- **Qiitaプロフィール**:
- **Qiitaフォロワー数**:
- **Qiita記事数**:
- **GitHubユーザー名**:
- **GitHubプロフィール**:
- **GitHubフォロワー数**:
- **GitHub公開リポジトリ数**:

---

## 2. 技術スタック・タグ頻度
- **Qiita主要タグ**:
- **GitHub主要言語**:

---

## 3. Qiita記事一覧(最大10件)
| タイトル | URL | いいね | ストック | タグ | 投稿日 |
|:--|:--|:--|:--|:--|:--|

---

## 4. GitHubリポジトリ一覧(最大10件)
| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |
|:--|:--|:--|:--|:--|:--|:--|

---

## 5. 人気Qiita記事ランキング(いいね順上位3件)
| タイトル | いいね | ストック | URL |
|:--|:--|:--|:--|

---

## 6. 人気GitHubリポジトリランキング(スター順上位3件)
| リポジトリ名 | スター | フォーク | URL |
|:--|:--|:--|:--|

---

## 7. 定量評価
- **Qiita**
    - 記事数:
    - フォロワー数:
    - いいね合計:
    - ストック合計:
- **GitHub**
    - 公開リポジトリ数:
    - フォロワー数:
    - スター合計:
    - フォーク合計:

---

## 8. 代表的なQiita記事・GitHubリポジトリ
- **Qiita記事**:  | [Link]() | 要約:
- **GitHubリポジトリ**:  | [Link]() | 説明:

---

各項目は受け取ったデータをもとに埋めてください。不足している場合は「情報がありません」と記載してください。`

report-agentにはQiitaとGitHubの情報を渡すのですが、項目が多くなってしまうので以下のようにQiitaエージェントやGitHubエージェントのツールで取得したものをLLMの中で変数において、レポートエージェントに明示的に渡しています。

  1. ユーザーから与えられたQiitaユーザーIDを使ってQiitaのユーザー情報と投稿記事一覧を取得してくださいそれを変数qiitaDataに格納してください
  parameters: z.object({
    qiitaData: z.string().describe("Qiitaのユーザー情報と投稿記事一覧"),
    githubData: z.string().describe("GitHubのユーザー情報と公開リポジトリ情報"),
  }),

main-agentにサブエージェントとしてreport-agentを登録します。
ここで重要なのがサブエージェントの実行順序になります。

順序を適切に定義しないとQiitaエージェントのあとにレポートエージェントを実行してしまう可能性があります。ここはプロンプトで明示的に指示をしましょう。

  instructions: `
  あなたはQiitaとGitHubの情報をまとめてエンジニアのポートフォリオレポートを生成するエージェントです。
  以下の手順に従って、QiitaとGitHubの情報をまとめてポートフォリオレポートを生成してください。

  # 手順
  0. 「Qiita ID: (QiitaのユーザーID) \n GitHub ID: (GitHubのユーザーID)」と入力されたらQiitaユーザーIDとGitHubユーザーIDとして取得する
  1. ユーザーから与えられたQiitaユーザーIDを使って、Qiitaのユーザー情報と投稿記事一覧を取得してください。それを変数qiitaDataに格納してください。
  2. ユーザーから与えられたGitHubユーザー名を使って、GitHubのユーザー情報と公開リポジトリ情報を取得してください。それを変数githubDataに格納してください。
  3. qiitaDataとgithubDataを元に、ポートフォリオレポートを生成してください。

  # 厳守事項
  -  サブエージェントやツールを呼び出す際にも、確認や同意のプロンプトは一切表示せず、ユーザーの追加アクションを求めないでください。
  `,

それでは実際に試してみます。サーバーを再起動してから以下のプロンプトを実行します。

Qiita ID: Sicut_study
GitHub ID: jinwatanabe

すると以下のようにGitHubのユーザーIDを教えてくださいといった返答がGitHubエージェントから返ってきました。つまり私達のプロンプトからGitHub IDをChatGPTが抽出することができなくなってしまったようです。
メインエージェントのプロンプトを改善する必要があります。

image.png

src/index.ts
import { VoltAgent, Agent, createTool } from "@voltagent/core";
import { VercelAIProvider } from "@voltagent/vercel-ai";

import { openai } from "@ai-sdk/openai";
import { z } from "zod";
import { getQiitaUserInfo, getQiitaUserItems } from "./tools/qiita.js";
import { getGithubRepos, getGithubUserInfo } from "./tools/github.js";

const qiitaAgent = new Agent({
  name: "qiita-agent",
  instructions: `ユーザーからQiitaユーザーIDを受け取った場合は、そのユーザーの情報と投稿記事一覧を取得し、まとめて返してください。`,
  parameters: z.object({
    userId: z.string().describe("QiitaユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getQiitaUserInfo, getQiitaUserItems],
});

const githubAgent = new Agent({
  name: "github-agent",
  instructions: `ユーザーからGitHubのユーザーIDを受け取った場合は、そのユーザーの情報と公開リポジトリ情報を取得し、まとめて返してください。`,
  parameters: z.object({
    username: z.string().describe("GitHubのユーザーID"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
  tools: [getGithubUserInfo, getGithubRepos],
});

const reportAgent = new Agent({
  name: "report-agent",
  instructions: `
あなたはポートフォリオ自動生成のAIアシスタントです。
受け取ったQiitaのユーザー情報・記事一覧、およびGitHubのユーザー情報・公開リポジトリ情報を、下記のMarkdownテンプレートに沿ってそのまま埋め込んでレポートを作成してください。

- 不足している情報があれば「情報がありません」と記載してください。
- 表やリストは空欄のままでも構いません。
- 余計な説明や補足は一切加えず、Markdownレポートのみを出力してください。

## 1. 基本情報
- **Qiitaユーザー名**:
- **Qiitaプロフィール**:
- **Qiitaフォロワー数**:
- **Qiita記事数**:
- **GitHubユーザー名**:
- **GitHubプロフィール**:
- **GitHubフォロワー数**:
- **GitHub公開リポジトリ数**:

---

## 2. 技術スタック・タグ頻度
- **Qiita主要タグ**:
- **GitHub主要言語**:

---

## 3. Qiita記事一覧(最大10件)
| タイトル | URL | いいね | ストック | タグ | 投稿日 |
|:--|:--|:--|:--|:--|:--|

---

## 4. GitHubリポジトリ一覧(最大10件)
| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |
|:--|:--|:--|:--|:--|:--|:--|

---

## 5. 人気Qiita記事ランキング(いいね順上位3件)
| タイトル | いいね | ストック | URL |
|:--|:--|:--|:--|

---

## 6. 人気GitHubリポジトリランキング(スター順上位3件)
| リポジトリ名 | スター | フォーク | URL |
|:--|:--|:--|:--|

---

## 7. 定量評価
- **Qiita**
    - 記事数:
    - フォロワー数:
    - いいね合計:
    - ストック合計:
- **GitHub**
    - 公開リポジトリ数:
    - フォロワー数:
    - スター合計:
    - フォーク合計:

---

## 8. 代表的なQiita記事・GitHubリポジトリ
- **Qiita記事**:  | [Link]() | 要約:
- **GitHubリポジトリ**:  | [Link]() | 説明:

---

各項目は受け取ったデータをもとに埋めてください。不足している場合は「情報がありません」と記載してください。`,
  parameters: z.object({
    qiitaData: z.string().describe("Qiitaのユーザー情報と投稿記事一覧"),
    githubData: z.string().describe("GitHubのユーザー情報と公開リポジトリ情報"),
  }),
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

const mainAgent = new Agent({
  name: "main-agent",
  instructions: `
あなたはQiitaとGitHubの情報をまとめてエンジニアのポートフォリオレポートを生成するエージェントです。
  以下の手順に従って、QiitaとGitHubの情報をまとめてポートフォリオレポートを生成してください。

  # 手順
  0. 「Qiita ID: (QiitaのユーザーID) \n GitHub ID: (GitHubのユーザーID)」と入力されたらQiitaユーザーIDとGitHubユーザーIDとして取得する
  1. ユーザーから与えられたQiitaユーザーIDを使って、Qiitaのユーザー情報と投稿記事一覧を取得してください。それを変数qiitaDataに格納してください。
  2. ユーザーから与えられたGitHubユーザー名を使って、GitHubのユーザー情報と公開リポジトリ情報を取得してください。それを変数githubDataに格納してください。
  3. qiitaDataとgithubDataを元に、ポートフォリオレポートを生成してください。

# 厳守事項
- 入力文からQiita IDとGitHub IDを必ず抽出して処理してください。
- サブエージェントやツールを呼び出す際にも、確認や同意のプロンプトは一切表示せず、ユーザーの追加アクションを求めないでください。
- Qiita IDまたはGitHub IDが見つからない場合は「情報がありません」として進めてください。
- 余計な説明や補足は一切加えず、Markdownレポートのみを出力してください。
`,
  parameters: z.object({
    prompt: z.string().describe("Qiita IDとGitHub IDを含む自然文入力"), // 修正
  }),
  subAgents: [qiitaAgent, githubAgent, reportAgent],
  llm: new VercelAIProvider(),
  model: openai("gpt-4o-mini"),
});

new VoltAgent({
  agents: {
    mainAgent,
  },
});

これまでのメインエージェントは

parameters: z.object({
  userId: z.string().describe("QiitaユーザーID"),
  username: z.string().describe("GitHubのユーザー名"),
})

このようになっており、「userId」「username」の値を明確に受け取る必要があります。
しかし、プロンプトで「Qiita ID: Sicut_study\nGitHub ID: jinwatanabe」と入力しているため、この自然文から値を自動抽出できず、「QiitaユーザーIDとGitHubユーザー名が必要です」と怒られています。

userId: Sicut_study
username: jinwatanabe

このように明確に判断がつくようにプロンプトを渡せばメインエージェントはそれぞれのIDを受け取れます。
ではどうすればいまのプロンプトを使ってQiitaエージェントとGitHubエージェントにIDを渡せるでしょうか?

ここでメインエージェントではプロンプトをすべて受け入れるように修正をします。

  parameters: z.object({
    prompt: z.string().describe("Qiita IDとGitHub IDを含む自然文入力"), // 修正
  }),

このようにすることで受け取ったプロンプトには「Qiita ID」と「GitHub ID」を含むものというのが自然言語で支持されているためChatGPT側でプロンプトからQiita IDとGitHub IDを抽出することが可能になります。

このあとサブエージェントを動かすときにIDが必要になるので、そのタイミングでプロンプトからIDを探してくれます。

先ほどと同じプロンプトを実行すると今度はレポートが正しく返ってくるようになりました。

image.png

7. ポートフォリオを表示する

ここまでで一通りエージェントの開発が終了したので、Reactを使って画面を作成していきます。
agentのサーバーは起動したまま進めてください

$ cd ../client // clientディレクトリに移動

clientディレクトリをVSCodeで開きます。
VoltAgentはAPIにも対応しており、簡単に作成したエージェントを利用することができます。

http://localhost:3141/uiを開いてください。
するとAPIの仕様書(Swagger UI)が表示されるのでAPIを試しに叩いてみましょう

image.png

POST /agent/{id}/textを開いて「Try it out」をクリック
idにはエージェント名をいれるので「main-agent」とします。

リクエストボディにはプロンプトを入れる必要があるので、「input」に「Qiita ID: Sicut_study\nGitHub ID: jinwatanabe」と入力します。

image.png

「Execute」を押すとAPIを叩いて結果を表示してくれます。(少し時間がかかります)

image.png

POST /agents/main-agent/textに対して先程のボディを送ればよさそうです!

src/App.tsx
import { useState } from "react";
import { Button } from "./components/ui/button";

function App() {
  const [portfolio, setPortfolio] = useState("");

  const generatePortfolio = async (qiitaId: string, githubId: string) => {
    const response = await fetch(
      "http://localhost:3141/agents/main-agent/text",
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: `Qiita ID: ${qiitaId}\nGitHub ID: ${githubId}`,
          options: {
            userId: "unique-user-id",
            conversationId: "unique-conversation-id",
            contextLimit: 10,
            temperature: 0.7,
            maxTokens: 100,
          },
        }),
      }
    );

    const res = (await response.json()) as {
      data: { provider: { text: string } };
    };

    return res.data.provider.text;
  };

  const handleGeneratePortfolio = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();

    const qiitaId = e.currentTarget.qiitaId.value;
    const githubId = e.currentTarget.githubId.value;

    const portfolio = await generatePortfolio(qiitaId, githubId);
    setPortfolio(portfolio);
  };

  return (
    <div>
      <form onSubmit={handleGeneratePortfolio}>
        <input type="text" name="qiitaId" placeholder="Qiita ID" />
        <input type="text" name="githubId" placeholder="GitHub ID" />
        <Button>Click me</Button>
      </form>
      <div>{portfolio}</div>
    </div>
  );
}

export default App;

まずはQiita IDとGitHub IDを入力するインプットフォームを作ります。

      <form onSubmit={handleGeneratePortfolio}>
        <input type="text" id="qiitaId" placeholder="Qiita ID" />
        <input type="text" id="githubId" placeholder="GitHub ID" />
        <Button>Click me</Button>
      </form>

formタグを用いてボタンをクリックしてサブミットイベントが発生したらonSubmitで指定したhandleGeneratePortfolioが実行されるようにします。

handleGeneratePortfolioではまず発火したイベントを受け取ります。ここにはインプットフォームに入力された値の情報もつまっているので取得しておきます。

  const handleGeneratePortfolio = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();

    const qiitaId = e.currentTarget.qiitaId.value;
    const githubId = e.currentTarget.githubId.value;

そしてidを使って先程のAPIを叩いたのと同じことを行います。

  const generatePortfolio = async (qiitaId: string, githubId: string) => {
    const response = await fetch(
      "http://localhost:3141/agents/main-agent/text",
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: `Qiita ID: ${qiitaId}\nGitHub ID: ${githubId}`,
          options: {
            userId: "unique-user-id",
            conversationId: "unique-conversation-id",
            contextLimit: 10,
            temperature: 0.7,
            maxTokens: 100,
          },
        }),
      }
    );

    const res = (await response.json()) as {
      data: { provider: { text: string } };
    };

    return res.data.provider.text;
  };

最後にレスポンスの中からres.data.provider.textを取得していますが、これは先ほど叩いたAPIのレスポンスを確認してそこに欲しい情報があったので取得しました。

image.png

最後に取得したレポートをuseStateで作ったportfolioにセットしてあげます。
つまり再レンダリングが走って画面にレポートが表示されます。

  const handleGeneratePortfolio = async (
    (省略)
    
    const portfolio = await generatePortfolio(qiitaId, githubId);
    setPortfolio(portfolio);
  };

  return (
    <div>
      <form onSubmit={handleGeneratePortfolio}>
        <input type="text" id="qiitaId" placeholder="Qiita ID" />
        <input type="text" id="githubId" placeholder="GitHub ID" />
        <Button>Click me</Button>
      </form>
      <div>{portfolio}</div>
    </div>
  );

それでは実際に試してみましょう

$ npm run dev

http://localhost:5173/を開いて

Qiita IDに「Sicut_study」
GitHub IDに「jinwatanabe」

を入力してボタンを押します。このときにネットワークタブを事前に開いておくとPendingでリクエストが走っていることがわかります。(時間がかかります)

image.png

レポートの生成が終了すると画面にマークダウンのレポートが表示されます。

image.png

良さそうなので次はマークダウン表示できるようにしましょう

8. マークダウンを綺麗に表示する

Reactでマークダウンを扱うのであればreact-markdownが便利なので使いましょう

$ npm i react-markdown
src/App.tsx
import React, { useState } from "react";
import ReactMarkdown from "react-markdown"; // 追加
import { Button } from "./components/ui/button";

function App() {
  // 修正
  const [portfolio, setPortfolio] = useState(
    `## 1. 基本情報\n- **Qiitaユーザー名**: Sicut_study\n- **Qiitaプロフィール**: [projisou.jp](https://projisou.jp)\n- **Qiitaフォロワー数**: 11,788\n- **Qiita記事数**: 642\n- **GitHubユーザー名**: jinwatanabe\n- **GitHubプロフィール**: 情報がありません\n- **GitHubフォロワー数**: 情報がありません\n- **GitHub公開リポジトリ数**: 100件以上\n\n---\n\n## 2. 技術スタック・タグ頻度\n- **Qiita主要タグ**: 情報がありません\n- **GitHub主要言語**: 情報がありません\n\n---\n\n## 3. Qiita記事一覧(最大10件)\n| タイトル | URL | いいね | ストック | タグ | 投稿日 |\n|:--|:--|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 467 | 598 | 情報がありません | 情報がありません |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) | 216 | 211 | 情報がありません | 情報がありません |\n| 【図解解説】MCPを実装理解!Next.jsでAIアシスタントを開発するチュートリアル【Hono/TypeScript/Prisma】 | [Link](https://qiita.com/Sicut_study/items/e0fbbbf51cdd54d76b1a) | 244 | 261 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) | 564 | 686 | 情報がありません | 情報がありません |\n| 【図解解説】Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアル【JStack/TypeScript/Neon/Cloudinary】 | [Link](https://qiita.com/Sicut_study/items/d1dd4727881cf4dfd026) | 119 | 106 | 情報がありません | 情報がありません |\n| Next.jsとCloudflare WorkersでDisallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scopeエラーが出る | [Link](https://qiita.com/Sicut_study/items/4d7ca4b956b01301926f) | 0 | 1 | 情報がありません | 情報がありません |\n| Wrangler pages devでポートリロードしたい | [Link](https://qiita.com/Sicut_study/items/238c86deaa3c5b70e642) | 0 | 0 | 情報がありません | 情報がありません |\n| Cloudflare D1とnext-on-pageでenv.DBを通したいがタイプエラーになる Drizzle hono | [Link](https://qiita.com/Sicut_study/items/7418108329c6a6ebb60c) | 0 | 0 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReact Hooks 20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/d4778cbe8b499570f79e) | 481 | 559 | 情報がありません | 情報がありません |\n| Ubuntu24.04でWindsurfをアップデートするとクラッシュする | [Link](https://qiita.com/Sicut_study/items/14c0d0f0081a2b3eda29) | 1 | 2 | 情報がありません | 情報がありません |\n\n---\n\n## 4. GitHubリポジトリ一覧(最大10件)\n| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |\n|:--|:--|:--|:--|:--|:--|:--|\n| jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 0 | 0 | 情報がありません | 情報がありません | 2025-07-06 |\n| go-todo-clean-app | [Link](https://github.com/jinwatanabe/go-todo-clean-app) | 7 | 0 | Go | 情報がありません | 2025-07-03 |\n| rust-todo-clean-app | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) | 3 | 1 | Rust | 情報がありません | 2025-07-03 |\n| make-your-original-react | [Link](https://github.com/jinwatanabe/make-your-original-react) | 0 | 0 | JavaScript | クリエイティブDOM完全理解!君だけのオリジナルReactで作業を学ぶチュートリアル | 2025-06-21 |\n| movie-app-for-react-beginner | [Link](https://github.com/jinwatanabe/movie-app-for-react-beginner) | 2 | 0 | CSS | 0からReactで映画サイトを作って基礎を学ぶチュートリアルのサンプルコード | 2025-06-12 |\n| fastapi-typing-game | [Link](https://github.com/jinwatanabe/fastapi-typing-game) | 5 | 1 | TypeScript | 情報がありません | 2025-06-05 |\n| react-server-tech-article-app | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) | 2 | 1 | JavaScript | 話題の神Reactフレームワークreact-serverで技術記事投稿サイトを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| jstack-twitter-clone | [Link](https://github.com/jinwatanabe/jstack-twitter-clone) | 2 | 0 | TypeScript | Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| file-share-app | [Link](https://github.com/jinwatanabe/file-share-app) | 0 | 0 | TypeScript | Next.js×Cloudflareでファイル共有アプリを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| mcp-todos | [Link](https://github.com/jinwatanabe/mcp-todos) | 0 | 2 | JavaScript | MCPを実装解説!Next.jsでAIアシスタントを開発するチュートリアルのサンプルコード | 2025-05-19 |\n\n---\n\n## 5. 人気Qiita記事ランキング(いいね順上位3件)\n| タイトル | いいね | ストック | URL |\n|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | 467 | 598 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | 564 | 686 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | 216 | 211 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) |\n\n---\n\n## 6. 人気GitHubリポジトリランキング(スター順上位3件)\n| リポジトリ名 | スター | フォーク | URL |\n|:--|:--|:--|:--|\n| go-todo-clean-app | 7 | 0 | [Link](https://github.com/jinwatanabe/go-todo-clean-app) |\n| rust-todo-clean-app | 3 | 1 | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) |\n| react-server-tech-article-app | 2 | 1 | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) |\n\n---\n\n## 7. 定量評価\n- **Qiita**\n    - 記事数: 642\n    - フォロワー数: 11,788\n    - いいね合計: 情報がありません\n    - ストック合計: 情報がありません\n- **GitHub**\n    - 公開リポジトリ数: 100件以上\n    - フォロワー数: 情報がありません\n    - スター合計: 情報がありません\n    - フォーク合計: 情報がありません\n\n---\n\n## 8. 代表的なQiita記事・GitHubリポジトリ\n- **Qiita記事**: 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 要約: 情報がありません\n- **GitHubリポジトリ**: jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 説明: 情報がありません\n\n---`
  );

  const generatePortfolio = async (qiitaId: string, githubId: string) => {
    const response = await fetch(
      "http://localhost:3141/agents/main-agent/text",
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: `Qiita ID: ${qiitaId}\nGitHub ID: ${githubId}`,
          options: {
            userId: "unique-user-id",
            conversationId: "unique-conversation-id",
            contextLimit: 10,
            temperature: 0.7,
            maxTokens: 100,
          },
        }),
      }
    );

    const res = (await response.json()) as {
      data: { provider: { text: string } };
    };

    return res.data.provider.text;
  };

  const handleGeneratePortfolio = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();

    const qiitaId = e.currentTarget.qiitaId.value;
    const githubId = e.currentTarget.githubId.value;

    const portfolio = await generatePortfolio(qiitaId, githubId);
    setPortfolio(portfolio);
  };

  return (
    <div>
      <form onSubmit={handleGeneratePortfolio}>
        <input type="text" name="qiitaId" placeholder="Qiita ID" />
        <input type="text" name="githubId" placeholder="GitHub ID" />
        <Button>Click me</Button>
      </form>
      <div>
        {/* 修正 */}
        <ReactMarkdown>{portfolio}</ReactMarkdown>
      </div>
    </div>
  );
}

export default App;

マークダウンで表示するのは簡単でReactMarkdownでマークダウン部分を囲むだけです。

        <ReactMarkdown>{portfolio}</ReactMarkdown>

エージェントを叩かないとポートフォリオのマークダウンを取得できないのはマークダウンのスタイルを当てる際に不便なのでportfolioの初期値を生成したマークダウンにしました。

  const [portfolio, setPortfolio] = useState(
    `## 1. 基本情報\n- **Qiitaユーザー名**: Sicut_study\n- **Qiitaプロフィール**: [projisou.jp](https://projisou.jp)\n- **Qiitaフォロワー数**: 11,788\n- **Qiita記事数**: 642\n- **GitHubユーザー名**: jinwatanabe\n- **GitHubプロフィール**: 情報がありません\n- **GitHubフォロワー数**: 情報がありません\n- **GitHub公開リポジトリ数**: 100件以上\n\n---\n\n## 2. 技術スタック・タグ頻度\n- **Qiita主要タグ**: 情報がありません\n- **GitHub主要言語**: 情報がありません\n\n---\n\n## 3. Qiita記事一覧(最大10件)\n| タイトル | URL | いいね | ストック | タグ | 投稿日 |\n|:--|:--|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 467 | 598 | 情報がありません | 情報がありません |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) | 216 | 211 | 情報がありません | 情報がありません |\n| 【図解解説】MCPを実装理解!Next.jsでAIアシスタントを開発するチュートリアル【Hono/TypeScript/Prisma】 | [Link](https://qiita.com/Sicut_study/items/e0fbbbf51cdd54d76b1a) | 244 | 261 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) | 564 | 686 | 情報がありません | 情報がありません |\n| 【図解解説】Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアル【JStack/TypeScript/Neon/Cloudinary】 | [Link](https://qiita.com/Sicut_study/items/d1dd4727881cf4dfd026) | 119 | 106 | 情報がありません | 情報がありません |\n| Next.jsとCloudflare WorkersでDisallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scopeエラーが出る | [Link](https://qiita.com/Sicut_study/items/4d7ca4b956b01301926f) | 0 | 1 | 情報がありません | 情報がありません |\n| Wrangler pages devでポートリロードしたい | [Link](https://qiita.com/Sicut_study/items/238c86deaa3c5b70e642) | 0 | 0 | 情報がありません | 情報がありません |\n| Cloudflare D1とnext-on-pageでenv.DBを通したいがタイプエラーになる Drizzle hono | [Link](https://qiita.com/Sicut_study/items/7418108329c6a6ebb60c) | 0 | 0 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReact Hooks 20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/d4778cbe8b499570f79e) | 481 | 559 | 情報がありません | 情報がありません |\n| Ubuntu24.04でWindsurfをアップデートするとクラッシュする | [Link](https://qiita.com/Sicut_study/items/14c0d0f0081a2b3eda29) | 1 | 2 | 情報がありません | 情報がありません |\n\n---\n\n## 4. GitHubリポジトリ一覧(最大10件)\n| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |\n|:--|:--|:--|:--|:--|:--|:--|\n| jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 0 | 0 | 情報がありません | 情報がありません | 2025-07-06 |\n| go-todo-clean-app | [Link](https://github.com/jinwatanabe/go-todo-clean-app) | 7 | 0 | Go | 情報がありません | 2025-07-03 |\n| rust-todo-clean-app | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) | 3 | 1 | Rust | 情報がありません | 2025-07-03 |\n| make-your-original-react | [Link](https://github.com/jinwatanabe/make-your-original-react) | 0 | 0 | JavaScript | クリエイティブDOM完全理解!君だけのオリジナルReactで作業を学ぶチュートリアル | 2025-06-21 |\n| movie-app-for-react-beginner | [Link](https://github.com/jinwatanabe/movie-app-for-react-beginner) | 2 | 0 | CSS | 0からReactで映画サイトを作って基礎を学ぶチュートリアルのサンプルコード | 2025-06-12 |\n| fastapi-typing-game | [Link](https://github.com/jinwatanabe/fastapi-typing-game) | 5 | 1 | TypeScript | 情報がありません | 2025-06-05 |\n| react-server-tech-article-app | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) | 2 | 1 | JavaScript | 話題の神Reactフレームワークreact-serverで技術記事投稿サイトを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| jstack-twitter-clone | [Link](https://github.com/jinwatanabe/jstack-twitter-clone) | 2 | 0 | TypeScript | Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| file-share-app | [Link](https://github.com/jinwatanabe/file-share-app) | 0 | 0 | TypeScript | Next.js×Cloudflareでファイル共有アプリを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| mcp-todos | [Link](https://github.com/jinwatanabe/mcp-todos) | 0 | 2 | JavaScript | MCPを実装解説!Next.jsでAIアシスタントを開発するチュートリアルのサンプルコード | 2025-05-19 |\n\n---\n\n## 5. 人気Qiita記事ランキング(いいね順上位3件)\n| タイトル | いいね | ストック | URL |\n|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | 467 | 598 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | 564 | 686 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | 216 | 211 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) |\n\n---\n\n## 6. 人気GitHubリポジトリランキング(スター順上位3件)\n| リポジトリ名 | スター | フォーク | URL |\n|:--|:--|:--|:--|\n| go-todo-clean-app | 7 | 0 | [Link](https://github.com/jinwatanabe/go-todo-clean-app) |\n| rust-todo-clean-app | 3 | 1 | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) |\n| react-server-tech-article-app | 2 | 1 | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) |\n\n---\n\n## 7. 定量評価\n- **Qiita**\n    - 記事数: 642\n    - フォロワー数: 11,788\n    - いいね合計: 情報がありません\n    - ストック合計: 情報がありません\n- **GitHub**\n    - 公開リポジトリ数: 100件以上\n    - フォロワー数: 情報がありません\n    - スター合計: 情報がありません\n    - フォーク合計: 情報がありません\n\n---\n\n## 8. 代表的なQiita記事・GitHubリポジトリ\n- **Qiita記事**: 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 要約: 情報がありません\n- **GitHubリポジトリ**: jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 説明: 情報がありません\n\n---`
  );

画面を見てみるとマークダウンがHTMLとして表示されるようになりました

image.png

これでは味気ないのでスタイリングもしてみましょう
ここではGitHub風のマークダウン表現をするためにプラグインremarkGfmを使います。

$ npm i remark-gfm

プラグインを活用しつつ、それぞれのタグにTailwindCSSでクラスを当てます。

src/App.tsx
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "./components/ui/button";
import remarkGfm from "remark-gfm";

function App() {
  const [portfolio, setPortfolio] = useState(
    `## 1. 基本情報\n- **Qiitaユーザー名**: Sicut_study\n- **Qiitaプロフィール**: [projisou.jp](https://projisou.jp)\n- **Qiitaフォロワー数**: 11,788\n- **Qiita記事数**: 642\n- **GitHubユーザー名**: jinwatanabe\n- **GitHubプロフィール**: 情報がありません\n- **GitHubフォロワー数**: 情報がありません\n- **GitHub公開リポジトリ数**: 100件以上\n\n---\n\n## 2. 技術スタック・タグ頻度\n- **Qiita主要タグ**: 情報がありません\n- **GitHub主要言語**: 情報がありません\n\n---\n\n## 3. Qiita記事一覧(最大10件)\n| タイトル | URL | いいね | ストック | タグ | 投稿日 |\n|:--|:--|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 467 | 598 | 情報がありません | 情報がありません |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) | 216 | 211 | 情報がありません | 情報がありません |\n| 【図解解説】MCPを実装理解!Next.jsでAIアシスタントを開発するチュートリアル【Hono/TypeScript/Prisma】 | [Link](https://qiita.com/Sicut_study/items/e0fbbbf51cdd54d76b1a) | 244 | 261 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) | 564 | 686 | 情報がありません | 情報がありません |\n| 【図解解説】Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアル【JStack/TypeScript/Neon/Cloudinary】 | [Link](https://qiita.com/Sicut_study/items/d1dd4727881cf4dfd026) | 119 | 106 | 情報がありません | 情報がありません |\n| Next.jsとCloudflare WorkersでDisallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scopeエラーが出る | [Link](https://qiita.com/Sicut_study/items/4d7ca4b956b01301926f) | 0 | 1 | 情報がありません | 情報がありません |\n| Wrangler pages devでポートリロードしたい | [Link](https://qiita.com/Sicut_study/items/238c86deaa3c5b70e642) | 0 | 0 | 情報がありません | 情報がありません |\n| Cloudflare D1とnext-on-pageでenv.DBを通したいがタイプエラーになる Drizzle hono | [Link](https://qiita.com/Sicut_study/items/7418108329c6a6ebb60c) | 0 | 0 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReact Hooks 20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/d4778cbe8b499570f79e) | 481 | 559 | 情報がありません | 情報がありません |\n| Ubuntu24.04でWindsurfをアップデートするとクラッシュする | [Link](https://qiita.com/Sicut_study/items/14c0d0f0081a2b3eda29) | 1 | 2 | 情報がありません | 情報がありません |\n\n---\n\n## 4. GitHubリポジトリ一覧(最大10件)\n| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |\n|:--|:--|:--|:--|:--|:--|:--|\n| jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 0 | 0 | 情報がありません | 情報がありません | 2025-07-06 |\n| go-todo-clean-app | [Link](https://github.com/jinwatanabe/go-todo-clean-app) | 7 | 0 | Go | 情報がありません | 2025-07-03 |\n| rust-todo-clean-app | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) | 3 | 1 | Rust | 情報がありません | 2025-07-03 |\n| make-your-original-react | [Link](https://github.com/jinwatanabe/make-your-original-react) | 0 | 0 | JavaScript | クリエイティブDOM完全理解!君だけのオリジナルReactで作業を学ぶチュートリアル | 2025-06-21 |\n| movie-app-for-react-beginner | [Link](https://github.com/jinwatanabe/movie-app-for-react-beginner) | 2 | 0 | CSS | 0からReactで映画サイトを作って基礎を学ぶチュートリアルのサンプルコード | 2025-06-12 |\n| fastapi-typing-game | [Link](https://github.com/jinwatanabe/fastapi-typing-game) | 5 | 1 | TypeScript | 情報がありません | 2025-06-05 |\n| react-server-tech-article-app | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) | 2 | 1 | JavaScript | 話題の神Reactフレームワークreact-serverで技術記事投稿サイトを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| jstack-twitter-clone | [Link](https://github.com/jinwatanabe/jstack-twitter-clone) | 2 | 0 | TypeScript | Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| file-share-app | [Link](https://github.com/jinwatanabe/file-share-app) | 0 | 0 | TypeScript | Next.js×Cloudflareでファイル共有アプリを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| mcp-todos | [Link](https://github.com/jinwatanabe/mcp-todos) | 0 | 2 | JavaScript | MCPを実装解説!Next.jsでAIアシスタントを開発するチュートリアルのサンプルコード | 2025-05-19 |\n\n---\n\n## 5. 人気Qiita記事ランキング(いいね順上位3件)\n| タイトル | いいね | ストック | URL |\n|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | 467 | 598 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | 564 | 686 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | 216 | 211 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) |\n\n---\n\n## 6. 人気GitHubリポジトリランキング(スター順上位3件)\n| リポジトリ名 | スター | フォーク | URL |\n|:--|:--|:--|:--|\n| go-todo-clean-app | 7 | 0 | [Link](https://github.com/jinwatanabe/go-todo-clean-app) |\n| rust-todo-clean-app | 3 | 1 | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) |\n| react-server-tech-article-app | 2 | 1 | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) |\n\n---\n\n## 7. 定量評価\n- **Qiita**\n    - 記事数: 642\n    - フォロワー数: 11,788\n    - いいね合計: 情報がありません\n    - ストック合計: 情報がありません\n- **GitHub**\n    - 公開リポジトリ数: 100件以上\n    - フォロワー数: 情報がありません\n    - スター合計: 情報がありません\n    - フォーク合計: 情報がありません\n\n---\n\n## 8. 代表的なQiita記事・GitHubリポジトリ\n- **Qiita記事**: 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 要約: 情報がありません\n- **GitHubリポジトリ**: jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 説明: 情報がありません\n\n---`
  );

  const generatePortfolio = async (qiitaId: string, githubId: string) => {
    const response = await fetch(
      "http://localhost:3141/agents/main-agent/text",
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: `Qiita ID: ${qiitaId}\nGitHub ID: ${githubId}`,
          options: {
            userId: "unique-user-id",
            conversationId: "unique-conversation-id",
            contextLimit: 10,
            temperature: 0.7,
            maxTokens: 100,
          },
        }),
      }
    );

    const res = (await response.json()) as {
      data: { provider: { text: string } };
    };

    return res.data.provider.text;
  };

  const handleGeneratePortfolio = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();

    const qiitaId = e.currentTarget.qiitaId.value;
    const githubId = e.currentTarget.githubId.value;

    const portfolio = await generatePortfolio(qiitaId, githubId);
    setPortfolio(portfolio);
  };

  return (
    <div>
      <form onSubmit={handleGeneratePortfolio}>
        <input type="text" name="qiitaId" placeholder="Qiita ID" />
        <input type="text" name="githubId" placeholder="GitHub ID" />
        <Button>Click me</Button>
      </form>
      <div>
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            table: ({ children }) => (
              <div className="overflow-x-auto my-4">
                <table className="min-w-full border-collapse border border-gray-300 rounded-lg overflow-hidden">
                  {children}
                </table>
              </div>
            ),
            th: ({ children }) => (
              <th className="border border-gray-300 bg-gray-50 text-gray-800 px-4 py-3 text-left font-semibold">
                {children}
              </th>
            ),
            td: ({ children }) => (
              <td className="border border-gray-300 text-gray-700 px-4 py-3">
                {children}
              </td>
            ),
            a: ({ href, children }) => (
              <a
                href={href}
                target="_blank"
                rel="noopener noreferrer"
                className="hover:underline transition-colors duration-300 text-blue-600 hover:text-blue-800"
              >
                {children}
              </a>
            ),
            h1: ({ children }) => (
              <h1 className="text-3xl font-bold mb-6 pb-3 border-b text-gray-900 border-gray-300">
                {children}
              </h1>
            ),
            h2: ({ children }) => (
              <h2 className="text-2xl font-semibold mb-4 mt-8 text-gray-800">
                {children}
              </h2>
            ),
            h3: ({ children }) => (
              <h3 className="text-xl font-medium mb-3 mt-6 text-gray-700">
                {children}
              </h3>
            ),
            ul: ({ children }) => (
              <ul className="list-disc list-inside mb-4 space-y-2">
                {children}
              </ul>
            ),
            li: ({ children }) => <li className="text-gray-700">{children}</li>,
            p: ({ children }) => (
              <p className="mb-4 leading-relaxed text-gray-700">{children}</p>
            ),
            hr: () => <hr className="my-8 border-gray-300" />,
          }}
        >
          {portfolio}
        </ReactMarkdown>
      </div>
    </div>
  );
}

export default App;

image.png

だいぶそれっぽくなってきました。
基本的にはプラグインで設定したremarkGfmのスタイルを利用していますが、componentsプロパティは、react-markdownで各Markdown要素(例: table, th, td, a, h1, h2, ul, li など)のスタイルをカスタマイズできる仕組みとなっており、remarkGfmを上書きすることが可能です。

            a: ({ href, children }) => (
              <a
                href={href}
                target="_blank"
                rel="noopener noreferrer"
                className="hover:underline transition-colors duration-300 text-blue-600 hover:text-blue-800"
              >
                {children}
              </a>
            ),

例えばaタグをみるとaタグの子要素(Children)だけでなく、属性要素(href)などもマークダウンから受け取ることができて、それを用いてコンポーネントを作り描画することができます。

9. スタイルをあてる

ここまでで必要な機能はすべてできたので最後はスタイリングを行います。
まずは必要なshacn/uiのコンポーネントをインストールします。

$ npx --legacy-deps shadcn@latest add input button label card

次にタイトルにタイプライターのようなアニメーションをいれるためにライブラリとアイコンのライブラリをインストールします。

$ npm install react-simple-typewriter lucide-react
src/App.tsx
import React, { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { Label } from "./components/ui/label";
import { Card, CardContent } from "./components/ui/card";
import { FileText, Heart, Code, Star } from "lucide-react";
import remarkGfm from "remark-gfm";
import { Typewriter } from "react-simple-typewriter";

function App() {
  const [isLoading, setIsLoading] = useState(false);
  const [portfolio, setPortfolio] = useState("");
  // const [portfolio, setPortfolio] = useState(
  //   `## 1. 基本情報\n- **Qiitaユーザー名**: Sicut_study\n- **Qiitaプロフィール**: [projisou.jp](https://projisou.jp)\n- **Qiitaフォロワー数**: 11,788\n- **Qiita記事数**: 642\n- **GitHubユーザー名**: jinwatanabe\n- **GitHubプロフィール**: 情報がありません\n- **GitHubフォロワー数**: 情報がありません\n- **GitHub公開リポジトリ数**: 100件以上\n\n---\n\n## 2. 技術スタック・タグ頻度\n- **Qiita主要タグ**: 情報がありません\n- **GitHub主要言語**: 情報がありません\n\n---\n\n## 3. Qiita記事一覧(最大10件)\n| タイトル | URL | いいね | ストック | タグ | 投稿日 |\n|:--|:--|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 467 | 598 | 情報がありません | 情報がありません |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) | 216 | 211 | 情報がありません | 情報がありません |\n| 【図解解説】MCPを実装理解!Next.jsでAIアシスタントを開発するチュートリアル【Hono/TypeScript/Prisma】 | [Link](https://qiita.com/Sicut_study/items/e0fbbbf51cdd54d76b1a) | 244 | 261 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) | 564 | 686 | 情報がありません | 情報がありません |\n| 【図解解説】Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアル【JStack/TypeScript/Neon/Cloudinary】 | [Link](https://qiita.com/Sicut_study/items/d1dd4727881cf4dfd026) | 119 | 106 | 情報がありません | 情報がありません |\n| Next.jsとCloudflare WorkersでDisallowed operation called within global scope. Asynchronous I/O (ex: fetch() or connect()), setting a timeout, and generating random values are not allowed within global scopeエラーが出る | [Link](https://qiita.com/Sicut_study/items/4d7ca4b956b01301926f) | 0 | 1 | 情報がありません | 情報がありません |\n| Wrangler pages devでポートリロードしたい | [Link](https://qiita.com/Sicut_study/items/238c86deaa3c5b70e642) | 0 | 0 | 情報がありません | 情報がありません |\n| Cloudflare D1とnext-on-pageでenv.DBを通したいがタイプエラーになる Drizzle hono | [Link](https://qiita.com/Sicut_study/items/7418108329c6a6ebb60c) | 0 | 0 | 情報がありません | 情報がありません |\n| 【図解解説】これ1本12分でReact Hooks 20種を理解できる教科書 | [Link](https://qiita.com/Sicut_study/items/d4778cbe8b499570f79e) | 481 | 559 | 情報がありません | 情報がありません |\n| Ubuntu24.04でWindsurfをアップデートするとクラッシュする | [Link](https://qiita.com/Sicut_study/items/14c0d0f0081a2b3eda29) | 1 | 2 | 情報がありません | 情報がありません |\n\n---\n\n## 4. GitHubリポジトリ一覧(最大10件)\n| リポジトリ名 | URL | スター | フォーク | 主要言語 | 説明 | 最終更新 |\n|:--|:--|:--|:--|:--|:--|:--|\n| jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 0 | 0 | 情報がありません | 情報がありません | 2025-07-06 |\n| go-todo-clean-app | [Link](https://github.com/jinwatanabe/go-todo-clean-app) | 7 | 0 | Go | 情報がありません | 2025-07-03 |\n| rust-todo-clean-app | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) | 3 | 1 | Rust | 情報がありません | 2025-07-03 |\n| make-your-original-react | [Link](https://github.com/jinwatanabe/make-your-original-react) | 0 | 0 | JavaScript | クリエイティブDOM完全理解!君だけのオリジナルReactで作業を学ぶチュートリアル | 2025-06-21 |\n| movie-app-for-react-beginner | [Link](https://github.com/jinwatanabe/movie-app-for-react-beginner) | 2 | 0 | CSS | 0からReactで映画サイトを作って基礎を学ぶチュートリアルのサンプルコード | 2025-06-12 |\n| fastapi-typing-game | [Link](https://github.com/jinwatanabe/fastapi-typing-game) | 5 | 1 | TypeScript | 情報がありません | 2025-06-05 |\n| react-server-tech-article-app | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) | 2 | 1 | JavaScript | 話題の神Reactフレームワークreact-serverで技術記事投稿サイトを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| jstack-twitter-clone | [Link](https://github.com/jinwatanabe/jstack-twitter-clone) | 2 | 0 | TypeScript | Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| file-share-app | [Link](https://github.com/jinwatanabe/file-share-app) | 0 | 0 | TypeScript | Next.js×Cloudflareでファイル共有アプリを開発するチュートリアルのサンプルコード | 2025-05-19 |\n| mcp-todos | [Link](https://github.com/jinwatanabe/mcp-todos) | 0 | 2 | JavaScript | MCPを実装解説!Next.jsでAIアシスタントを開発するチュートリアルのサンプルコード | 2025-05-19 |\n\n---\n\n## 5. 人気Qiita記事ランキング(いいね順上位3件)\n| タイトル | いいね | ストック | URL |\n|:--|:--|:--|:--|\n| 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | 467 | 598 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) |\n| 【図解解説】これ1本12分でReactのコンポーネント20種を理解できる教科書 | 564 | 686 | [Link](https://qiita.com/Sicut_study/items/3247f55e8ae7992485e1) |\n| 【図解解説】Cloudflareを使って2時間でNext.jsを開発して学ぶチュートリアル【初心者OK】 | 216 | 211 | [Link](https://qiita.com/Sicut_study/items/1e03af8bb7f54198bb8a) |\n\n---\n\n## 6. 人気GitHubリポジトリランキング(スター順上位3件)\n| リポジトリ名 | スター | フォーク | URL |\n|:--|:--|:--|:--|\n| go-todo-clean-app | 7 | 0 | [Link](https://github.com/jinwatanabe/go-todo-clean-app) |\n| rust-todo-clean-app | 3 | 1 | [Link](https://github.com/jinwatanabe/rust-todo-clean-app) |\n| react-server-tech-article-app | 2 | 1 | [Link](https://github.com/jinwatanabe/react-server-tech-article-app) |\n\n---\n\n## 7. 定量評価\n- **Qiita**\n    - 記事数: 642\n    - フォロワー数: 11,788\n    - いいね合計: 情報がありません\n    - ストック合計: 情報がありません\n- **GitHub**\n    - 公開リポジトリ数: 100件以上\n    - フォロワー数: 情報がありません\n    - スター合計: 情報がありません\n    - フォーク合計: 情報がありません\n\n---\n\n## 8. 代表的なQiita記事・GitHubリポジトリ\n- **Qiita記事**: 【図解解説】10からReact開発して基礎をマスターできる最強チュートリアル【初心者完全版】 | [Link](https://qiita.com/Sicut_study/items/afd66cac978f4b0a6e61) | 要約: 情報がありません\n- **GitHubリポジトリ**: jinwatanabe | [Link](https://github.com/jinwatanabe/jinwatanabe) | 説明: 情報がありません\n\n---`
  // );

  const generatePortfolio = async (qiitaId: string, githubId: string) => {
    const response = await fetch(
      "http://localhost:3141/agents/main-agent/text",
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          input: `Qiita ID: ${qiitaId}\nGitHub ID: ${githubId}`,
          options: {
            userId: "unique-user-id",
            conversationId: "unique-conversation-id",
            contextLimit: 10,
            temperature: 0.7,
            maxTokens: 100,
          },
        }),
      }
    );

    const res = (await response.json()) as {
      data: { provider: { text: string } };
    };

    return res.data.provider.text;
  };

  const handleGeneratePortfolio = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();
    setIsLoading(true);
    const qiitaId = e.currentTarget.qiitaId.value;
    const githubId = e.currentTarget.githubId.value;
    try {
      const portfolio = await generatePortfolio(qiitaId, githubId);
      setPortfolio(portfolio);
    } finally {
      setIsLoading(false);
    }
  };

  const animatedQiitaArticles = 642;
  const animatedQiitaLikes = 1234;
  const animatedGithubRepos = 12;
  const animatedGithubStars = 456;

  return (
    <div className="min-h-screen transition-all duration-500 bg-white p-4 relative overflow-hidden">
      <div className="max-w-3xl mx-auto relative z-10">
        <div className="text-center mb-12 animate-fade-in">
          <div className="flex items-center justify-center gap-3 mb-4">
            <h1 className="text-5xl font-bold text-gray-900">
              Portfolio Generator
            </h1>
          </div>
          <div className="text-xl text-gray-600 h-8">
            <span style={{ whiteSpace: "pre" }}>
              <Typewriter
                words={["あなたの技術力を可視化します"]}
                loop={0}
                cursor
                cursorStyle="|"
                typeSpeed={80}
                deleteSpeed={50}
                delaySpeed={2000}
              />
            </span>
          </div>
        </div>
        {portfolio && (
          <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8 animate-slide-up max-w-3xl mx-auto">
            <Card className="bg-white border border-gray-200 hover:scale-105 transition-transform duration-300">
              <CardContent className="p-4 text-center">
                <FileText className="w-8 h-8 mx-auto mb-2 text-blue-600" />
                <div className="text-2xl font-bold text-gray-900">
                  {animatedQiitaArticles}
                </div>
                <div className="text-sm text-gray-600">Qiita記事</div>
              </CardContent>
            </Card>
            <Card className="bg-white border border-gray-200 hover:scale-105 transition-transform duration-300">
              <CardContent className="p-4 text-center">
                <Heart className="w-8 h-8 mx-auto mb-2 text-red-600" />
                <div className="text-2xl font-bold text-gray-900">
                  {animatedQiitaLikes}
                </div>
                <div className="text-sm text-gray-600">いいね</div>
              </CardContent>
            </Card>
            <Card className="bg-white border border-gray-200 hover:scale-105 transition-transform duration-300">
              <CardContent className="p-4 text-center">
                <Code className="w-8 h-8 mx-auto mb-2 text-green-600" />
                <div className="text-2xl font-bold text-gray-900">
                  {animatedGithubRepos}
                </div>
                <div className="text-sm text-gray-600">リポジトリ</div>
              </CardContent>
            </Card>
            <Card className="bg-white border border-gray-200 hover:scale-105 transition-transform duration-300">
              <CardContent className="p-4 text-center">
                <Star className="w-8 h-8 mx-auto mb-2 text-yellow-600" />
                <div className="text-2xl font-bold text-gray-900">
                  {animatedGithubStars}
                </div>
                <div className="text-sm text-gray-600">スター</div>
              </CardContent>
            </Card>
          </div>
        )}
        <Card className="p-8 mb-8 bg-white/90 shadow-lg rounded-xl">
          {isLoading && (
            <div className="flex justify-center mb-4">
              <div className="w-8 h-8 border-4 border-blue-400 border-t-transparent rounded-full animate-spin" />
            </div>
          )}
          <form onSubmit={handleGeneratePortfolio} className="space-y-6">
            <div className="space-y-2">
              <Label htmlFor="qiitaId" className="text-gray-700">
                Qiita ID
              </Label>
              <Input
                id="qiitaId"
                name="qiitaId"
                placeholder="例: Sicut_study"
                className="bg-white border-gray-300"
                autoComplete="username"
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="githubId" className="text-gray-700">
                GitHub ID
              </Label>
              <Input
                id="githubId"
                name="githubId"
                placeholder="例: jinwatanabe"
                className="bg-white border-gray-300"
                autoComplete="username"
              />
            </div>
            <div className="flex gap-2">
              <Button type="submit" className="flex-1" disabled={isLoading}>
                {isLoading ? "生成中..." : "ポートフォリオ生成"}
              </Button>
            </div>
          </form>
        </Card>
        <ReactMarkdown
          remarkPlugins={[remarkGfm]}
          components={{
            table: ({ children }) => (
              <div className="overflow-x-auto my-4">
                <table className="min-w-full border-collapse border border-gray-300 rounded-lg overflow-hidden">
                  {children}
                </table>
              </div>
            ),
            th: ({ children }) => (
              <th className="border border-gray-300 bg-gray-50 text-gray-800 px-4 py-3 text-left font-semibold">
                {children}
              </th>
            ),
            td: ({ children }) => (
              <td className="border border-gray-300 text-gray-700 px-4 py-3">
                {children}
              </td>
            ),
            a: ({ href, children }) => (
              <a
                href={href}
                target="_blank"
                rel="noopener noreferrer"
                className="hover:underline transition-colors duration-300 text-blue-600 hover:text-blue-800"
              >
                {children}
              </a>
            ),
            h1: ({ children }) => (
              <h1 className="text-3xl font-bold mb-6 pb-3 border-b text-gray-900 border-gray-300">
                {children}
              </h1>
            ),
            h2: ({ children }) => (
              <h2 className="text-2xl font-semibold mb-4 mt-8 text-gray-800">
                {children}
              </h2>
            ),
            h3: ({ children }) => (
              <h3 className="text-xl font-medium mb-3 mt-6 text-gray-700">
                {children}
              </h3>
            ),
            ul: ({ children }) => (
              <ul className="list-disc list-inside mb-4 space-y-2">
                {children}
              </ul>
            ),
            li: ({ children }) => <li className="text-gray-700">{children}</li>,
            p: ({ children }) => (
              <p className="mb-4 leading-relaxed text-gray-700">{children}</p>
            ),
            hr: () => <hr className="my-8 border-gray-300" />,
          }}
        >
          {portfolio}
        </ReactMarkdown>
      </div>
    </div>
  );
}

export default App;

このようなデザインになりました!

image.png

ステップアップ課題

エージェントはとても便利なものですが、表示まで時間がかかるのとトークンを使うためコストもかかります。
そこでポートフォリオを事前生成することにします。
QiitaやGitHubの情報はリアルタイム性が不要なので事前生成したものを表示するようにすればすぐにユーザーは結果をみることが可能です。

  1. エージェントを叩いてポートフォリオをmd形式で保存するTypeScriptのツールを作ってください
  2. クライアントに保存したmdファイルをおいて表示するように修正してください

おわりに

いかがでしたでしょうか?
今回はAIエージェント開発としてVoltAgentを紹介してみました
ぜひ自分でもエージェントを開発してより理解を深めてみてください

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

  • risa様
  • tokec様
  • k-kaijima様
  • 山本様
  • banana様
  • 河野様
    次回のハンズオンのレビュアーはXにて募集します。
90
77
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
77

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?