| import dotenv from 'dotenv'; |
| import express from 'express'; |
| import { readFile } from 'node:fs/promises'; |
| import { readdirSync, existsSync } from 'node:fs'; |
| import path from 'node:path'; |
| import { fileURLToPath } from 'node:url'; |
| import { GoogleGenAI } from '@google/genai'; |
|
|
| const __filename = fileURLToPath(import.meta.url); |
| const __dirname = path.dirname(__filename); |
|
|
| dotenv.config({ path: path.join(__dirname, 'local.env') }); |
|
|
| const app = express(); |
| const port = process.env.PORT || 3000; |
| const modelName = process.env.GEMINI_MODEL || 'gemini-2.5-flash'; |
|
|
| app.use(express.json({ limit: '7mb' })); |
| app.use(express.static(path.join(__dirname, 'public'))); |
|
|
| app.get('/api/health', (_req, res) => { |
| res.json({ |
| ok: true, |
| model: modelName, |
| hasGeminiKey: Boolean(process.env.GEMINI_API_KEY) |
| }); |
| }); |
|
|
| app.post('/api/generate', async (req, res) => { |
| try { |
| if (!process.env.GEMINI_API_KEY) { |
| return res.status(400).json({ |
| error: 'Missing GEMINI_API_KEY. Add it to local.env locally and to Railway variables before deployment.' |
| }); |
| } |
|
|
| const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); |
| const { |
| artworkName = '', |
| mode = 'Beginner', |
| question = '', |
| image, |
| useKnowledgeBase = false |
| } = req.body || {}; |
|
|
| if (!artworkName.trim() && !question.trim() && !image?.data) { |
| return res.status(400).json({ error: 'Please enter an artwork name, ask a question, or upload an image.' }); |
| } |
|
|
| const query = [artworkName, question].filter(Boolean).join(' '); |
| const context = useKnowledgeBase ? await retrieveContext(query) : ''; |
| const prompt = buildPrompt({ artworkName, mode, question, context, hasImage: Boolean(image?.data) }); |
| const contents = buildContents({ prompt, image }); |
|
|
| const response = await ai.models.generateContent({ |
| model: modelName, |
| contents |
| }); |
|
|
| res.json({ |
| answer: response.text, |
| usedKnowledgeBase: Boolean(context), |
| model: modelName |
| }); |
| } catch (error) { |
| console.error(error); |
| res.status(500).json({ |
| error: error.message || 'The model call failed. Check your API key, network, and server logs.' |
| }); |
| } |
| }); |
|
|
| app.post('/api/followup', async (req, res) => { |
| try { |
| if (!process.env.GEMINI_API_KEY) { |
| return res.status(400).json({ |
| error: 'Missing GEMINI_API_KEY. Add it to local.env locally and to Railway variables before deployment.' |
| }); |
| } |
|
|
| const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); |
| const { |
| artworkName = '', |
| mode = 'Beginner', |
| originalQuestion = '', |
| originalAnswer = '', |
| followupQuestion = '', |
| image, |
| useKnowledgeBase = false |
| } = req.body || {}; |
|
|
| if (!followupQuestion.trim()) { |
| return res.status(400).json({ error: 'Please enter a follow-up question.' }); |
| } |
|
|
| if (!originalAnswer.trim()) { |
| return res.status(400).json({ error: 'Please generate the main artwork explanation first.' }); |
| } |
|
|
| const query = [artworkName, originalQuestion, followupQuestion].filter(Boolean).join(' '); |
| const context = useKnowledgeBase ? await retrieveContext(query) : ''; |
| const prompt = buildFollowupPrompt({ |
| artworkName, |
| mode, |
| originalQuestion, |
| originalAnswer, |
| followupQuestion, |
| context, |
| hasImage: Boolean(image?.data) |
| }); |
| const contents = buildContents({ prompt, image }); |
|
|
| const response = await ai.models.generateContent({ |
| model: modelName, |
| contents |
| }); |
|
|
| res.json({ |
| answer: response.text, |
| usedKnowledgeBase: Boolean(context), |
| model: modelName |
| }); |
| } catch (error) { |
| console.error(error); |
| res.status(500).json({ |
| error: error.message || 'The follow-up model call failed. Check your API key, network, and server logs.' |
| }); |
| } |
| }); |
|
|
| function buildPrompt({ artworkName, mode, question, context, hasImage }) { |
| const sourceBlock = context |
| ? `Use this source material when it is relevant. If it conflicts with the visible artwork or widely known art history, explain the uncertainty simply.\\n\\nSOURCE MATERIAL:\\n${context}\\n\\n` |
| : ''; |
| const systemPrompt = `You are Explain Art to Me, an empathetic art guide for people without formal art training. |
| |
| Your job is to explain artwork in simple, friendly, emotionally relatable language. Avoid unnecessary art jargon. If a technical term is useful, define it in plain English. Do not pretend to know facts that are uncertain; say when you are making a careful guess from the image or title. Keep the first explanation warm but fairly compact so it is easy to scan. |
| |
| Explanation mode: ${mode} |
| Artwork title or clue: ${artworkName || hasImage ? artworkName || 'Uploaded image' : 'Not provided'}`; |
|
|
| return `${sourceBlock}${systemPrompt} |
| |
| User question: |
| ${question || 'Explain this artwork in a friendly way.'} |
| |
| Respond with: |
| 1. Artist in brief |
| 2. What you are looking at |
| 3. Emotions and symbols |
| 4. Why it is famous |
| 5. Personal or historical context |
| |
| Use plain text section labels exactly like "Artist in brief:" without Markdown heading marks, asterisks, or bullet symbols. |
| Keep each section to 2 or 3 concise sentences. |
| End with one short line that helps the user look at the artwork again with fresh eyes.`; |
| } |
|
|
| function buildFollowupPrompt({ |
| artworkName, |
| mode, |
| originalQuestion, |
| originalAnswer, |
| followupQuestion, |
| context, |
| hasImage |
| }) { |
| const sourceBlock = context |
| ? `Use this source material when it is relevant. If it conflicts with the visible artwork, the previous answer, or widely known art history, explain the uncertainty simply.\\n\\nSOURCE MATERIAL:\\n${context}\\n\\n` |
| : ''; |
|
|
| return `${sourceBlock}You are Explain Art to Me, an empathetic art guide for people without formal art training. |
| |
| Continue the same simple, friendly, emotionally relatable tone from the initial explanation. Answer the user's follow-up as part of an art conversation. Avoid unnecessary art jargon. If a technical term is useful, define it in plain English. If the answer is uncertain, say so gently. |
| |
| Explanation mode: ${mode} |
| Artwork title or clue: ${artworkName || hasImage ? artworkName || 'Uploaded image' : 'Not provided'} |
| |
| Original user question: |
| ${originalQuestion || 'Explain this artwork in a friendly way.'} |
| |
| Initial explanation: |
| ${originalAnswer} |
| |
| Follow-up question: |
| ${followupQuestion} |
| |
| Respond with a focused answer to the follow-up question in 2 to 4 short paragraphs. Use plain text only, without Markdown heading marks, asterisks, or bullet symbols.`; |
| } |
|
|
| function buildContents({ prompt, image }) { |
| if (!image?.data || !image?.mimeType) return prompt; |
|
|
| return [ |
| { |
| role: 'user', |
| parts: [ |
| { text: prompt }, |
| { |
| inlineData: { |
| mimeType: image.mimeType, |
| data: image.data |
| } |
| } |
| ] |
| } |
| ]; |
| } |
|
|
| async function retrieveContext(query) { |
| const docsDir = path.join(__dirname, 'docs'); |
| if (!existsSync(docsDir)) return ''; |
|
|
| const files = readdirSync(docsDir).filter((name) => /\\.(md|txt)$/i.test(name)); |
| const chunks = []; |
|
|
| for (const file of files) { |
| const text = await readFile(path.join(docsDir, file), 'utf8'); |
| for (const chunk of chunkText(text)) { |
| chunks.push({ file, chunk, score: scoreChunk(query, chunk) }); |
| } |
| } |
|
|
| return chunks |
| .filter((item) => item.score > 0) |
| .sort((a, b) => b.score - a.score) |
| .slice(0, 3) |
| .map((item) => `[${item.file}]\\n${item.chunk}`) |
| .join('\\n\\n---\\n\\n'); |
| } |
|
|
| function chunkText(text) { |
| return text |
| .split(/\\n\\s*\\n/g) |
| .map((chunk) => chunk.trim()) |
| .filter(Boolean) |
| .slice(0, 30); |
| } |
|
|
| function scoreChunk(query, chunk) { |
| const queryTerms = new Set( |
| query |
| .toLowerCase() |
| .split(/[^a-z0-9]+/) |
| .filter((term) => term.length > 3) |
| ); |
|
|
| if (!queryTerms.size) return 0; |
| const lowerChunk = chunk.toLowerCase(); |
| let score = 0; |
| for (const term of queryTerms) { |
| if (lowerChunk.includes(term)) score += 1; |
| } |
| return score; |
| } |
|
|
| app.listen(port, () => { |
| console.log(`App running on http://localhost:${port}`); |
| }); |
|
|