iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 28

Day 28|進階AI功能自動生成技能學習路線圖(Roadmap Generation)

  • 分享至 

  • xImage
  •  

在Day27中,我們成功整合了VercelAISDK與GoogleGeminiAPI,為VibePulse打造了具備脈絡感知的AI技能學習教練」。

今天(Day28),我們將解鎖AI功能的終極型態一鍵生成結構化技能學習路線圖(RoadmapGeneration)!

當使用者想要學習一項全新的技能(如"Rust"或"React")時,往往面臨不知如何拆解學習階段的困境。今天我們將利用VercelAISDK的generateObject與ZodSchema(StructuredOutputs),實作:

結構化JSON輸出(StructuredOutputs):強制GeminiAPI回傳符合我們定義的ZodSchema,絕不跑偏。

自動拆解3-5個學習階段(Milestones):每個階段包含階段名稱、估計耗時、核心知識點(Tasks)與實戰驗驗專案(Project)。

一鍵匯入至Prisma資料庫:將AI生成的路線圖自動轉化為可勾選履行的實體Task與學習目標!

1.數據結構與ZodSchema定義
我們需要確保AI回傳的資料可以直接被前端渲染,並無縫寫入Prisma。

在@/lib/schemas/roadmap.ts中定義Schema:

// lib/schemas/roadmap.ts
import { z } from 'zod';

export const roadmapTaskSchema = z.object({
  title: z.string().describe('子任務或核心知識點名稱'),
  description: z.string().describe('知識點詳細說明與學習重點'),
});

export const roadmapStageSchema = z.object({
  stageNumber: z.number().describe('階段順序,從 1 開始'),
  title: z.string().describe('階段主題(如:基礎語法與型別系統)'),
  estimatedHours: z.number().describe('建議練習總時數(小時)'),
  description: z.string().describe('本階段的核心目標'),
  tasks: z.array(roadmapTaskSchema).describe('本階段需完成的 3-5 個子任務'),
  milestoneProject: z.string().describe('本階段結束後的實戰驗收小專案'),
});

export const roadmapSchema = z.object({
  skillTitle: z.string().describe('技能名稱'),
  summary: z.string().describe('學習路線圖總結說明'),
  recommendedTotalHours: z.number().describe('全階段預估總時數'),
  stages: z.array(roadmapStageSchema).min(3).max(5).describe('3 到 5 個遞進的學習階段'),
});

export type RoadmapData = z.infer<typeof roadmapSchema>;

2.實戰步驟1:建立JSON生成APIRoute(app/api/ai/roadmap/route.ts)
利用VercelAISDK的generateObject函式,傳入google('gemini-2.5-flash')與schema:

// app/api/ai/roadmap/route.ts
import { google } from '@ai-sdk/google';
import { generateObject } from 'ai';
import { NextResponse } from 'next/server';
import { roadmapSchema } from '@/lib/schemas/roadmap';

export const maxDuration = 45;

export async function POST(req: Request) {
  try {
    const { skillTitle, category, currentLevel } = await req.json();

    if (!skillTitle) {
      return NextResponse.json({ success: false, error: '請提供技能名稱' }, { status: 400 });
    }

    const prompt = `
請為技能「${skillTitle}」(分類領域:${category || '通用'})規劃一套系統化的學習路線圖。
使用者目前的基礎等級為:${currentLevel || '零基礎/初學者'}。

請遵循以下規則拆解:
1. 將學習過程拆解為 3 到 5 個由淺入深的階段 (Stages)。
2. 每個階段需提供明確的階段目標、估算練習時數、3-5 個核心知識任務,以及一個實驗驗收專案。
3. 內容需具體可行、切合現代實務標準。
`;

    // 強制 Gemini 回傳符合 roadmapSchema 的結構化物件
    const { object } = await generateObject({
      model: google('gemini-2.5-flash'),
      schema: roadmapSchema,
      prompt,
    });

    return NextResponse.json({ success: true, data: object });
  } catch (error) {
    console.error('Roadmap generation failed:', error);
    return NextResponse.json({ success: false, error: '生成路線圖失敗' }, { status: 500 });
  }
}

3.實戰步驟2:打造Roadmap可視化階層組件(components/skills/RoadmapViewer.tsx)
將AI生成的結構化JSON以脈絡清晰的Timeline/Accordion卡片呈現:

// components/skills/RoadmapViewer.tsx
'use client';

import { RoadmapData } from '@/lib/schemas/roadmap';
import { CheckCircle2, Clock, Rocket, Flag, Sparkles } from 'lucide-react';

interface RoadmapViewerProps {
  roadmap: RoadmapData;
  onImport?: (roadmap: RoadmapData) => void;
  isImporting?: boolean;
}

