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

สิ่งที่เราจะใช้
- Next.js 13 (ส่วนหน้าและส่วนหลัง)
- Upstash Redis (จำกัดอัตรา)
- จำลอง (API การเรียนรู้ของเครื่อง)
- Tailwind CSS (สไตล์)
- Vercel (การปรับใช้)
สิ่งที่คุณต้องการ
- บัญชี Upstash เพื่อสร้างฐานข้อมูล
- จำลองบัญชีเพื่อเข้าถึง Machine Learning API
การตั้งค่า Upstash Redis
เมื่อคุณสร้างบัญชี Upstash และเข้าสู่ระบบแล้ว คุณจะไปที่แท็บ Redis และสร้างฐานข้อมูล


หลังจากที่คุณสร้างฐานข้อมูลแล้ว คุณจะไปที่แท็บรายละเอียด เลื่อนลงไปจนกว่าคุณจะพบส่วน REST API แล้วเลือกปุ่ม .env คัดลอกเนื้อหาและบันทึกไว้ในที่ที่ปลอดภัย

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

การตั้งค่าโครงการ
แทนที่จะสร้างโปรเจ็กต์ตั้งแต่ต้น คุณสามารถโคลนพื้นที่เก็บข้อมูลจาก GitHub ได้
เมื่อคุณโคลน repo แล้ว คุณจะต้องสร้างไฟล์ .env คัดลอกข้อมูลจากไฟล์ .example.env ลงในไฟล์ .env เมื่อคุณคัดลอกแล้ว คุณจะต้องเพิ่มรายการที่เราบันทึกไว้จากส่วนด้านบน
มันควรมีลักษณะดังนี้:
// .env
REPLICATE_API_KEY="your_replicate_api_key_from_above"
// Optional, if you're doing 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 โครงสร้างพื้นที่เก็บข้อมูล
นี่คือโครงสร้างโฟลเดอร์หลักสำหรับโปรเจ็กต์ ฉันได้วงกลมสีแดงไว้ในไฟล์ที่จะกล่าวถึงเพิ่มเติมในโพสต์นี้ซึ่งเกี่ยวข้องกับการอัปโหลดรูปภาพ การจำกัดอัตรา และการนำ BLIP ML API ไปใช้

กระแสข้อมูลระดับสูง
นี่คือแผนภาพระดับสูงที่แสดงการไหลของข้อมูล ข้อมูลที่เราป้อนซึ่งเป็นรูปภาพที่ผู้ใช้อัปโหลดจะส่งผ่านองค์ประกอบการอัปโหลดไปยังแบ็กเอนด์สำหรับการประมวลผล BLIP ML API จากนั้นจึงแสดงข้อความตอบกลับใน UI

