Skip to content

Commit 19cc170

Browse files
authored
Merge pull request #85 from vilpessoa/claude/wonderful-sagan-3tJ90
feat: assistente de IA (Gemini) na toolbar do editor DAX
2 parents e15f810 + eb1d6ab commit 19cc170

10 files changed

Lines changed: 499 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# URL pública do proxy serverless que encapsula a Gemini API.
2+
# A GEMINI_API_KEY NUNCA deve ser colocada no frontend — ela vive apenas
3+
# nas variáveis de ambiente do provedor serverless (Vercel/Netlify/Cloudflare).
4+
VITE_GEMINI_PROXY_URL=https://your-proxy.vercel.app/api/gemini

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ Ele tokeniza o código, monta uma AST por descida recursiva e avalia para produz
2828
do `RETURN`. Funções de tabela (ALL, FILTER, ADDCOLUMNS, TOPN, RANKX, CONCATENATEX, SUMX, etc.)
2929
operam sobre tabelas fictícias geradas a partir dos JSONs em `src/data/`.
3030

31+
## Assistente de IA (Gemini)
32+
33+
A toolbar do editor inclui um botão **Sparkles** que aciona o assistente Gemini para
34+
**Refatorar**, **Indentar**, **Comentar** ou **Corrigir** o código DAX atual.
35+
36+
### Configuração
37+
38+
Por segurança, a `GEMINI_API_KEY` **nunca** é exposta no bundle do frontend.
39+
O app chama um **proxy serverless** que guarda a chave server-side.
40+
41+
1. Faça deploy do proxy de exemplo em `docs/serverless-proxy-example.ts`
42+
(Vercel/Netlify/Cloudflare). Defina no provedor:
43+
- `GEMINI_API_KEY=<sua chave>`
44+
- `ALLOWED_ORIGIN=https://seu-app...` (opcional)
45+
2. Copie `.env.example` para `.env.local` e ajuste:
46+
```
47+
VITE_GEMINI_PROXY_URL=https://seu-proxy.vercel.app/api/gemini
48+
```
49+
3. Reinicie `npm run dev`. O prompt do sistema vive em
50+
`src/assets/prompts/dax_analyst_prompt.txt` e pode ser editado livremente.
51+
3152
## Atalhos
3253

3354
- `Ctrl+Enter` — renderizar

api/gemini.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Proxy serverless — Vercel detecta automaticamente arquivos em /api
2+
// Guarda GEMINI_API_KEY nas variáveis de ambiente do Vercel (nunca no Git).
3+
4+
export const config = { runtime: 'edge' };
5+
6+
interface ProxyBody {
7+
action: string;
8+
code: string;
9+
history?: { role: 'user' | 'model'; text: string }[];
10+
systemInstruction: string;
11+
actionContext: string;
12+
model?: string;
13+
}
14+
15+
export default async function handler(req: Request): Promise<Response> {
16+
const origin = req.headers.get('origin') ?? '*';
17+
const cors = {
18+
'Access-Control-Allow-Origin': origin,
19+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
20+
'Access-Control-Allow-Headers': 'Content-Type',
21+
};
22+
23+
if (req.method === 'OPTIONS') return new Response(null, { headers: cors });
24+
if (req.method !== 'POST') {
25+
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
26+
status: 405,
27+
headers: { ...cors, 'Content-Type': 'application/json' },
28+
});
29+
}
30+
31+
const apiKey = process.env.GEMINI_API_KEY;
32+
if (!apiKey) {
33+
return new Response(JSON.stringify({ error: 'GEMINI_API_KEY não configurada.' }), {
34+
status: 401,
35+
headers: { ...cors, 'Content-Type': 'application/json' },
36+
});
37+
}
38+
39+
let body: ProxyBody;
40+
try {
41+
body = (await req.json()) as ProxyBody;
42+
} catch {
43+
return new Response(JSON.stringify({ error: 'JSON inválido.' }), {
44+
status: 400,
45+
headers: { ...cors, 'Content-Type': 'application/json' },
46+
});
47+
}
48+
49+
const model = body.model ?? 'gemini-2.5-flash';
50+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
51+
52+
const contents = [
53+
...(body.history ?? []).map((h) => ({
54+
role: h.role,
55+
parts: [{ text: h.text }],
56+
})),
57+
{
58+
role: 'user',
59+
parts: [{ text: `${body.actionContext}\n\nCódigo:\n${body.code}` }],
60+
},
61+
];
62+
63+
let upstream: Response;
64+
try {
65+
upstream = await fetch(url, {
66+
method: 'POST',
67+
headers: { 'Content-Type': 'application/json' },
68+
body: JSON.stringify({
69+
systemInstruction: { parts: [{ text: body.systemInstruction }] },
70+
contents,
71+
}),
72+
});
73+
} catch {
74+
return new Response(JSON.stringify({ error: 'Falha ao conectar à Gemini API.' }), {
75+
status: 502,
76+
headers: { ...cors, 'Content-Type': 'application/json' },
77+
});
78+
}
79+
80+
if (!upstream.ok) {
81+
if (upstream.status === 429) {
82+
const text = await upstream.text();
83+
const scope = /per day|daily/i.test(text) ? 'daily' : 'minute';
84+
return new Response(JSON.stringify({ error: 'Rate limited' }), {
85+
status: 429,
86+
headers: { ...cors, 'Content-Type': 'application/json', 'X-Quota-Scope': scope },
87+
});
88+
}
89+
if (upstream.status === 401 || upstream.status === 403) {
90+
return new Response(JSON.stringify({ error: 'Chave inválida.' }), {
91+
status: upstream.status,
92+
headers: { ...cors, 'Content-Type': 'application/json' },
93+
});
94+
}
95+
return new Response(JSON.stringify({ error: `Gemini API retornou ${upstream.status}` }), {
96+
status: 502,
97+
headers: { ...cors, 'Content-Type': 'application/json' },
98+
});
99+
}
100+
101+
const data = await upstream.json();
102+
const text: string =
103+
data?.candidates?.[0]?.content?.parts
104+
?.map((p: { text?: string }) => p.text ?? '')
105+
.join('') ?? '';
106+
107+
return new Response(JSON.stringify({ text }), {
108+
status: 200,
109+
headers: { ...cors, 'Content-Type': 'application/json' },
110+
});
111+
}

