เมื่อ AI เข้าถึงได้ง่ายขึ้น บริษัทอย่าง Replicate ทำให้การผสานรวม machine learning models เข้าโปรเจกต์ง่ายขึ้นอย่างมาก
ในบทความนี้ ผมจะเล่าวิธีสร้าง CaptionAI เว็บแอปที่ให้ผู้ใช้อัปโหลดภาพแล้วได้ AI generated text caption โปรเจกต์นี้สร้างจาก Vercel template นี้ มี วิดีโอ อธิบายการสร้างด้วย

เทคโนโลยีที่ใช้
- Next.js 13 (Front-end และ Back-end)
- Upstash Redis (Rate Limiting)
- Replicate (Machine Learning API)
- Tailwind CSS (Styling)
- Vercel (Deployment)
สิ่งที่ต้องเตรียม
- บัญชี Upstash สำหรับสร้าง database
- บัญชี Replicate สำหรับเข้าถึง Machine Learning API
ตั้งค่า Upstash Redis
หลังสร้างบัญชี Upstash และล็อกอินแล้ว ไปที่แท็บ Redis สร้าง database


หลังสร้าง database แล้ว ไปที่แท็บ Details เลื่อนลงหาส่วน REST API เลือกปุ่ม .env คัดลอกเนื้อหาเก็บไว้ที่ปลอดภัย

ตั้งค่า Replicate
หลังสร้างบัญชี Replicate และล็อกอิน ไปที่แท็บ Account เก็บ API token ไว้ที่ปลอดภัย
หมายเหตุ: ใช้ Replicate ฟรีได้ แต่พอระดับหนึ่งจะขอเพิ่มบัตรเครดิต ราคาขึ้นอยู่กับโมเดลที่ใช้ โมเดลที่เราใช้คือ salesforce/blip ใช้ประมาณ $0.00042 ต่อครั้ง

ตั้งค่าโปรเจกต์
แทนที่จะสร้างจากศูนย์ ให้ clone repository จาก GitHub
หลัง clone repo แล้ว สร้างไฟล์ .env คัดลอกข้อมูลจาก .example.env ไปที่ .env แล้วเพิ่มค่าที่เก็บไว้จากขั้นตอนก่อนหน้า ควรจะหน้าตาประมาณนี้:
// .env
REPLICATE_API_KEY="your_replicate_api_key_from_above"
// Optional, ถ้าทำ rate limiting
UPSTASH_REDIS_REST_URL="your_upstash_redis_rest__url_from_above"
UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest__token_from_above"
หลังใส่ข้อมูลครบ รันโปรเจกต์ได้ด้วยคำสั่ง:
npm install
npm run dev
โครงสร้าง Repository
นี่คือโฟลเดอร์หลักของโปรเจกต์ ผมวงแดงไฟล์ที่จะอธิบายเพิ่มเติมเกี่ยวกับการอัปโหลดภาพ rate limiting และการใช้ BLIP ML API

High-Level Data Flow
แผนภาพระดับสูงของ data flow input คือ user-uploaded image ผ่าน Upload Component ไป backend สำหรับ BLIP ML API processing แล้วแสดง response text ใน UI