สร้างอินสแตนซ์ Redis
ภายในโปรเจ็กต์ เราจะตั้งค่าไคลเอ็นต์ Redis ที่สะสมไว้ซึ่งเราสามารถอ้างอิงได้ตลอดทั้งโปรเจ็กต์เมื่อจำเป็น
// `/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; ข้อมูลโค้ดนี้จะนำเข้าโมดูล Redis จากแพ็คเกจ "@upstash/redis" และสร้างอินสแตนซ์ Redis ใหม่ อินสแตนซ์ถูกสร้างขึ้นตามเงื่อนไขโดยมีตัวแปรสภาพแวดล้อมสองตัวคือ UPSTASH_REDIS_REST_URL และ UPSTASH_REDIS_REST_TOKEN
หากมีการกำหนดตัวแปรทั้งสอง อินสแตนซ์ Redis ใหม่จะถูกสร้างขึ้นด้วย URL และโทเค็นที่ระบุ หากตัวแปรตัวใดตัวหนึ่งหรือทั้งสองไม่ได้กำหนดไว้ ตัวแปร Redis จะถูกตั้งค่าเป็นไม่ได้กำหนด สุดท้าย ตัวแปร Redis จะถูกส่งออกจากโมดูลเพื่อใช้ในส่วนอื่นๆ ของแอปพลิเคชัน
การอัปโหลดรูปภาพ
// `/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", // Primary buttons & links
error: "#d23f4d", // Error messages
shade100: "#fff", // Standard text
shade200: "#fffe", // Secondary button text
shade300: "#fffd", // Secondary button text (hover)
shade400: "#fffc", // Welcome text
shade500: "#fff9", // Modal close button
shade600: "#fff7", // Border
shade700: "#fff2", // Progress indicator background
shade800: "#fff1", // File item background
shade900: "#ffff", // Various (draggable crop buttons, etc.)
},
},
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";
},
}; รหัสนี้จะตั้งค่าตัวเลือกการกำหนดค่าสำหรับส่วนประกอบของผู้อัปโหลด ตัวอัปโหลดถูกสร้างขึ้นโดยใช้ฟังก์ชัน Uploader() และตัวเลือกต่างๆ จะถูกส่งผ่านเป็นออบเจ็กต์ไปให้
ตัวเลือกการกำหนดค่าแรกคือ apiKey ซึ่งใช้ในการตรวจสอบสิทธิ์กับบริการของผู้อัปโหลด ค่าของ apiKey จะพิจารณาจากว่ามีการตั้งค่าตัวแปรสภาพแวดล้อม NEXT_PUBLIC_UPLOAD_API_KEY หรือไม่ หากมีการตั้งค่าไว้ ระบบจะใช้ค่าของตัวแปรสภาพแวดล้อม มิฉะนั้นจะใช้ค่า "ฟรี"
ออบเจ็กต์ตัวเลือกประกอบด้วยตัวเลือกต่างๆ สำหรับผู้อัปโหลด ซึ่งรวมถึง:
- maxFileCount:ตั้งค่าจำนวนไฟล์สูงสุดที่สามารถอัปโหลดได้ในคราวเดียวเป็น 1 ไฟล์
- mimeTypes:ตั้งค่าประเภท MIME ที่อนุญาตสำหรับไฟล์ที่อัปโหลดเป็น "image/jpeg", "image/png" และ "image/jpg"
- ตัวแก้ไข:กำหนดค่าตัวเลือกสำหรับโปรแกรมแก้ไขรูปภาพ ซึ่งในกรณีนี้จะถูกปิดใช้งานโดยการตั้งค่าการครอบตัดเป็นเท็จ
- สไตล์:กำหนดสไตล์ที่กำหนดเองสำหรับ UI ของผู้อัปโหลด
- onValidate:กำหนดฟังก์ชันที่ถูกเรียกเพื่อตรวจสอบแต่ละไฟล์ก่อนที่จะอัปโหลด ในกรณีนี้ ฟังก์ชันจะใช้ NSFWPredictor เพื่อตรวจสอบว่ารูปภาพนั้นปลอดภัยสำหรับการทำงานหรือไม่ หากรูปภาพไม่ปลอดภัย ข้อความแสดงข้อผิดพลาดจะถูกส่งกลับเพื่อระบุว่าไม่อนุญาตให้ใช้รูปภาพ
// `/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!"); // set the button text to "Copied!" when text is copied
setTimeout(() => {
setButtonText("Copy"); // set the button text back to "Copy" after 2 seconds
}, 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);
}
...
มีตัวแปรสถานะหลายตัวที่กำหนดด้วย useState hook
08รหัส> คือสตริงที่แสดงถึง URL ของภาพที่อัปโหลด13รหัส> เป็นสตริงที่มีคำบรรยายที่สร้างขึ้นสำหรับภาพที่อัปโหลด27รหัส> คือสตริงที่แสดงข้อความบนปุ่มคัดลอก30รหัส> เป็นบูลีนที่ระบุว่าส่วนประกอบกำลังดึงข้อมูลอยู่หรือไม่42รหัส> เป็นสตริงที่มีข้อความแสดงข้อผิดพลาดหากมีข้อผิดพลาดในระหว่างกระบวนการสร้างคำบรรยาย
ส่วนประกอบมีฟังก์ชันที่เรียกว่า copyToClipboard ซึ่งใช้เมธอด navigator.clipboard.writeText เพื่อคัดลอกตัวแปรคำอธิบายภาพไปยังคลิปบอร์ด เมื่อคัดลอกข้อความ ข้อความจะเปลี่ยนตัวแปร buttonText เป็น "คัดลอกแล้ว!" เป็นเวลาสองวินาทีก่อนที่จะรีเซ็ตเป็น "คัดลอก"
นอกจากนี้ยังมีองค์ประกอบย่อยที่เรียกว่า UploadDropZone ซึ่งแสดงผลอินสแตนซ์ขององค์ประกอบ UploadDropzone พร้อมด้วยผู้อัปโหลดและตัวเลือกที่ระบุ การเรียกกลับ onUpdate ใช้เพื่ออัปเดตตัวแปรรูปภาพและคำบรรยายต้นฉบับด้วย URL ของรูปภาพที่อัปโหลดและคำบรรยายที่สร้างขึ้น
สุดท้ายนี้ มีฟังก์ชันอะซิงโครนัสชื่อ GenerateCaption ซึ่งรับพารามิเตอร์ fileUrl ซึ่งเป็น URL ของรูปภาพที่อัปโหลด โดยจะใช้การดึงข้อมูลเพื่อเรียกตำแหน่งข้อมูล /api/generate ด้วยคำขอ POST และส่งผ่าน fileUrl เป็นเพย์โหลด JSON จากนั้น การตอบสนองจะถูกแยกวิเคราะห์เป็น JSON และตั้งค่าคำอธิบายภาพหรือตัวแปรข้อผิดพลาด ขึ้นอยู่กับว่าการตอบสนองสำเร็จหรือไม่ ตัวแปรการโหลดยังได้รับการอัปเดตเพื่อระบุว่าคำขอยังอยู่ระหว่างดำเนินการหรือไม่ ฟังก์ชันนี้ยังรวมการหน่วงเวลา 500ms โดยใช้ฟังก์ชัน setTimeout เพื่อหลีกเลี่ยงไม่ให้ถึงขีดจำกัดอัตรา API
การจำกัดอัตรา
// `/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;
... โค้ดนี้จะนำเข้าโมดูลและประเภทที่จำเป็นสำหรับการสร้างตำแหน่งข้อมูล API ใน Next.js รวมถึงไคลเอนต์ฐานข้อมูล Upstash Redis และไลบรารีตัวจำกัด arate ที่เรียกว่า "@upstash/ratelimit"
ค่าคงที่ Ratelimit จะสร้างอินสแตนซ์ใหม่ของคลาส Ratelimit ซึ่งสร้างตัวจำกัดอัตราหน้าต่างคงที่ที่อนุญาต 5 คำขอทุกๆ 1440 นาที (24 ชั่วโมง) คุณสมบัติ Redis ถูกส่งผ่านเป็นพารามิเตอร์ไปยังตัวสร้าง Ratelimit เพื่อเปิดใช้งานการจำกัดอัตราในหลายอินสแตนซ์ของแอปพลิเคชัน หากไม่ได้กำหนด Redis (เช่น หากไม่ได้กำหนดค่าฐานข้อมูล Redis) อัตราขีดจำกัดก็จะถูกตั้งค่าเป็นไม่ได้กำหนดเช่นกัน ซึ่งหมายความว่าการจำกัดอัตราจะไม่มีผลหากไม่มี 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 ที่จำกัดอัตราการร้องขอที่ไคลเอ็นต์สามารถสร้างให้กับ API ขั้นแรกจะตรวจสอบว่าอินสแตนซ์ตัวจำกัดอัตราพร้อมใช้งานหรือไม่ และหากเป็นเช่นนั้น ให้แยกที่อยู่ IP ของไคลเอ็นต์โดยใช้แพ็คเกจ request-ip แล้วส่งต่อไปยังเมธอดratelimit.limit วิธีการนี้จะส่งคืนออบเจ็กต์ที่มีจำนวนคำขอที่เหลืออยู่ภายในกรอบเวลาที่ระบุและไม่ว่าคำขอจะสำเร็จหรือไม่ก็ตาม
หากคำขอสำเร็จ ส่วนหัว X-RateLimit-Limit และ X-RateLimit-Remaining จะถูกตั้งค่าในการตอบกลับ หากเกินขีดจำกัดคำขอ รหัสสถานะ 429 และข้อความแสดงข้อผิดพลาดจะถูกส่งไปในการตอบกลับ และฟังก์ชันจะกลับมาก่อนกำหนดเพื่อป้องกันการดำเนินการต่อไป
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 จากเนื้อหาคำขอและส่งคำขอ POST ไปยังตำแหน่งข้อมูล "https://api.replicate.com/v1/predictions" เพื่อรับคำอธิบายภาพโดยใช้งาน image_captioning คำขอดังกล่าวรวมส่วนหัวการอนุญาตที่มีคีย์ Replicate API สำหรับการตรวจสอบสิทธิ์ และส่วนหัวประเภทเนื้อหาได้รับการตั้งค่าเป็น "application/json" การตอบสนองจาก API จะถูกแยกวิเคราะห์เป็น JSON และ endpointUrl ถูกแยกออกจากออบเจ็กต์ jsonStartResponse
คุณสามารถค้นหาหมายเลขเวอร์ชันของรุ่นได้โดยเลือกรุ่นที่คุณต้องการใช้

