Initial commit
This commit is contained in:
1
.env-sample
Normal file
1
.env-sample
Normal file
@@ -0,0 +1 @@
|
|||||||
|
GOOGLE_AI_API_KEY=AIzaSyCjd8EnlhDUlhLRHZFKogEy6fCgK1FqGmo
|
||||||
5
.firebaserc
Normal file
5
.firebaserc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "hum-app-d68e3"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
firebase-service-account.json
|
||||||
16
firebase.json
Normal file
16
firebase.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"source": "functions",
|
||||||
|
"codebase": "default",
|
||||||
|
"disallowLegacyRuntimeConfig": true,
|
||||||
|
"ignore": [
|
||||||
|
"node_modules",
|
||||||
|
".git",
|
||||||
|
"firebase-debug.log",
|
||||||
|
"firebase-debug.*.log",
|
||||||
|
"*.local"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
functions/.gitignore
vendored
Normal file
2
functions/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.local
|
||||||
167
functions/index.js
Normal file
167
functions/index.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// index.js
|
||||||
|
const { onRequest } = require("firebase-functions/v2/https");
|
||||||
|
const { defineString } = require("firebase-functions/params");
|
||||||
|
const admin = require("firebase-admin");
|
||||||
|
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
||||||
|
|
||||||
|
// Initialize Firebase Admin
|
||||||
|
admin.initializeApp();
|
||||||
|
|
||||||
|
// Define environment parameter for Gemini API key
|
||||||
|
// Set this using: firebase functions:secrets:set GEMINI_API_KEY
|
||||||
|
const GEMINI_API_KEY = defineString("GEMINI_API_KEY");
|
||||||
|
|
||||||
|
const MODEL_NAME = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an image from a public URL and returns base64 + mimeType.
|
||||||
|
*/
|
||||||
|
const fetch_image_as_base64 = async (image_url) => {
|
||||||
|
const response = await fetch(image_url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch image: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const array_buffer = await response.arrayBuffer();
|
||||||
|
const base64_data = Buffer.from(array_buffer).toString("base64");
|
||||||
|
const mime_type = response.headers.get("content-type") || "image/jpeg";
|
||||||
|
|
||||||
|
return { base64_data, mime_type };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches categories from Firestore and returns them in a format
|
||||||
|
* that Gemini can use to assign a category ID.
|
||||||
|
* Only returns categories where visible = true.
|
||||||
|
*/
|
||||||
|
const fetch_categories_from_firestore = async () => {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const categories_snapshot = await db
|
||||||
|
.collection("categories")
|
||||||
|
.where("visible", "==", true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const categories = [];
|
||||||
|
categories_snapshot.forEach((doc) => {
|
||||||
|
let cat_data = doc.data();
|
||||||
|
cat_data.id = doc.id;
|
||||||
|
categories.push(cat_data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main logic: takes an image URL, calls Gemini, and returns the description text.
|
||||||
|
*/
|
||||||
|
const describe_image_from_url = async (image_url, api_key) => {
|
||||||
|
if (!api_key) {
|
||||||
|
throw new Error("Missing Gemini API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Gemini
|
||||||
|
const gen_ai = new GoogleGenerativeAI(api_key);
|
||||||
|
const model = gen_ai.getGenerativeModel({ model: MODEL_NAME });
|
||||||
|
|
||||||
|
// Fetch categories from Firestore
|
||||||
|
const categories = await fetch_categories_from_firestore();
|
||||||
|
|
||||||
|
// Format categories for the prompt
|
||||||
|
const categories_list = categories
|
||||||
|
.map((cat) => `${cat.id}: ${cat.name}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const { base64_data, mime_type } = await fetch_image_as_base64(image_url);
|
||||||
|
|
||||||
|
const prompt = `Analyze this rental item for marketplace listing with value estimation. Return raw JSON only, no markdown:
|
||||||
|
{
|
||||||
|
"title": "Clear, concise title (max 50 chars)",
|
||||||
|
"description": "Detailed description (2-3 sentences)",
|
||||||
|
"categoryId": "Category ID from this list: [${categories_list}]. If none match, leave this field as an empty string",
|
||||||
|
"condition": "One of: New, Like New, Good, Fair, Poor",
|
||||||
|
"suggestedDailyPrice": 15.99,
|
||||||
|
"suggestedWeeklyPrice": 89.99,
|
||||||
|
"suggestedMonthlyPrice": 299.99,
|
||||||
|
"estimatedValue": 599.99,
|
||||||
|
"brand": "Brand name if identifiable",
|
||||||
|
"model": "Model if identifiable",
|
||||||
|
"features": ["Key feature 1", "Feature 2", "Feature 3"],
|
||||||
|
"targetAudience": ["Students", "Professionals", "Families"],
|
||||||
|
"seasonality": "One of: Year-round, Summer, Winter, Spring, Fall, Holiday",
|
||||||
|
"insuranceRecommended": true,
|
||||||
|
"depositSuggestion": 100.00
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = await model.generateContent({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
data: base64_data,
|
||||||
|
mimeType: mime_type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.response.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Firebase Cloud Function
|
||||||
|
// Call this from your Flutter app
|
||||||
|
// Function name: hum-process-image
|
||||||
|
exports.humprocessimage = onRequest(
|
||||||
|
{
|
||||||
|
cors: true,
|
||||||
|
},
|
||||||
|
async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { imageUrl } = req.body;
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Missing imageUrl in request body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await describe_image_from_url(
|
||||||
|
imageUrl,
|
||||||
|
GEMINI_API_KEY.value()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse the JSON response from Gemini
|
||||||
|
// Remove markdown code blocks if present
|
||||||
|
let cleaned_result = result.trim();
|
||||||
|
if (cleaned_result.startsWith("```json")) {
|
||||||
|
cleaned_result = cleaned_result.replace(/^```json\n/, "").replace(/\n```$/, "");
|
||||||
|
} else if (cleaned_result.startsWith("```")) {
|
||||||
|
cleaned_result = cleaned_result.replace(/^```\n/, "").replace(/\n```$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed_result = JSON.parse(cleaned_result);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: parsed_result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error analyzing rental item:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
7330
functions/package-lock.json
generated
Normal file
7330
functions/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
functions/package.json
Normal file
24
functions/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "functions",
|
||||||
|
"description": "Cloud Functions for Firebase",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "firebase emulators:start --only functions",
|
||||||
|
"shell": "firebase functions:shell",
|
||||||
|
"start": "npm run shell",
|
||||||
|
"deploy": "firebase deploy --only functions",
|
||||||
|
"logs": "firebase functions:log"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "24"
|
||||||
|
},
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"firebase-admin": "^13.6.0",
|
||||||
|
"firebase-functions": "^7.0.0",
|
||||||
|
"@google/generative-ai": "^0.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"firebase-functions-test": "^3.4.1"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
215
index.js
Normal file
215
index.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// index.js
|
||||||
|
const functions = require("firebase-functions");
|
||||||
|
const admin = require("firebase-admin");
|
||||||
|
const { GoogleGenerativeAI } = require("@google/generative-ai");
|
||||||
|
|
||||||
|
// Load .env only for local testing
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
require("dotenv").config();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Firebase Admin
|
||||||
|
// In production (Firebase Functions), this uses default credentials
|
||||||
|
// For local testing, it uses the service account file
|
||||||
|
if (!admin.apps.length) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
const serviceAccount = require("./firebase-service-account.json");
|
||||||
|
admin.initializeApp({
|
||||||
|
credential: admin.credential.cert(serviceAccount),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
admin.initializeApp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key from environment
|
||||||
|
// In Firebase Functions, set this using: firebase functions:config:set gemini.api_key="YOUR_KEY"
|
||||||
|
// For local testing, use .env file
|
||||||
|
const api_key =
|
||||||
|
process.env.GOOGLE_AI_API_KEY || functions.config().gemini?.api_key;
|
||||||
|
|
||||||
|
const MODEL_NAME = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches an image from a public URL and returns base64 + mimeType.
|
||||||
|
* This helper can be reused inside a Firebase Function later.
|
||||||
|
*/
|
||||||
|
const fetch_image_as_base64 = async (image_url) => {
|
||||||
|
const response = await fetch(image_url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch image: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const array_buffer = await response.arrayBuffer();
|
||||||
|
const base64_data = Buffer.from(array_buffer).toString("base64");
|
||||||
|
const mime_type = response.headers.get("content-type") || "image/jpeg";
|
||||||
|
|
||||||
|
return { base64_data, mime_type };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches categories from Firestore and returns them in a format
|
||||||
|
* that Gemini can use to assign a category ID.
|
||||||
|
* Only returns categories where visible = true.
|
||||||
|
*/
|
||||||
|
const fetch_categories_from_firestore = async () => {
|
||||||
|
const db = admin.firestore();
|
||||||
|
const categories_snapshot = await db
|
||||||
|
.collection("categories")
|
||||||
|
.where("visible", "==", true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
const categories = [];
|
||||||
|
categories_snapshot.forEach((doc) => {
|
||||||
|
let cat_data = doc.data();
|
||||||
|
cat_data.id = doc.id;
|
||||||
|
categories.push(cat_data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main logic: takes an image URL, calls Gemini, and returns the description text.
|
||||||
|
* This function is what we'll later call from Firebase Functions.
|
||||||
|
*/
|
||||||
|
const describe_image_from_url = async (image_url) => {
|
||||||
|
if (!api_key) {
|
||||||
|
throw new Error("Missing Gemini API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Gemini
|
||||||
|
const gen_ai = new GoogleGenerativeAI(api_key);
|
||||||
|
const model = gen_ai.getGenerativeModel({ model: MODEL_NAME });
|
||||||
|
|
||||||
|
// Fetch categories from Firestore
|
||||||
|
const categories = await fetch_categories_from_firestore();
|
||||||
|
|
||||||
|
// Format categories for the prompt
|
||||||
|
const categories_list = categories
|
||||||
|
.map((cat) => `${cat.id}: ${cat.name}`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const { base64_data, mime_type } = await fetch_image_as_base64(image_url);
|
||||||
|
|
||||||
|
const prompt = `Analyze this rental item for marketplace listing with value estimation. Return raw JSON only, no markdown:
|
||||||
|
{
|
||||||
|
"title": "Clear, concise title (max 50 chars)",
|
||||||
|
"description": "Detailed description (2-3 sentences)",
|
||||||
|
"categoryId": "Category ID from this list: [${categories_list}]. If none match, leave this field as an empty string",
|
||||||
|
"condition": "One of: New, Like New, Good, Fair, Poor",
|
||||||
|
"suggestedDailyPrice": 15.99,
|
||||||
|
"suggestedWeeklyPrice": 89.99,
|
||||||
|
"suggestedMonthlyPrice": 299.99,
|
||||||
|
"estimatedValue": 599.99,
|
||||||
|
"brand": "Brand name if identifiable",
|
||||||
|
"model": "Model if identifiable",
|
||||||
|
"features": ["Key feature 1", "Feature 2", "Feature 3"],
|
||||||
|
"targetAudience": ["Students", "Professionals", "Families"],
|
||||||
|
"seasonality": "One of: Year-round, Summer, Winter, Spring, Fall, Holiday",
|
||||||
|
"insuranceRecommended": true,
|
||||||
|
"depositSuggestion": 100.00
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = await model.generateContent({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [
|
||||||
|
{ text: prompt },
|
||||||
|
{
|
||||||
|
inlineData: {
|
||||||
|
data: base64_data,
|
||||||
|
mimeType: mime_type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.response.text();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI entrypoint for local testing:
|
||||||
|
* node index.js https://example.com/image.jpg
|
||||||
|
*/
|
||||||
|
const main = async () => {
|
||||||
|
const image_url = process.argv[2];
|
||||||
|
|
||||||
|
if (!image_url) {
|
||||||
|
console.error("Usage: node index.js <image_url>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, fetch categories from Firestore
|
||||||
|
console.log("Fetching categories from Firestore...");
|
||||||
|
const categories = await fetch_categories_from_firestore();
|
||||||
|
console.log("Categories found:", categories);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
console.log("Fetching and describing image from:", image_url);
|
||||||
|
const description = await describe_image_from_url(image_url);
|
||||||
|
|
||||||
|
console.log("\n=== GEMINI DESCRIPTION ===\n");
|
||||||
|
console.log(description);
|
||||||
|
console.log("\n==========================\n");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error describing image:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run main only if called directly: `node index.js ...`
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Firebase Cloud Function
|
||||||
|
// Call this from your Flutter app
|
||||||
|
exports.analyzeRentalItem = functions.https.onRequest(async (req, res) => {
|
||||||
|
// Enable CORS
|
||||||
|
res.set("Access-Control-Allow-Origin", "*");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.set("Access-Control-Allow-Methods", "POST");
|
||||||
|
res.set("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
res.set("Access-Control-Max-Age", "3600");
|
||||||
|
return res.status(204).send("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return res.status(405).json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { imageUrl } = req.body;
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return res.status(400).json({ error: "Missing imageUrl in request body" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await describe_image_from_url(imageUrl);
|
||||||
|
|
||||||
|
// Parse the JSON response from Gemini
|
||||||
|
const parsed_result = JSON.parse(result);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: parsed_result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error analyzing rental item:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export helper function for local testing
|
||||||
|
module.exports.describe_image_from_url = describe_image_from_url;
|
||||||
2809
package-lock.json
generated
Normal file
2809
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
package.json
Normal file
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "gemini-image-url-test",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"firebase-admin": "^13.6.0",
|
||||||
|
"firebase-functions": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user