สร้าง Redis Instance
ในโปรเจกต์ ตั้งค่า upstash redis client เพื่ออ้างอิงตลอดโปรเจกต์
// `/utils/redis.ts`
import { Redis } from "@upstash/redis";
const redis = !!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: undefined;
export default redis;
โค้ดนี้ import Redis module จาก "@upstash/redis" และสร้าง Redis instance แบบ conditional ตาม environment variables UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN ถ้าทั้งคู่ defined จะสร้าง instance พร้อม URL และ token ถ้าไม่ ไฟล์ redis จะเป็น undefined สุดท้าย export redis variable เพื่อใช้ในส่วนอื่นของแอปพลิเคชัน
อัปโหลดภาพ
// `/pages/captions.tsx`
const uploader = Uploader({
apiKey: !!process.env.NEXT_PUBLIC_UPLOAD_API_KEY
? process.env.NEXT_PUBLIC_UPLOAD_API_KEY
: "free",
});
const options = {
maxFileCount: 1,
mimeTypes: ["image/jpeg", "image/png", "image/jpg"],
editor: {
images: { crop: false }
},
styles: {
colors: {
primary: "#5a5cd1",
error: "#d23f4d",
shade100: "#fff",
shade200: "#fffe",
shade300: "#fffd",
shade400: "#fffc",
shade500: "#fff9",
shade600: "#fff7",
shade700: "#fff2",
shade800: "#fff1",
shade900: "#ffff",
},
},
onValidate: async (file: File): Promise<undefined | string> => {
let isSafe = false;
try {
isSafe = await NSFWPredictor.isSafeImg(file);
if (!isSafe) va.track("NSFW Image blocked");
} catch (error) {
console.error("NSFW predictor threw an error", error);
}
return isSafe ? undefined : "Detected a NSFW image which is not allowed. If this was a mistake, please contact me at hosna.qasmei@gmail.com";
},
};
โค้ดนี้ตั้งค่า options สำหรับ uploader component สร้าง uploader ด้วย Uploader() function และส่ง options เป็น object
ตัวเลือกแรกคือ apiKey สำหรับ authenticate กับ uploader service ค่า apiKey ขึ้นอยู่กับว่า environment variable NEXT_PUBLIC_UPLOAD_API_KEY ถูกตั้งค่าหรือไม่ ถ้าตั้งค่า ใช้ค่านั้น ถ้าไม่ ใช้ค่า "free"
Object options มีตัวเลือกต่างๆ:
maxFileCount: จำกัดไฟล์อัปโหลดพร้อมกันได้ 1 ไฟล์mimeTypes: อนุญาต MIME types "image/jpeg", "image/png", "image/jpg"editor: ตั้งค่า image editor ปิด cropstyles: กำหนด custom styles สำหรับ uploader UIonValidate: function validate ไฟล์ก่อนอัปโหลด ใช้ NSFWPredictor เช็คภาพ safe หรือไม่ ถ้าไม่ safe ส่ง error message
// `/pages/captions.tsx` continued
const Home: NextPage = () => {
const [originalPhoto, setOriginalPhoto] = useState<string | null>(null);
const [caption, setCaption] = useState<string | null>(null);
const [buttonText, setButtonText] = useState("Copy");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const copyToClipboard = () => {
navigator.clipboard.writeText(caption!);
setButtonText("Copied!");
setTimeout(() => { setButtonText("Copy"); }, 2000);
};
const UploadDropZone = () => (
<UploadDropzone
uploader={uploader}
options={options}
onUpdate={(file) => {
if (file.length !== 0) {
setOriginalPhoto(file[0].fileUrl.replace("raw", "thumbnail"));
generateCaption(file[0].fileUrl.replace("raw", "thumbnail"));
}
}}
width="670px"
height="250px"
/>
);
async function generateCaption(fileUrl: string) {
await new Promise((resolve) => setTimeout(resolve, 500));
setLoading(true);
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: fileUrl }),
});
let newCaption = await res.json();
if (res.status !== 200) {
setError(newCaption);
} else {
setCaption(newCaption);
}
setLoading(false);
}
...
มี state variables หลายตัวที่ใช้ useState hook:
originalPhoto: string URL ของภาพที่อัปโหลดcaption: string caption ที่สร้างขึ้นสำหรับภาพbuttonText: string ข้อความบนปุ่ม copyloading: boolean แสดงสถานะกำลัง fetch dataerror: string error message ถ้ามี error ระหว่าง generate caption
มีฟังก์ชัน copyToClipboard ใช้ navigator.clipboard.writeText copy caption ไป clipboard เปลี่ยน buttonText เป็น "Copied!" 2 วินาที แล้วกลับเป็น "Copy"
มี sub-component UploadDropZone render UploadDropzone component ด้วย uploader และ options ที่ระบุ ใช้ onUpdate callback อัปเดต originalPhoto และ caption variables
สุดท้าย ฟังก์ชัน async generateCaption รับ fileUrl parameter ใช้ fetch เรียก /api/generate endpoint ด้วย POST request ส่ง fileUrl เป็น JSON payload parse response เป็น JSON ตั้งค่า caption หรือ error variable ขึ้นกับสำเร็จหรือไม่ loading variable อัปเดตสถานะ request ยังมี delay 500ms ด้วย setTimeout เพื่อหลีกเลี่ยง API rate limits
Rate Limiting
// `/pages/api/generate.ts`
import redis from "../../utils/redis";
import requestIp from "request-ip";
import { Ratelimit } from "@upstash/ratelimit";
import type { NextApiRequest, NextApiResponse } from "next";
type Data = string;
interface ExtendedNextApiRequest extends NextApiRequest {
body: { imageUrl: string; };
}
// Create a new ratelimiter, that allows 3 requests every 15 minutes
const ratelimit = redis
? new Ratelimit({
redis: redis,
limiter: Ratelimit.fixedWindow(5, "1440 m"),
analytics: true,
})
: undefined;
...
โค้ดนี้ import modules และ types สำหรับสร้าง API endpoint ใน Next.js รวมถึง Upstash Redis database client และ rate limiter library "@upstash/ratelimit"
Constant ratelimit สร้าง instance ใหม่ของ Ratelimit class fixed-window rate limiter อนุญาต 5 requests ทุก 1440 นาที (24 ชั่วโมง) ส่ง redis property ไปยัง Ratelimit constructor เพื่อ rate limiting ข้าม multiple instances ถ้า redis เป็น undefined (เช่น Redis database ไม่ได้ config) ratelimit ก็จะเป็น undefined หมายถึง rate limiting จะไม่ถูกนำไปใช้ถ้า Redis ไม่พร้อม
// `/pages/api/generate.ts` continued
export default async function handler(
req: ExtendedNextApiRequest,
res: NextApiResponse<Data>
) {
// Rate Limiter Code
if (ratelimit) {
const identifier = requestIp.getClientIp(req);
const result = await ratelimit.limit(identifier!);
res.setHeader("X-RateLimit-Limit", result.limit);
res.setHeader("X-RateLimit-Remaining", result.remaining);
if (!result.success) {
res
.status(429)
.json("Too many uploads in 1 day. Please try again after 24 hours.");
return;
}
}
...
บล็อกโค้ดนี้เป็นส่วนของ API handler function จำกัดอัตราคำขอที่ client สามารถส่งไป API ได้ เช็คก่อนว่ามี rate limiter instance หรือไม่ ถ้ามี ดึง client IP address ด้วย request-ip package ส่งไป ratelimit.limit method method นี้ return object ที่มีจำนวน requests ที่เหลือใน time frame ที่กำหนด และว่า request สำเร็จหรือไม่
ถ้าสำเร็จ ตั้ง header X-RateLimit-Limit และ X-RateLimit-Remaining ใน response ถ้าเกินขีดจำกัด ส่ง status 429 พร้อม error message return ทันทีเพื่อหยุดการทำงานต่อ
BLIP ML API
// `/pages/api/generate.ts` continued
const imageUrl = req.body.imageUrl;
let startResponse = await fetch("https://api.replicate.com/v1/predictions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Token " + process.env.REPLICATE_API_KEY,
},
body: JSON.stringify({
version: "2e1dddc8621f72155f24cf2e0adbde548458d3cab9f00c0139eea840d0ac4746",
input: {
image: imageUrl,
task: "image_captioning",
},
}),
});
...
ส่วนนี้รับ imageUrl จาก request body ส่ง POST request ไป "https://api.replicate.com/v1/predictions" เพื่อขอ image caption ด้วย task image_captioning request มี Authorization header ที่มี Replicate API key สำหรับ authenticate และ Content-Type header เป็น "application/json" parse response เป็น JSON ดึง endpointUrl จาก jsonStartResponse object
หา version number ของโมเดลได้โดยเลือกโมเดลที่ต้องการใช้