เลือกแท็บ API

เลื่อนลงไปจนกระทั่งหมายเลขเวอร์ชันปรากฏขึ้น โดยมีเส้นขอบเป็นสีแดง และเส้นขอบสีน้ำเงินคือพารามิเตอร์อินพุตที่คุณสามารถใช้ได้ 
// `/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 เพื่อสำรวจ endpointUrl ในช่วงเวลา 1 วินาทีจนกว่าคำบรรยายจะพร้อม ลูปจะส่งคำขอ GET ไปยัง endpointUrl ด้วยส่วนหัว Authorization และ Content-Type เดียวกัน และการตอบสนองจะถูกแยกวิเคราะห์เป็น JSON ด้วย หากสถานะในออบเจ็กต์ jsonFinalResponse เป็น "สำเร็จ" คำอธิบายภาพจะถูกแยกออกจากคุณสมบัติเอาต์พุต หากสถานะเป็น "ล้มเหลว" การวนซ้ำจะสิ้นสุดลง หากสถานะไม่ "สำเร็จ" หรือ "ล้มเหลว" การวนซ้ำจะรอเป็นเวลา 1 วินาทีโดยใช้เมธอด setTimeout ก่อนที่จะทำการโพลอีกครั้ง
สุดท้าย คำอธิบายภาพจะถูกส่งกลับเป็นการตอบกลับ JSON โดยมีรหัสสถานะ 200 หากไม่เป็นค่าว่าง มิฉะนั้น การตอบกลับที่มีข้อความ "ไม่สามารถสร้างคำอธิบายภาพ" จะถูกส่งกลับพร้อมรหัสสถานะ 200
บทสรุป
โดยสรุป โปรเจ็กต์นี้ได้มอบประสบการณ์อันมีค่าในการใช้งานการอัปโหลดรูปภาพ การจำกัดอัตรา และการผสานรวม API การเรียนรู้ของเครื่อง เมื่อทำโปรเจ็กต์นี้สำเร็จ เราก็ได้รับความเข้าใจที่ดีขึ้นเกี่ยวกับเทคโนโลยีเหล่านี้ และวิธีการนำไปใช้เพื่อสร้างโปรเจ็กต์ขั้นสูงเพิ่มเติมในอนาคต