export function RoadmapViewer({ roadmap, onImport, isImporting }: RoadmapViewerProps) {
  return (
    <div className="space-y-6 bg-slate-900/60 border border-slate-800 p-6 rounded-2xl backdrop-blur-md">
      {/* 標頭簡介 */}
      <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-5">
        <div>
          <div className="flex items-center gap-2 text-cyan-400 text-xs font-bold uppercase tracking-wider mb-1">
            <Sparkles className="h-4 w-4" /> AI 生成學習路線圖
          </div>
          <h2 className="text-2xl font-black text-slate-100">{roadmap.skillTitle}</h2>
          <p className="text-sm text-slate-400 mt-1 max-w-2xl">{roadmap.summary}</p>
        </div>

        <div className="flex items-center gap-3 shrink-0">
          <div className="text-right">
            <div className="text-xs text-slate-500">預估總時數</div>
            <div className="text-lg font-mono font-bold text-cyan-400 flex items-center gap-1">
              <Clock className="h-4 w-4" /> {roadmap.recommendedTotalHours} 小時
            </div>
          </div>
          {onImport && (
            <button
              onClick={() => onImport(roadmap)}
              disabled={isImporting}
              className="px-4 py-2.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-sm rounded-xl shadow-lg shadow-cyan-600/20 transition disabled:opacity-50"
            >
              {isImporting ? '匯入中...' : '一鍵匯入此路線圖'}
            </button>
          )}
        </div>
      </div>

      {/* 階段時間軸 (Timeline) */}
      <div className="relative pl-6 border-l-2 border-slate-800 space-y-8 my-4">
        {roadmap.stages.map((stage) => (
          <div key={stage.stageNumber} className="relative group">
            {/* 時間軸節點圖示 */}
            <div className="absolute -left-[31px] top-1.5 w-6 h-6 rounded-full bg-slate-950 border-2 border-cyan-500 flex items-center justify-center text-xs font-mono font-bold text-cyan-400">
              {stage.stageNumber}
            </div>

            {/* 階段卡片 */}
            <div className="bg-slate-950/80 border border-slate-800/80 rounded-xl p-5 space-y-4 hover:border-slate-700 transition">
              <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/60 pb-3">
                <h3 className="text-base font-bold text-slate-100 flex items-center gap-2">
                  <span>階段 {stage.stageNumber}:{stage.title}</span>
                </h3>
                <span className="text-xs font-mono bg-slate-900 border border-slate-800 text-slate-400 px-2.5 py-1 rounded-md w-fit">
                  預計 {stage.estimatedHours} 小時
                </span>
              </div>

              <p className="text-xs text-slate-400 leading-relaxed">{stage.description}</p>

              {/* 子任務清單 */}
              <div className="space-y-2">
                <h4 className="text-xs font-semibold text-slate-300">核心知識點任務:</h4>
                <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
                  {stage.tasks.map((task, idx) => (
                    <div
                      key={idx}
                      className="flex items-start gap-2 bg-slate-900/50 p-2.5 rounded-lg border border-slate-800/50 text-xs"
                    >
                      <CheckCircle2 className="h-4 w-4 text-cyan-500 shrink-0 mt-0.5" />
                      <div>
                        <div className="font-semibold text-slate-200">{task.title}</div>
                        <div className="text-slate-400 text-[11px] mt-0.5">{task.description}</div>
                      </div>
                    </div>
                  ))}
                </div>
              </div>

              {/* 實戰小專案 */}
              <div className="flex items-center gap-2 bg-amber-500/10 border border-amber-500/20 text-amber-300 p-3 rounded-lg text-xs">
                <Rocket className="h-4 w-4 shrink-0" />
                <div>
                  <span className="font-bold">階段驗收專案:</span> {stage.milestoneProject}
                </div>
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

4.實戰步驟3:整合至技能創建Modal(components/skills/CreateSkillModal.tsx)
現在我們讓使用者在新增技能時,可選擇手動新增或AI自動生成路線圖:

// components/skills/CreateSkillModal.tsx (簡化示意)
'use client';

import { useState } from 'react';
import { Sparkles, Loader2 } from 'lucide-react';
import { RoadmapViewer } from './RoadmapViewer';
import { RoadmapData } from '@/lib/schemas/roadmap';
import { toast } from 'sonner';

export function CreateSkillModal() {
  const [skillTitle, setSkillTitle] = useState('');
  const [category, setCategory] = useState('Frontend');
  const [isGenerating, setIsGenerating] = useState(false);
  const [roadmap, setRoadmap] = useState<RoadmapData | null>(null);

  const handleGenerateRoadmap = async () => {
    if (!skillTitle.trim()) {
      toast.error('請先輸入想要學習的技能名稱');
      return;
    }

    setIsGenerating(true);
    try {
      const res = await fetch('/api/ai/roadmap', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ skillTitle, category, currentLevel: '初學者' }),
      });

      const json = await res.json();
      if (json.success) {
        setRoadmap(json.data);
        toast.success('AI 學習路線圖生成成功!');
      } else {
        toast.error('生成失敗:' + json.error);
      }
    } catch {
      toast.error('網路連線異常');
    } finally {
      setIsGenerating(false);
    }
  };

  return (
    <div className="space-y-6">
      <div className="flex gap-2">
        <input
          value={skillTitle}
          onChange={(e) => setSkillTitle(e.target.value)}
          placeholder="例如:Rust 程式語言、跆拳道品勢..."
          className="flex-1 bg-slate-950 border border-slate-800 rounded-xl p-3 text-sm text-slate-100 focus:border-cyan-500 focus:outline-none"
        />
        <button
          onClick={handleGenerateRoadmap}
          disabled={isGenerating || !skillTitle.trim()}
          className="flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-cyan-600 to-blue-600 hover:from-cyan-500 hover:to-blue-500 text-white font-semibold text-sm rounded-xl shadow-lg shadow-cyan-600/20 transition disabled:opacity-50"
        >
          {isGenerating ? (
            <>
              <Loader2 className="h-4 w-4 animate-spin" /> AI 分析中...
            </>
          ) : (
            <>
              <Sparkles className="h-4 w-4" /> 生成 AI 路線圖
            </>
          )}
        </button>
      </div>

      {roadmap && <RoadmapViewer roadmap={roadmap} />}
    </div>
  );
}

今天我們成功實作了VibePulse的進階AI核心功能:

generateObject+ZodSchema:實現強型別的StructuredOutputs,確保AI回傳百分之百符合專案資料結構的JSON。

階層式路線圖視覺化:繪製動態Timeline卡片,將複雜的技能拆解為可執行的階段、核心知識點與驗收專案。

提升學習體驗:大幅降低使用者建立新技能學習計畫的心理門檻!


上一篇
Day 27|AI 智能助手整合使用Vercel AI SDK與Gemini打造技能教練
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言