เลือกแท็บ API

เลื่อนลงจนเจอ version number (วงแดง) และ input parameters (วงน้ำเงิน) 
// `/pages/api/generate.ts` continued
let jsonStartResponse = await startResponse.json();
let endpointUrl = jsonStartResponse.urls.get;
// GET request to get the status of the image restoration process & return the result when it's ready
let caption: string | null = null;
while (!caption) {
// Loop in 1s intervals until the alt text is ready
console.log("polling for result...");
let finalResponse = await fetch(endpointUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Token " + process.env.REPLICATE_API_KEY,
},
});
let jsonFinalResponse = await finalResponse.json();
if (jsonFinalResponse.status === "succeeded") {
caption = jsonFinalResponse.output;
} else if (jsonFinalResponse.status === "failed") {
break;
} else {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
res.status(200).json(caption ? caption : "Failed to generate caption");
}
ใช้ while loop poll endpointUrl ทุก 1 วินาที จนกว่า caption จะพร้อม ส่ง GET request ไป endpointUrl พร้อม headers เดียวกัน parse response เป็น JSON ถ้า status ใน jsonFinalResponse เป็น "succeeded" ดึง caption จาก output property ถ้า "failed" หยุด loop ถ้ายังไม่เสร็จ รอ 1 วินาทีด้วย setTimeout แล้ว poll ใหม่
สุดท้าย ส่ง caption เป็น JSON response status 200 ถ้าไม่ใช่ null มิฉะนั้นส่ง "Failed to generate caption" status 200
สรุป
โปรเจกต์นี้ให้ประสบการณ์มีค่าในการ implement การอัปโหลดภาพ rate limiting และการผสานรวม machine learning APIs ด้วยการทำสำเร็จ เราเข้าใจเทคโนโลยีเหล่านี้ดีขึ้น และวิธีนำไปสร้างโปรเจกต์ขั้นสูงในอนาคต