Il contesto
PanelControl è il gestionale interno usato dal team commerciale per tracciare ordini, lead, attivazioni e bonus mensili. Tutti i dati vivono su Firebase Realtime Database. Il team fa quotidianamente domande ripetitive: chi ha venduto di più questo mese? Quante attivazioni mancano per raggiungere la soglia bonus? Come funziona la procedura X?
L'idea è stata aggiungere un pulsante ✦ Chiedi all'AI che apra un pannello di conversazione, stesso stile glassmorphism già presente nel gestionale, e che risponda con piena consapevolezza del contesto aziendale e dei dati live del mese corrente.
Il punto tecnico centrale: un modello AI non sa nulla del tuo gestionale. Devi tu costruire il contesto e passarglielo ad ogni domanda nel system prompt. Questo articolo documenta come è stato fatto, compreso il processo di scelta dell'API e i problemi di versioning dei modelli Gemini.
The context
PanelControl is the internal management panel used by the commercial team to track orders, leads, activations and monthly bonuses. All data lives in Firebase Realtime Database. The team asks the same repetitive questions every day: who sold the most this month? How many activations are missing to reach the bonus threshold? How does procedure X work?
The idea was to add a ✦ Ask AI button that opens a conversation panel — same glassmorphism style already present in the panel — responding with full awareness of the business context and the current month's live data.
The key technical point: an AI model knows nothing about your management panel. You have to build the context and pass it with every question in the system prompt. This article documents how that was done, including the API selection process and Gemini model versioning issues.
Gemini API vs Anthropic API: la scelta
La prima valutazione è stata quale API usare. Le opzioni principali erano due: Google Gemini API (tramite generativelanguage.googleapis.com) e Anthropic API (tramite api.anthropic.com).
Gemini API vs Anthropic API: the choice
The first evaluation was which API to use. The two main options were: Google Gemini API (via generativelanguage.googleapis.com) and Anthropic API (via api.anthropic.com).
| Gemini API (Google) | Anthropic API | |
|---|---|---|
| Free tier | 1.500 req/giorno su Gemini Flash | $5 di credito iniziale |
| Fatturazione | Account Google Cloud richiesto (carta non addebitata nel free tier) | Separata dall'abbonamento claude.ai |
| Pattern chiamata | fetch su endpoint REST |
fetch su endpoint REST |
| CORS in Netlify | Nessun blocco (dominio pubblico) | Già in whitelist Netlify |
La scelta è ricaduta su Gemini per il free tier più generoso per un uso interno leggero (poche decine di domande al giorno). Un punto importante: Google Cloud richiede un account di fatturazione anche per usare il piano gratuito, ma inserire la carta non comporta addebiti finché si resta nel free tier.
The choice fell on Gemini for the more generous free tier for light internal use (a few dozen questions per day). An important point: Google Cloud requires a billing account even to use the free plan, but adding a card incurs no charges as long as you stay within the free tier.
La chiamata fetch: nessuna libreria necessaria
La Gemini API si chiama con una semplice fetch POST. Il corpo della richiesta contiene il prompt nel campo contents. La risposta torna in candidates[0].content.parts[0].text.
The fetch call: no library needed
The Gemini API is called with a simple fetch POST. The request body contains the prompt in the contents field. The response comes back in candidates[0].content.parts[0].text.
async function askGemini(userMessage, systemPrompt) {
const GEMINI_KEY = '[GEMINI_API_KEY]';
const MODEL = 'gemini-2.5-flash-lite';
const ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_KEY}`;
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: systemPrompt }] },
contents: [{ parts: [{ text: userMessage }] }]
})
});
const data = await response.json();
// Gestione errore modello non disponibile
if (!response.ok) {
const msg = data?.error?.message || 'Errore API';
throw new Error(msg);
}
return data.candidates[0].content.parts[0].text;
}
Non mettere mai la API key direttamente nel frontend di un'app pubblica — chiunque apra il sorgente la vedrebbe. Per un gestionale interno ad accesso limitato è un tradeoff accettabile. In alternativa: salvarla come variabile d'ambiente su Netlify e fare le chiamate tramite una Netlify Function proxy.
Il problema del versioning modelli Gemini
Il primo ostacolo non è stato tecnico, ma di disponibilità. Gemini depreca i modelli rapidamente per i nuovi account. La sequenza di errori incontrata:
gemini-2.0-flash→ "no longer available to new users"gemini-2.0-flash-lite→ stesso erroregemini-2.5-flash-lite→ funzionante, free tier attivo
La lezione: non fidarsi del nome del modello hardcodato in un tutorial. Prima di integrare, verificare sempre su ai.google.dev/gemini-api/docs/models quali modelli sono disponibili per il proprio account e piano. Anche il formato della API key conta: le chiavi Gemini iniziano sempre con AIzaSy, non con altri prefissi.
The Gemini model versioning problem
The first obstacle wasn't technical, but one of availability. Gemini deprecates models quickly for new accounts. The error sequence encountered:
gemini-2.0-flash→ "no longer available to new users"gemini-2.0-flash-lite→ same errorgemini-2.5-flash-lite→ working, free tier active
The lesson: don't trust a hardcoded model name from a tutorial. Before integrating, always verify on ai.google.dev/gemini-api/docs/models which models are available for your account and plan. The API key format matters too: Gemini keys always start with AIzaSy, not other prefixes.
Il pulsante UI e il pannello glassmorphism
Il pulsante ✦ Chiedi all'AI è posizionato sopra il pulsante chat già esistente nel gestionale, con gradiente verde/teal per distinguersi visivamente dal blu della chat. Al click apre un pannello laterale con la stessa estetica glassmorphism del resto dell'interfaccia.
Il pannello contiene: header con icona e identificativo del provider AI, area messaggi con typing indicator animato, suggerimenti rapidi cliccabili al primo avvio, e un campo input con auto-resize e invio con Enter.
The UI button and glassmorphism panel
The ✦ Ask AI button is positioned above the existing chat button in the management panel, with a green/teal gradient to visually distinguish it from the chat's blue. On click it opens a side panel with the same glassmorphism aesthetic as the rest of the interface.
The panel contains: a header with icon and AI provider identifier, a message area with animated typing indicator, quick suggestion chips clickable on first launch, and an auto-resize input field with Enter to send.
<!-- Pulsante trigger -->
<button id="aiBtn" class="fab-btn ai-fab">
<span class="fab-label">✦ Chiedi all'AI</span>
<span class="fab-icon">✦</span>
</button>
<!-- Pannello conversazione -->
<div id="aiPanel" class="ai-panel hidden">
<div class="ai-panel-header">
<span>✦ Assistente AI · [PROVIDER]</span>
<button onclick="closeAiPanel()">✕</button>
</div>
<div id="aiMessages" class="ai-messages"></div>
<div class="ai-input-row">
<textarea id="aiInput" rows="1" placeholder="Scrivi una domanda..."></textarea>
<button onclick="sendAiMessage()">↑</button>
</div>
</div>
Il system prompt dinamico: dati live da Firebase
Il punto più interessante dell'integrazione è la costruzione del system prompt. Ad ogni domanda, prima di inviare la richiesta all'API, viene assemblato un blocco di testo che serializza lo stato corrente del gestionale: utente connesso, mese corrente, classifica operatori con ordini per categoria, totale lead e attivazioni, soglie bonus.
Tutti questi dati sono già in memoria nel gestionale perché Firebase li carica all'avvio tramite listener onValue. Il system prompt li legge dallo stato JavaScript esistente — nessuna chiamata aggiuntiva al database.
The dynamic system prompt: live data from Firebase
The most interesting part of the integration is building the system prompt. With every question, before sending the request to the API, a text block is assembled that serializes the current state of the management panel: logged-in user, current month, operator rankings with orders by category, total leads and activations, bonus thresholds.
All this data is already in memory in the management panel because Firebase loads it at startup via onValue listeners. The system prompt reads from the existing JavaScript state — no additional database calls.
function buildSystemPrompt() {
const mese = getCurrentMonthLabel(); // es. "Giugno 2026"
const utente = sessionStorage.getItem('panelUser') || 'Sconosciuto';
// Classifica operatori con totali per categoria
const rankingText = Object.entries(state.operatori)
.map(([nome, dati]) =>
`${nome}: ${dati.totale} ordini (Cat-A: ${dati.catA}, Cat-B: ${dati.catB})`
).join('\n');
return `Sei un assistente interno per il team commerciale di [AZIENDA].
Rispondi sempre in italiano, in modo diretto e professionale.
=== CONTESTO ATTUALE ===
Utente connesso: ${utente}
Mese di riferimento: ${mese}
Sezione aperta: ${state.sezioneAttiva}
=== OPERATORI E RISULTATI ===
${rankingText}
=== KPI MENSILI ===
Lead totali: ${state.leadTotali}
Attivazioni: ${state.attivazioni}
Annullati: ${state.annullati}
Soglia bonus: ${state.sogliaBonus} attivazioni
=== CONOSCENZA AZIENDALE ===
[Qui va la conoscenza statica — vedi sezione successiva]
`;
}
La chiave è che il modello riceve ogni volta un system prompt fresco con lo snapshot aggiornato dei dati. Non c'è memoria tra una sessione e l'altra, ma all'interno della conversazione i messaggi precedenti vengono accodati nell'array contents per mantenere il filo del dialogo.
Conoscenza statica: il "manuale aziendale" nel prompt
Oltre ai dati live, il system prompt include un blocco di conoscenza statica che non cambia: prodotti e tariffe commercializzati dall'azienda, procedure operative (come confermare un'email a un cliente, come registrare un'attivazione), ruoli del team, glossario dei termini interni.
Questo blocco è scritto direttamente come stringa JavaScript nel codice del gestionale. Non viene letto da Firebase — è parte del sorgente. Il vantaggio: nessuna latenza aggiuntiva. Lo svantaggio: aggiornarlo richiede un deploy.
Il bilanciamento giusto è mettere nel blocco statico le cose che cambiano raramente (struttura prodotti, procedure core, glossario) e lasciare ai dati Firebase tutto quello che cambia quotidianamente (chi ha venduto cosa, lead del mese, target raggiunto).
Static knowledge: the "company manual" in the prompt
In addition to live data, the system prompt includes a static knowledge block that doesn't change: products and pricing plans sold by the company, operational procedures (how to confirm a customer email, how to register an activation), team roles, glossary of internal terms.
This block is written directly as a JavaScript string in the management panel's source code. It's not read from Firebase — it's part of the source. The advantage: no additional latency. The disadvantage: updating it requires a deploy.
The right balance is putting in the static block the things that change rarely (product structure, core procedures, glossary) and leaving to Firebase data everything that changes daily (who sold what, month's leads, target reached).
const STATIC_KNOWLEDGE = `
=== PRODOTTI ===
[PRODOTTO_A]: terminale POS portatile. Piani disponibili: [PIANO_BASE] (€0/mese, commissione 1,20%),
[PIANO_PRO] (€12/mese, commissione 0,95%), [PIANO_CUSTOM] (negoziato).
[PRODOTTO_B]: terminale fisso per banco. Disponibile solo a noleggio.
[PRODOTTO_C]: conto business + carta prepagata. Piani: Freemium (gratuito),
Smart (€9/mese), Business (€25/mese).
=== PROCEDURE ===
Conferma email cliente:
1. Aprire il portale [PORTALE_INTERNO]
2. Cercare il cliente per codice fiscale o ragione sociale
3. Inviare il link di verifica dalla sezione "Comunicazioni"
4. Attendere la conferma (solitamente entro 24h)
Registrazione attivazione:
1. Inserire il numero pratica nel gestionale
2. Verificare che lo stato sia "Attivo" sul portale provider
3. Aggiornare il record nel database con data attivazione
=== GLOSSARIO ===
Lead: contatto commerciale acquisito, non ancora convertito
Attivazione: contratto firmato e servizio attivo sul portale
Annullato: pratica ritirata dal cliente o rifiutata dal provider
[TERMINE_INTERNO_1]: sistema di firma contratti digitale
[TERMINE_INTERNO_2]: piattaforma CRM per la gestione lead
`;
Conversazione multi-turn: accodare i messaggi
Gemini API non ha memoria propria. Per simulare una conversazione con più scambi, ogni chiamata successiva deve includere nell'array contents tutti i messaggi precedenti — alternando role: "user" e role: "model".
Multi-turn conversation: queuing messages
The Gemini API has no memory of its own. To simulate a conversation with multiple exchanges, each subsequent call must include in the contents array all previous messages — alternating role: "user" and role: "model".
let conversationHistory = []; // resettata all'apertura del pannello
async function sendAiMessage() {
const userText = document.getElementById('aiInput').value.trim();
if (!userText) return;
// Aggiunge il turno utente alla storia
conversationHistory.push({
role: 'user',
parts: [{ text: userText }]
});
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_instruction: { parts: [{ text: buildSystemPrompt() }] },
contents: conversationHistory // intera storia ad ogni chiamata
})
});
const data = await response.json();
const aiText = data.candidates[0].content.parts[0].text;
// Aggiunge la risposta del modello alla storia
conversationHistory.push({
role: 'model',
parts: [{ text: aiText }]
});
renderMessage('ai', aiText);
}
Attenzione alla dimensione del contesto: con conversazioni molto lunghe, l'array contents cresce e ogni chiamata diventa più pesante. Per un uso interno con domande brevi non è un problema, ma è buona pratica resettare la storia al riavvio del pannello o ogni N turni.
Cosa abbiamo imparato
- Il system prompt è il prodotto. La qualità delle risposte dipende quasi interamente da quanto è ben strutturato il contesto che passi al modello. L'AI non è magica — risponde con quello che le dai.
- I modelli Gemini si deprecano rapidamente. Non hardcodare il nome del modello senza verificare la disponibilità corrente. Tenerlo in una costante facile da aggiornare e documentare quale modello si usa e perché.
- Dati live + conoscenza statica = il giusto mix. Separare quello che cambia ogni giorno (dati Firebase) da quello che cambia raramente (procedure, glossario) rende il prompt mantenibile nel tempo.
- Per app interne, la sicurezza della API key è un tradeoff. La soluzione ideale è una Netlify Function proxy che nasconde la chiave dal frontend. Per un gestionale con accesso limitato e un budget mensile impostato sulla console del provider, il rischio è accettabile.
- Nessuna libreria necessaria. Una fetch, un JSON e un po' di DOM sono tutto quello che serve per integrare un modello AI in un'applicazione vanilla JavaScript esistente.
What we learned
- The system prompt is the product. Response quality depends almost entirely on how well-structured the context you pass to the model is. AI isn't magic — it responds with what you give it.
- Gemini models deprecate quickly. Don't hardcode the model name without verifying current availability. Keep it in an easy-to-update constant and document which model you're using and why.
- Live data + static knowledge = the right mix. Separating what changes every day (Firebase data) from what changes rarely (procedures, glossary) keeps the prompt maintainable over time.
- For internal apps, API key security is a tradeoff. The ideal solution is a Netlify Function proxy that hides the key from the frontend. For a management panel with limited access and a monthly budget set on the provider's console, the risk is acceptable.
- No library needed. A fetch, some JSON and a bit of DOM manipulation are all you need to integrate an AI model into an existing vanilla JavaScript application.