docs/serverless-proxy-example.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Exemplo de proxy serverless para a Gemini API — pronto para Vercel
2+
// (`api/gemini.ts`). Adapte para Netlify Functions ou Cloudflare Workers
3+
// conforme necessário.
4+
//
5+
// Defina no provedor (NUNCA no Git):
6+
// GEMINI_API_KEY=<sua-chave>
7+
// ALLOWED_ORIGIN=https://seu-app.vercel.app (opcional, default *)
8+
//
9+
// O frontend define VITE_GEMINI_PROXY_URL apontando para esta função.
10+
11+
export const config = { runtime: 'edge' };
12+
13+
interface ProxyBody {
14+
action: 'refactor' | 'indent' | 'comment' | 'fix';
15+
code: string;
16+
history?: { role: 'user' | 'model'; text: string }[];
17+
systemInstruction: string;
18+
actionContext: string;
19+
model?: string;
20+
}
21+
22+
function corsHeaders(origin: string) {
23+
return {
24+
'Access-Control-Allow-Origin': origin,
25+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
26+
'Access-Control-Allow-Headers': 'Content-Type',
27+
};
28+
}
29+
30+
export default async function handler(req: Request): Promise<Response> {
31+
const allowOrigin = process.env.ALLOWED_ORIGIN ?? '*';
32+
const headers = { ...corsHeaders(allowOrigin), 'Content-Type': 'application/json' };
33+
34+
if (req.method === 'OPTIONS') return new Response(null, { headers });
35+
if (req.method !== 'POST') {
36+
return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers });
37+
}
38+
39+
const apiKey = process.env.GEMINI_API_KEY;
40+
if (!apiKey) {
41+
return new Response(JSON.stringify({ error: 'GEMINI_API_KEY missing' }), { status: 401, headers });
42+
}
43+
44+
let body: ProxyBody;
45+
try {
46+
body = (await req.json()) as ProxyBody;
47+
} catch {
48+
return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers });
49+
}
50+
51+
const model = body.model ?? 'gemini-2.5-flash';
52+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
53+
54+
const contents = [
55+
...(body.history ?? []).map((h) => ({
56+
role: h.role,
57+
parts: [{ text: h.text }],
58+
})),
59+
{
60+
role: 'user',
61+
parts: [{ text: `${body.actionContext}\n\nCódigo:\n${body.code}` }],
62+
},
63+
];
64+
65+
const upstream = await fetch(url, {
66+
method: 'POST',
67+
headers: { 'Content-Type': 'application/json' },
68+
body: JSON.stringify({
69+
systemInstruction: { parts: [{ text: body.systemInstruction }] },
70+
contents,
71+
}),
72+
});
73+
74+
if (!upstream.ok) {
75+
// Repasse de 429 com classificação minuto vs. diário (heurística simples
76+
// baseada na mensagem da API; ajuste conforme necessidade).
77+
if (upstream.status === 429) {
78+
const text = await upstream.text();
79+
const scope = /per day|daily/i.test(text) ? 'daily' : 'minute';
80+
return new Response(JSON.stringify({ error: 'Rate limited' }), {
81+
status: 429,
82+
headers: { ...headers, 'X-Quota-Scope': scope },
83+
});
84+
}
85+
if (upstream.status === 401 || upstream.status === 403) {
86+
return new Response(JSON.stringify({ error: 'Auth failed' }), {
87+
status: upstream.status,
88+
headers,
89+
});
90+
}
91+
return new Response(JSON.stringify({ error: `Upstream ${upstream.status}` }), {
92+
status: 502,
93+
headers,
94+
});
95+
}
96+
97+
const data = await upstream.json();
98+
const text = data?.candidates?.[0]?.content?.parts?.map((p: { text?: string }) => p.text ?? '').join('') ?? '';
99+
100+
return new Response(JSON.stringify({ text }), { status: 200, headers });
101+
}

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,8 @@ export default function App() {
317317
onDaxEditorThemeChange={onDaxEditorThemeChange}
318318
searchOpen={searchOpen}
319319
onToggleSearch={() => setSearchOpen((v) => !v)}
320+
code={code}
321+
onAiApply={setCode}
320322
/>
321323
<AnimatePresence>
322324
{searchOpen && (
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Você é um analista e engenheiro de BI sênior especialista em otimização de código DAX para Power BI.
2+
Sua função é receber um trecho de código DAX e aplicar a ação solicitada de forma limpa, elegante e performática.
3+
4+
DIRETRIZES OBRIGATÓRIAS DE RETORNO:
5+
1. Retorne APENAS o código DAX resultante dentro de um bloco de código markdown padrão. Não adicione saudações, introduções ou explicações textuais fora do código, exceto se solicitado explicitamente pelas regras de sugestão.
6+
2. Siga as boas práticas de espaçamento e indentação padrão internacional do DAX Formatter.
7+
3. Se a ação incluir "Comentar", insira comentários curtos em linha usando '//' apenas onde houver alterações ou pontos críticos de lógica.
8+
4. Mantenha os nomes das variáveis internas originais (ex: _cor_alavancavel, _cor_gargalo) intactos, a menos que uma refatoração estrutural de nomenclatura seja explicitamente mandatória por performance.

src/components/DaxEditorToolbar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '@/components/ui/dropdown';
2626
import * as DM from '@radix-ui/react-dropdown-menu';
2727
import { ZOOM_MIN, ZOOM_MAX } from '@/components/ZoomControls';
28+
import { AIAssistantDropdown } from '@/components/ai/AIAssistantDropdown';
2829
import type { DaxEditorTheme } from '@/lib/storage';
2930

3031
const EDITOR_THEMES: { key: DaxEditorTheme; label: string; dot1: string; dot2: string }[] = [
@@ -48,6 +49,8 @@ interface DaxEditorToolbarProps {
4849
onDaxEditorThemeChange: (t: DaxEditorTheme) => void;
4950
searchOpen: boolean;
5051
onToggleSearch: () => void;
52+
code: string;
53+
onAiApply: (newCode: string) => void;
5154
}
5255

5356
function VDivider() {
@@ -104,6 +107,8 @@ export function DaxEditorToolbar({
104107
onDaxEditorThemeChange,
105108
searchOpen,
106109
onToggleSearch,
110+
code,
111+
onAiApply,
107112
}: DaxEditorToolbarProps) {
108113
const clampZoom = (v: number) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, v));
109114

@@ -151,6 +156,11 @@ export function DaxEditorToolbar({
151156
Pesquisar ou Substituir
152157
</TooltipContent>
153158
</Tooltip>
159+
160+
<VDivider />
161+
162+
{/* 6. Assistente IA */}
163+
<AIAssistantDropdown code={code} onApply={onAiApply} />
154164
</div>
155165

156166
{/* Right: Zoom controls and Theme picker */}

0 commit comments

Comments
 (0)