diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..15b89e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Google Gemini API Key (Optional - can also be set via the add-in UI) +# Get your free API key from: https://aistudio.google.com/app/apikey +# REACT_APP_GEMINI_API_KEY=your_api_key_here + +# Development settings +NODE_ENV=development diff --git a/.gitignore b/.gitignore index 837f7f8..1b4a726 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules .env .DS_Store +dist + diff --git a/src/taskpane/components/ApiKeyManager.tsx b/src/taskpane/components/ApiKeyManager.tsx new file mode 100644 index 0000000..b9ddc85 --- /dev/null +++ b/src/taskpane/components/ApiKeyManager.tsx @@ -0,0 +1,174 @@ +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Input, + Field, + Spinner, + Toast, + ToastBody, + ToastTitle, + useToastController, + useId, +} from "@fluentui/react-components"; +import { Settings24Regular, Eye24Regular, EyeOff24Regular } from "@fluentui/react-icons"; +import * as React from "react"; +import { storeApiKey, validateApiKey } from "../utils/apiKeyUtils"; + +interface ApiKeyManagerProps { + onApiKeyChange: (apiKey: string) => void; + currentApiKey: string; +} + +const ApiKeyManager: React.FC = ({ onApiKeyChange, currentApiKey }) => { + const [isOpen, setIsOpen] = React.useState(false); + const [apiKey, setApiKey] = React.useState(currentApiKey); + const [showApiKey, setShowApiKey] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const toasterId = useId("apikey-toaster"); + const { dispatchToast } = useToastController(toasterId); + + const showToast = ( + title: string, + body: string, + intent: "info" | "success" | "warning" | "error" + ) => { + dispatchToast( + + {title} + {body} + , + { intent } + ); + }; + + const handleSave = async () => { + console.log("Attempting to save API key:", apiKey?.length, "characters"); + + if (!apiKey.trim()) { + showToast("Error", "Please enter a valid API key", "error"); + return; + } + + const isValid = validateApiKey(apiKey); + console.log("API key validation result:", isValid); + + if (!isValid) { + showToast("Error", `Please enter a valid Gemini API key (at least 15 characters, alphanumeric)`, "error"); + return; + } + + setIsSaving(true); + try { + await storeApiKey(apiKey); + onApiKeyChange(apiKey); + setIsOpen(false); + showToast("Success", "API key saved successfully", "success"); + console.log("API key saved successfully"); + } catch (error) { + console.error("Error saving API key:", error); + showToast("Error", "Failed to save API key", "error"); + } finally { + setIsSaving(false); + } + }; + + const handleClose = () => { + setApiKey(currentApiKey); // Reset to current value if cancelled + setIsOpen(false); + }; + + React.useEffect(() => { + setApiKey(currentApiKey); + }, [currentApiKey]); + + const maskedApiKey = apiKey ? `${"*".repeat(Math.max(0, apiKey.length - 8))}${apiKey.slice(-8)}` : ""; + + return ( + <> + setIsOpen(data.open)}> + + + + + + + + + ); +}; + +export default ApiKeyManager; diff --git a/src/taskpane/components/App.tsx b/src/taskpane/components/App.tsx index 12a7cc3..1031dcc 100644 --- a/src/taskpane/components/App.tsx +++ b/src/taskpane/components/App.tsx @@ -21,9 +21,25 @@ import { removeSelectionChangeHandler, } from "../taskpane"; import useDarkMode from "./useDarkMode"; +import ApiKeyManager from "./ApiKeyManager"; +import { getStoredApiKey } from "../utils/apiKeyUtils"; -const genAI = new GoogleGenerativeAI(process.env.REACT_APP_GEMINI_API_KEY || ""); -const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); +// Initialize with fallback API key, will be updated dynamically +let genAI: GoogleGenerativeAI; +let model: any; + +const initializeAI = (apiKey: string) => { + if (apiKey) { + genAI = new GoogleGenerativeAI(apiKey); + model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" }); + } +}; + +// Try to initialize with environment API key as fallback +const envApiKey = typeof process !== 'undefined' && process.env ? process.env.REACT_APP_GEMINI_API_KEY : ''; +if (envApiKey) { + initializeAI(envApiKey); +} const SYSTEM_PROMPT = `You operate within Excel. All responses must be in the following format: { @@ -43,6 +59,8 @@ const App = () => { address: "", }); const [loading, setLoading] = React.useState(false); + const [apiKey, setApiKey] = React.useState(""); + const [isApiKeyLoaded, setIsApiKeyLoaded] = React.useState(false); const toasterId = useId("toaster"); const { dispatchToast } = useToastController(toasterId); const { isDarkMode } = useDarkMode(); @@ -62,6 +80,16 @@ const App = () => { }; const handlePromptSubmit = async () => { + if (!apiKey) { + showToast("Error", "Please set up your API key in settings first", "error"); + return; + } + + if (!model) { + showToast("Error", "AI model not initialized. Please check your API key", "error"); + return; + } + setLoading(true); try { const result = await model.generateContent({ @@ -92,7 +120,11 @@ const App = () => { }); } catch (error) { console.error("Error generating text:", error); - showToast("Error", "Failed to generate text", "error"); + if (error.toString().includes("API_KEY") || error.toString().includes("401")) { + showToast("Error", "Invalid API key. Please check your settings.", "error"); + } else { + showToast("Error", "Failed to generate text", "error"); + } } finally { setLoading(false); } @@ -109,6 +141,37 @@ const App = () => { return "No range selected"; }, [selectedRange.address]); + const handleApiKeyChange = (newApiKey: string) => { + setApiKey(newApiKey); + initializeAI(newApiKey); + }; + + // Load stored API key on component mount + useEffect(() => { + const loadApiKey = async () => { + try { + const storedApiKey = await getStoredApiKey(); + if (storedApiKey) { + setApiKey(storedApiKey); + initializeAI(storedApiKey); + } else { + // Use environment key as fallback (safely) + const envKey = typeof process !== 'undefined' && process.env ? process.env.REACT_APP_GEMINI_API_KEY : ''; + if (envKey) { + setApiKey(envKey); + initializeAI(envKey); + } + } + } catch (error) { + console.error("Error loading API key:", error); + } finally { + setIsApiKeyLoaded(true); + } + }; + + loadApiKey(); + }, []); + useEffect(() => { registerSelectionChangeHandler(setSelectedRange); @@ -120,11 +183,52 @@ const App = () => { return (
- {!response ? ( -
-

- Welcome to [Cheesy AI Generated Name Here] -

+ {/* Header with settings */} +
+

+ Excel AI Assistant +

+ +
+ + {!isApiKeyLoaded ? ( +
+ +
+ ) : !apiKey ? ( +
+

+ Welcome to [Cheesy AI Generated Name Here] +

+

+ To get started, you need to set up your Google Gemini API key. +

+

+ You can get a free API key from{" "} + + Google AI Studio + +

+
+ +
+
+ ) : !response ? ( +
+

+ Ready to Go! 🚀 +

Select a range and ask AI to:

  • Analyze your data
  • @@ -142,36 +246,42 @@ const App = () => { AI Overlord Says: -
    -

    {response}

    +
    +

    {response}

    )} -
    -
    -

    {selectedRangeDescription}

    - setPrompt(e.target.value)} - placeholder="Ask AI" - className="w-full" - onKeyDown={(e) => { - if (e.key === "Enter") { - handlePromptSubmit(); - } - }} - /> + + {apiKey && ( +
    +
    +

    + {selectedRangeDescription} +

    + setPrompt(e.target.value)} + placeholder="Ask AI about your data..." + className="w-full" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handlePromptSubmit(); + } + }} + /> +
    +
    - -
    + )}
    => { + try { + // Try Office roaming settings first (syncs across devices) + if (typeof Office !== "undefined" && Office.context && Office.context.roamingSettings) { + const apiKey = Office.context.roamingSettings.get("gemini_api_key"); + return apiKey || ""; + } + + // Fallback to localStorage for development + return localStorage.getItem("gemini_api_key") || ""; + } catch (error) { + console.error("Error retrieving API key:", error); + return ""; + } +}; + +/** + * Stores the API key to Office roaming settings or localStorage + */ +export const storeApiKey = async (apiKey: string): Promise => { + try { + // Try Office roaming settings first (syncs across devices) + if (typeof Office !== "undefined" && Office.context && Office.context.roamingSettings) { + Office.context.roamingSettings.set("gemini_api_key", apiKey); + await Office.context.roamingSettings.saveAsync(); + } else { + // Fallback to localStorage for development + localStorage.setItem("gemini_api_key", apiKey); + } + } catch (error) { + console.error("Error storing API key:", error); + throw error; + } +}; + +/** + * Removes the stored API key + */ +export const removeStoredApiKey = async (): Promise => { + try { + // Try Office roaming settings first + if (typeof Office !== "undefined" && Office.context && Office.context.roamingSettings) { + Office.context.roamingSettings.remove("gemini_api_key"); + await Office.context.roamingSettings.saveAsync(); + } else { + // Fallback to localStorage for development + localStorage.removeItem("gemini_api_key"); + } + } catch (error) { + console.error("Error removing API key:", error); + throw error; + } +}; + +/** + * Validates if an API key appears to be in the correct format + */ +export const validateApiKey = (apiKey: string): boolean => { + // More lenient validation for Gemini API key format + // Allow various formats as long as it's a reasonable length and contains valid characters + if (!apiKey || typeof apiKey !== 'string') return false; + + // Must be at least 15 characters and contain alphanumeric/underscore/dash + return apiKey.length >= 15 && /^[A-Za-z0-9_-]+$/.test(apiKey); +}; diff --git a/webpack.config.js b/webpack.config.js index 0908bae..4c1800f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,7 +5,7 @@ const CopyWebpackPlugin = require("copy-webpack-plugin"); const HtmlWebpackPlugin = require("html-webpack-plugin"); const webpack = require("webpack"); const dotenv = require("dotenv"); -const env = dotenv.config().parsed; +const env = dotenv.config().parsed || {}; // Provide empty object fallback const urlDev = "https://localhost:3000/"; const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION @@ -15,10 +15,15 @@ async function getHttpsOptions() { return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert }; } +// Always provide process.env fallbacks for browser compatibility const envKeys = Object.keys(env).reduce((prev, next) => { prev[`process.env.${next}`] = JSON.stringify(env[next]); return prev; -}, {}); +}, { + // Add fallback for process.env even if no .env file + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), + 'process.env.REACT_APP_GEMINI_API_KEY': JSON.stringify(env.REACT_APP_GEMINI_API_KEY || '') +}); module.exports = async (env, options) => { const dev = options.mode === "development";