Skip to content

Commit 5f9cdb9

Browse files
authored
Merge pull request #20 from tinybirdco/api-key
Api key
2 parents da5fbcb + 87981a4 commit 5f9cdb9

File tree

8 files changed

+232
-40
lines changed

8 files changed

+232
-40
lines changed

dashboard/ai-analytics/src/app/api/extract-cost-parameters/route.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ const todayDateOnly = today.split(' ')[0]; // Just the date part: yyyy-MM-dd
9494
// Update the POST function to properly map meta with data
9595
export async function POST(req: Request) {
9696
try {
97-
const { query } = await req.json();
97+
const { query, apiKey } = await req.json();
9898

9999
if (!query) {
100100
return NextResponse.json({ error: 'Query is required' }, { status: 400 });
101101
}
102102

103+
if (!apiKey) {
104+
return NextResponse.json({ error: 'OpenAI API key is required' }, { status: 400 });
105+
}
106+
103107
// Fetch pipe definition and available dimensions in parallel
104108
const [pipeDefinition, availableDimensions] = await Promise.all([
105109
fetchPipeDefinition(),
@@ -182,7 +186,8 @@ export async function POST(req: Request) {
182186
`;
183187

184188
const result = await generateObject({
185-
model: openai('gpt-3.5-turbo'),
189+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
190+
model: openai('gpt-3.5-turbo', { apiKey } as any),
186191
schema: costParametersSchema,
187192
prompt: query,
188193
systemPrompt: systemPromptText,
@@ -231,6 +236,12 @@ export async function POST(req: Request) {
231236
return NextResponse.json(processedResult);
232237
} catch (error) {
233238
console.error('Error extracting parameters:', error);
239+
240+
// Check if it's an API key error
241+
if (error instanceof Error && error.message.includes('API key')) {
242+
return NextResponse.json({ error: 'Invalid OpenAI API key' }, { status: 401 });
243+
}
244+
234245
return NextResponse.json({ error: 'Failed to extract parameters' }, { status: 500 });
235246
}
236247
}

dashboard/ai-analytics/src/app/api/search/route.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ const DIMENSIONS = {
1919
};
2020

2121
export async function POST(req: Request) {
22-
const { prompt } = await req.json();
22+
const { prompt, apiKey } = await req.json();
23+
24+
if (!apiKey) {
25+
return Response.json({ error: 'OpenAI API key is required' }, { status: 400 });
26+
}
2327

2428
try {
2529
// Create the schema outside the function call
@@ -36,16 +40,23 @@ export async function POST(req: Request) {
3640
Return only valid values from the provided dimensions.`;
3741

3842
const result = await generateObject({
39-
model: openai('gpt-3.5-turbo'),
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
model: openai('gpt-3.5-turbo', { apiKey } as any),
4045
schema: filterSchema,
4146
prompt,
4247
systemPrompt: systemPromptText,
4348
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44-
} as any); // Using 'as any' to bypass TypeScript's type checking
49+
} as any);
4550

4651
return Response.json(result.object);
4752
} catch (error) {
48-
console.error('Error generating object:', error);
49-
return Response.json({ error: 'Failed to parse filters' }, { status: 500 });
53+
console.error('Error processing search:', error);
54+
55+
// Check if it's an API key error
56+
if (error instanceof Error && error.message.includes('API key')) {
57+
return Response.json({ error: 'Invalid OpenAI API key' }, { status: 401 });
58+
}
59+
60+
return Response.json({ error: 'Failed to process search query' }, { status: 500 });
5061
}
5162
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useApiKeyStore } from '@/stores/apiKeyStore';
5+
6+
export default function ApiKeyInput() {
7+
const { openaiKey, setOpenaiKey, clearOpenaiKey } = useApiKeyStore();
8+
const [inputKey, setInputKey] = useState('');
9+
const [isVisible, setIsVisible] = useState(false);
10+
11+
const handleSave = () => {
12+
if (inputKey.trim()) {
13+
setOpenaiKey(inputKey.trim());
14+
setInputKey('');
15+
}
16+
};
17+
18+
return (
19+
<div className="mb-4 p-4 border border-gray-200 dark:border-gray-800 rounded-lg">
20+
<h3 className="text-lg font-medium mb-2">OpenAI API Key</h3>
21+
22+
{openaiKey ? (
23+
<div>
24+
<div className="flex items-center mb-2">
25+
<span className="text-sm text-gray-600 dark:text-gray-400">
26+
{isVisible ? openaiKey : '••••••••••••••••••••••' + openaiKey.slice(-5)}
27+
</span>
28+
<button
29+
onClick={() => setIsVisible(!isVisible)}
30+
className="ml-2 text-xs text-indigo-600 hover:text-indigo-800"
31+
>
32+
{isVisible ? 'Hide' : 'Show'}
33+
</button>
34+
</div>
35+
<button
36+
onClick={clearOpenaiKey}
37+
className="text-sm text-red-600 hover:text-red-800"
38+
>
39+
Remove Key
40+
</button>
41+
</div>
42+
) : (
43+
<div>
44+
<div className="flex items-center">
45+
<input
46+
type="password"
47+
value={inputKey}
48+
onChange={(e) => setInputKey(e.target.value)}
49+
placeholder="Enter your OpenAI API key"
50+
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-md dark:bg-gray-800"
51+
/>
52+
<button
53+
onClick={handleSave}
54+
className="ml-2 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
55+
>
56+
Save
57+
</button>
58+
</div>
59+
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
60+
Your API key is stored locally in your browser and never sent to our servers.
61+
</p>
62+
</div>
63+
)}
64+
</div>
65+
);
66+
}

dashboard/ai-analytics/src/app/components/CostPredictionModal.tsx

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { X, Calculator, Copy, Check, Sparkles, ChevronDown, ChevronUp } from 'lu
66
import { AreaChart, BarChart } from '@tremor/react';
77
import { useTinybirdToken } from '@/providers/TinybirdProvider';
88
import { fetchLLMUsage } from '@/services/tinybird';
9+
import { useApiKeyStore } from '@/stores/apiKeyStore';
910

1011
interface CostPredictionModalProps {
1112
isOpen: boolean;
@@ -76,6 +77,9 @@ export default function CostPredictionModal({
7677

7778
const { token } = useTinybirdToken();
7879
const inputRef = useRef<HTMLInputElement>(null);
80+
81+
// Get the API key from the store
82+
const { openaiKey } = useApiKeyStore();
7983

8084
// Example queries that users can select
8185
const exampleQueries = [
@@ -260,6 +264,13 @@ export default function CostPredictionModal({
260264
const handleSubmit = async (e: React.FormEvent) => {
261265
e.preventDefault();
262266
if (!query.trim()) return;
267+
268+
// Check if API key is available
269+
if (!openaiKey) {
270+
// Show a message to the user that they need to provide an API key
271+
alert('Please provide your OpenAI API key in settings to use this feature.');
272+
return;
273+
}
263274

264275
setIsLoading(true);
265276
try {
@@ -281,7 +292,7 @@ export default function CostPredictionModal({
281292
headers: {
282293
'Content-Type': 'application/json',
283294
},
284-
body: JSON.stringify({ query }),
295+
body: JSON.stringify({ query, apiKey: openaiKey }),
285296
});
286297

287298
if (!response.ok) {
@@ -946,11 +957,7 @@ export default function CostPredictionModal({
946957
{entry.name === 'actualCost' ? 'Actual' : 'Predicted'}:
947958
</span>
948959
<span className="text-white ml-1">
949-
${typeof entry.value === 'number'
950-
? entry.value.toFixed(2)
951-
: Array.isArray(entry.value)
952-
? entry.value.join(', ')
953-
: entry.value}
960+
${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}
954961
</span>
955962
</div>
956963
))}
@@ -981,11 +988,7 @@ export default function CostPredictionModal({
981988
/>
982989
<span className="text-gray-400">{entry.name}:</span>
983990
<span className="text-white ml-1">
984-
${typeof entry.value === 'number'
985-
? entry.value.toFixed(2)
986-
: Array.isArray(entry.value)
987-
? entry.value.join(', ')
988-
: entry.value}
991+
${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}
989992
</span>
990993
</div>
991994
))}
@@ -1016,11 +1019,7 @@ export default function CostPredictionModal({
10161019
/>
10171020
<span className="text-gray-400">Cost:</span>
10181021
<span className="text-white ml-1">
1019-
${typeof entry.value === 'number'
1020-
? entry.value.toFixed(2)
1021-
: Array.isArray(entry.value)
1022-
? entry.value.join(', ')
1023-
: entry.value}
1022+
${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}
10241023
</span>
10251024
</div>
10261025
))}

dashboard/ai-analytics/src/app/components/TopBar.tsx

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import FilterChips from './FilterChips';
66
import { useTinybirdToken } from '@/providers/TinybirdProvider';
77
import { useRef, useState } from 'react';
88
import DateRangeSelector from './DateRangeSelector';
9-
import { Calculator } from 'lucide-react';
9+
import { Calculator, Settings } from 'lucide-react';
1010
import { useModal } from '../context/ModalContext';
11+
import { useApiKeyStore } from '@/stores/apiKeyStore';
12+
import ApiKeyInput from './ApiKeyInput';
13+
import { Dialog, DialogPanel } from '@tremor/react';
1114

1215
interface Selection {
1316
dimension: string;
@@ -27,11 +30,19 @@ export default function TopBar({ selections, onRemoveFilter }: TopBarProps) {
2730
const inputRef = useRef<HTMLInputElement>(null);
2831
const [isLoading, setIsLoading] = useState(false);
2932
const { openCostPrediction } = useModal();
33+
const { openaiKey } = useApiKeyStore();
34+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
3035

3136
const handleSearch = async (e: React.KeyboardEvent<HTMLInputElement>) => {
3237
if (e.key === 'Enter') {
3338
const input = e.currentTarget.value;
3439
if (input.trim()) {
40+
// Check if API key is available
41+
if (!openaiKey) {
42+
alert('Please provide your OpenAI API key in settings to use this feature.');
43+
return;
44+
}
45+
3546
setIsLoading(true);
3647
console.log('Searching for:', input);
3748

@@ -41,7 +52,7 @@ export default function TopBar({ selections, onRemoveFilter }: TopBarProps) {
4152
headers: {
4253
'Content-Type': 'application/json',
4354
},
44-
body: JSON.stringify({ prompt: input }),
55+
body: JSON.stringify({ prompt: input, apiKey: openaiKey }),
4556
});
4657

4758
if (!response.ok) {
@@ -78,7 +89,7 @@ export default function TopBar({ selections, onRemoveFilter }: TopBarProps) {
7889
inputRef.current.value = '';
7990
}
8091
} catch (error) {
81-
console.error('Error during search:', error);
92+
console.error('Search error:', error);
8293
} finally {
8394
setIsLoading(false);
8495
}
@@ -155,6 +166,13 @@ export default function TopBar({ selections, onRemoveFilter }: TopBarProps) {
155166
</div>
156167

157168
<div className="flex items-center space-x-4">
169+
<button
170+
onClick={() => setIsSettingsOpen(true)}
171+
className="flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors shadow-sm"
172+
>
173+
<Settings className="w-4 h-4 mr-2" />
174+
Settings
175+
</button>
158176
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
159177
{orgName || 'Admin User'}
160178
</span>
@@ -174,6 +192,31 @@ export default function TopBar({ selections, onRemoveFilter }: TopBarProps) {
174192
<UserButton afterSignOutUrl="/" />
175193
</SignedIn>
176194
</div>
195+
196+
{/* Settings Modal */}
197+
<Dialog
198+
open={isSettingsOpen}
199+
onClose={() => setIsSettingsOpen(false)}
200+
static={true}
201+
>
202+
<DialogPanel className="max-w-md">
203+
<div className="p-6">
204+
<div className="flex items-center justify-between mb-6">
205+
<h3 className="text-lg font-semibold">Settings</h3>
206+
<button
207+
onClick={() => setIsSettingsOpen(false)}
208+
className="text-gray-500 hover:text-gray-700"
209+
>
210+
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
211+
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
212+
</svg>
213+
</button>
214+
</div>
215+
216+
<ApiKeyInput />
217+
</div>
218+
</DialogPanel>
219+
</Dialog>
177220
</div>
178221
);
179222
}

dashboard/ai-analytics/src/app/layout.tsx

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,32 +17,56 @@ function RootLayoutContent({ children }: { children: React.ReactNode }) {
1717
const [isReady, setIsReady] = useState(false)
1818

1919
useEffect(() => {
20-
fetch(window.location.pathname)
21-
.then(response => {
20+
let isMounted = true
21+
22+
const fetchToken = async () => {
23+
try {
24+
const response = await fetch(window.location.pathname)
25+
if (!isMounted) return
26+
2227
const token = response.headers.get('x-tinybird-token')
2328
const orgName = response.headers.get('x-org-name')
29+
2430
if (token) {
2531
setToken(token)
2632
setOrgName(orgName || '')
27-
setIsReady(true)
2833
}
29-
})
30-
}, [setToken, setOrgName])
34+
35+
setIsReady(true)
36+
} catch (error) {
37+
console.error('Error fetching token:', error)
38+
if (isMounted) setIsReady(true)
39+
}
40+
}
41+
42+
fetchToken()
43+
44+
return () => {
45+
isMounted = false
46+
}
47+
}, []) // Empty dependency array to run only once
3148

3249
if (!isReady) return <div>Loading...</div>
3350

34-
return children
51+
return (
52+
<>
53+
{children}
54+
</>
55+
)
3556
}
3657

37-
function ModalController({ filters }: { filters: Record<string, string | undefined> }) {
38-
const { isCostPredictionOpen, openCostPrediction, closeCostPrediction } = useModal()
39-
40-
// Setup Cmd+K shortcut
41-
useKeyboardShortcut('k', openCostPrediction, true)
42-
58+
function ModalController({ filters }: { filters: Record<string, string> }) {
59+
const { isCostPredictionOpen, closeCostPrediction } = useModal()
60+
61+
useKeyboardShortcut('c', () => {
62+
if (!isCostPredictionOpen) {
63+
window.dispatchEvent(new CustomEvent('open-cost-prediction'))
64+
}
65+
})
66+
4367
return (
44-
<CostPredictionModal
45-
isOpen={isCostPredictionOpen}
68+
<CostPredictionModal
69+
isOpen={isCostPredictionOpen}
4670
onClose={closeCostPrediction}
4771
currentFilters={filters}
4872
/>

0 commit comments

Comments
 (0)