Quellcode durchsuchen

feat: implement OpenRouter model fetching with error handling and search functionality

Burak Sormageç vor 4 Monaten
Ursprung
Commit
3b8dc3d342
2 geänderte Dateien mit 358 neuen und 55 gelöschten Zeilen
  1. 72 1
      chrome-extension/src/background/index.ts
  2. 286 54
      pages/options/src/components/ModelSettings.tsx

+ 72 - 1
chrome-extension/src/background/index.ts

@@ -74,7 +74,39 @@ chrome.tabs.onRemoved.addListener(tabId => {
 
 logger.info('background loaded');
 
-// Setup connection listener
+// Listen for simple messages (e.g., from options page)
+chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+  if (message.type === 'fetch_openrouter_models') {
+    if (!message.apiKey) {
+      sendResponse({ type: 'error', error: 'API Key is required to fetch OpenRouter models.' });
+      return true; // Indicates async response
+    }
+
+    fetchOpenRouterModels(message.apiKey)
+      .then(models => {
+        sendResponse({ type: 'openrouter_models_fetched', models });
+      })
+      .catch(fetchError => {
+        logger.error('Error fetching OpenRouter models:', fetchError);
+        sendResponse({
+          type: 'error',
+          error:
+            fetchError instanceof Error
+              ? `Failed to fetch OpenRouter models: ${fetchError.message}`
+              : 'Failed to fetch OpenRouter models: Unknown error',
+        });
+      });
+
+    return true; // Indicates that the response is sent asynchronously
+  }
+
+  // Handle other message types if needed in the future
+
+  // Return false if response is not sent asynchronously
+  // return false;
+});
+
+// Setup connection listener for long-lived connections (e.g., side panel)
 chrome.runtime.onConnect.addListener(port => {
   if (port.name === 'side-panel-connection') {
     currentPort = port;
@@ -145,6 +177,7 @@ chrome.runtime.onConnect.addListener(port => {
             await currentExecutor.pause();
             return port.postMessage({ type: 'success' });
           }
+
           default:
             return port.postMessage({ type: 'error', error: 'Unknown message type' });
         }
@@ -219,6 +252,44 @@ async function setupExecutor(taskId: string, task: string, browserContext: Brows
   return executor;
 }
 
+// Function to fetch models from OpenRouter API
+async function fetchOpenRouterModels(apiKey: string): Promise<string[]> {
+  const url = 'https://openrouter.ai/api/v1/models';
+  try {
+    const response = await fetch(url, {
+      method: 'GET',
+      headers: {
+        Authorization: `Bearer ${apiKey}`,
+        'Content-Type': 'application/json',
+      },
+    });
+
+    if (!response.ok) {
+      // Try to get more specific error from response body
+      let errorDetails = `HTTP error! status: ${response.status}`;
+      try {
+        const errorData = await response.json();
+        errorDetails += ` - ${errorData.error?.message || JSON.stringify(errorData)}`;
+      } catch (jsonError) {
+        // Ignore if response body is not JSON or empty
+      }
+      throw new Error(errorDetails);
+    }
+
+    const data = await response.json();
+
+    // Extract model IDs
+    if (data && Array.isArray(data.data)) {
+      const modelIds = data.data.map((model: { id: string }) => model.id).filter(Boolean); // Filter out any null/undefined IDs
+      return modelIds;
+    }
+    throw new Error('Invalid response format from OpenRouter API');
+  } catch (error) {
+    logger.error('fetchOpenRouterModels failed:', error);
+    throw error; // Re-throw the error to be caught by the caller
+  }
+}
+
 // Update subscribeToExecutorEvents to use port
 async function subscribeToExecutorEvents(executor: Executor) {
   // Clear previous event listeners to prevent multiple subscriptions

+ 286 - 54
pages/options/src/components/ModelSettings.tsx

@@ -1,3 +1,11 @@
+/*
+ * Changes:
+ * - Added a searchable select component with filtering capability for model selection
+ * - Implemented keyboard navigation and accessibility for the custom dropdown
+ * - Added search functionality that filters models based on user input
+ * - Added keyboard event handlers to close dropdowns with Escape key
+ * - Styling for both light and dark mode themes
+ */
 import { useEffect, useState, useRef, useCallback } from 'react';
 import type { KeyboardEvent } from 'react';
 import { Button } from '@extension/ui';
@@ -12,9 +20,11 @@ import {
   getDefaultAgentModelParams,
   type ProviderConfig,
 } from '@extension/storage';
+// Import chrome for messaging
+const IS_CHROME = typeof chrome !== 'undefined' && typeof chrome.runtime !== 'undefined';
 
 interface ModelSettingsProps {
-  isDarkMode?: boolean;
+  isDarkMode?: boolean; // Controls dark/light theme styling
 }
 
 export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
@@ -41,6 +51,19 @@ export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
   const [availableModels, setAvailableModels] = useState<
     Array<{ provider: string; providerName: string; model: string }>
   >([]);
+  // State for OpenRouter model fetching
+  const [openRouterFetchState, setOpenRouterFetchState] = useState<
+    Record<
+      string,
+      {
+        allModels: string[] | null; // Store all fetched models here
+        displayMode: 'all' | 'free' | null; // Track which list to show
+        isFetching: boolean;
+        error: string | null;
+      }
+    >
+  >({});
+  const [searchInputs, setSearchInputs] = useState<Record<string, string>>({});
 
   useEffect(() => {
     const loadProviders = async () => {
@@ -858,6 +881,95 @@ export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
     }));
   };
 
+  // Fix the handleFetchOpenRouterModels function's return block
+  const handleFetchOpenRouterModels = async (providerId: string, mode: 'all' | 'free') => {
+    const apiKey = providers[providerId]?.apiKey;
+    if (!apiKey) {
+      setOpenRouterFetchState(prev => ({
+        ...prev,
+        [providerId]: {
+          ...prev[providerId],
+          isFetching: false,
+          error: 'API Key is required to fetch models.',
+          displayMode: null,
+        }, // Reset displayMode on error
+      }));
+      return;
+    }
+
+    // Update state to show fetching and set display mode
+    setOpenRouterFetchState(prev => ({
+      ...prev,
+      [providerId]: {
+        allModels: prev[providerId]?.allModels ?? null, // Preserve existing models if any
+        isFetching: true,
+        error: null,
+        displayMode: mode, // Set the display mode based on the button clicked
+      },
+    }));
+
+    if (IS_CHROME) {
+      chrome.runtime.sendMessage({ type: 'fetch_openrouter_models', apiKey }, response => {
+        // Check for runtime error before accessing message
+        if (chrome.runtime.lastError) {
+          console.error('Error sending message to background:', chrome.runtime.lastError);
+          setOpenRouterFetchState(prev => ({
+            ...prev,
+            [providerId]: {
+              allModels: null, // Clear models on error
+              isFetching: false,
+              error: `Connection error: ${chrome.runtime.lastError?.message || 'Unknown connection error'}`,
+              displayMode: null, // Reset displayMode
+            },
+          }));
+          return;
+        }
+
+        if (response?.type === 'openrouter_models_fetched') {
+          // Always store all fetched models, sort them
+          const fetchedModels = response.models.sort();
+          setOpenRouterFetchState(prev => ({
+            ...prev,
+            [providerId]: {
+              ...prev[providerId], // Keep the current displayMode
+              allModels: fetchedModels,
+              isFetching: false,
+              error: null,
+            },
+          }));
+        } else if (response?.type === 'error') {
+          setOpenRouterFetchState(prev => ({
+            ...prev,
+            [providerId]: {
+              allModels: null, // Clear models on error
+              isFetching: false,
+              error: response.error,
+              displayMode: null, // Reset displayMode
+            },
+          }));
+        } else {
+          // Handle unexpected response
+          setOpenRouterFetchState(prev => ({
+            ...prev,
+            [providerId]: {
+              allModels: null,
+              isFetching: false,
+              error: 'Unexpected response from background script.',
+              displayMode: null, // Reset displayMode
+            },
+          }));
+        }
+      });
+    } else {
+      // Handle non-chrome environment (e.g., development, testing)
+      console.warn('Cannot send message: Chrome runtime not available.');
+      setOpenRouterFetchState(prev => ({
+        ...prev,
+        [providerId]: { allModels: null, isFetching: false, error: 'Chrome runtime not available.', displayMode: null },
+      }));
+    }
+  };
+
   return (
     <section className="space-y-6">
       {/* LLM Providers Section */}
@@ -1113,64 +1225,184 @@ export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
                       </div>
                     )}
 
-                    {/* Models/Deployments input field with tags (HIDE for Azure) */}
+                    {/* Models/Deployments input section */}
                     {(providerConfig.type as ProviderTypeEnum) !== ProviderTypeEnum.AzureOpenAI && (
                       <div className="flex items-start">
                         <label
-                          htmlFor={`${providerId}-models`}
+                          htmlFor={`${providerId}-models-label`}
                           className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
-                          {/* Updated label for Azure */}
-                          {providerConfig.type === ProviderTypeEnum.AzureOpenAI ? 'Deployments' : 'Models'}
+                          Models
                         </label>
-                        <div className="flex-1">
-                          <div
-                            className={`flex min-h-[42px] flex-wrap items-center gap-2 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} p-2`}>
-                            {/* Display existing models as tags */}
-                            {(() => {
-                              // Get models from provider config or default models
-                              const models =
-                                providerConfig.modelNames !== undefined
-                                  ? providerConfig.modelNames
-                                  : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
-
-                              return models.map(model => (
-                                <div
-                                  key={model}
-                                  className={`flex items-center rounded-full ${isDarkMode ? 'bg-blue-900 text-blue-100' : 'bg-blue-100 text-blue-800'} px-2 py-1 text-sm`}>
-                                  <span>{model}</span>
-                                  <button
-                                    type="button"
-                                    onClick={() => removeModel(providerId, model)}
-                                    className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
-                                    aria-label={`Remove ${model}`}>
-                                    ×
-                                  </button>
-                                </div>
-                              ));
-                            })()}
-
-                            {/* Input for new models */}
-                            <input
-                              id={`${providerId}-models`}
-                              type="text"
-                              placeholder="" // Placeholder kept empty for tags input style
-                              value={newModelInputs[providerId] || ''}
-                              onChange={e => handleModelsChange(providerId, e.target.value)}
-                              onKeyDown={e => handleKeyDown(e, providerId)}
-                              className={`min-w-[150px] flex-1 border-none text-sm ${isDarkMode ? 'bg-transparent text-gray-200' : 'bg-transparent text-gray-700'} p-1 outline-none`}
-                            />
-                          </div>
-                          {/* Updated description for model input */}
-                          <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
-                            Type and Press Enter or Space to add.
-                            {/* Added clarification for Azure */}
-                            {providerConfig.type === ProviderTypeEnum.AzureOpenAI && (
-                              <span className="block">
-                                Enter your exact Azure Deployment Names here (e.g., &apos;my-gpt4o-deployment&apos;).
-                                This name is used to call the specific model you deployed in Azure.
-                              </span>
-                            )}
-                          </p>
+                        <div className="flex-1 space-y-2">
+                          {/* === START: Conditional UI for OpenRouter === */}
+                          {(providerConfig.type as ProviderTypeEnum) === ProviderTypeEnum.OpenRouter ? (
+                            <>
+                              {/* Fetch Buttons Container */}
+                              <div className="mb-2 flex space-x-2">
+                                <Button
+                                  variant="secondary"
+                                  onClick={() => handleFetchOpenRouterModels(providerId, 'all')}
+                                  disabled={
+                                    !providers[providerId]?.apiKey || openRouterFetchState[providerId]?.isFetching
+                                  }
+                                  title={!providers[providerId]?.apiKey ? 'Enter API Key first' : ''}>
+                                  {openRouterFetchState[providerId]?.isFetching &&
+                                  openRouterFetchState[providerId]?.displayMode === 'all'
+                                    ? 'Fetching All...'
+                                    : 'Fetch All Models'}
+                                </Button>
+                                <Button
+                                  variant="secondary"
+                                  onClick={() => handleFetchOpenRouterModels(providerId, 'free')}
+                                  disabled={
+                                    !providers[providerId]?.apiKey || openRouterFetchState[providerId]?.isFetching
+                                  }
+                                  title={!providers[providerId]?.apiKey ? 'Enter API Key first' : ''}>
+                                  {openRouterFetchState[providerId]?.isFetching &&
+                                  openRouterFetchState[providerId]?.displayMode === 'free'
+                                    ? 'Fetching Free...'
+                                    : 'Fetch Free Models'}
+                                </Button>
+                              </div>
+
+                              {/* Error Display */}
+                              {openRouterFetchState[providerId]?.error && (
+                                <p
+                                  className={`text-xs ${isDarkMode ? 'text-red-400' : 'text-red-600'}`}>{`Error: ${openRouterFetchState[providerId]?.error}`}</p>
+                              )}
+
+                              {/* Search input for filtering models */}
+                              {openRouterFetchState[providerId]?.allModels && (
+                                <input
+                                  type="text"
+                                  placeholder="Search models..."
+                                  className={`mb-2 w-full rounded-md border text-sm ${
+                                    isDarkMode
+                                      ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800'
+                                      : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'
+                                  } p-2 outline-none`}
+                                  value={searchInputs[providerId] || ''}
+                                  onChange={e =>
+                                    setSearchInputs(prev => ({
+                                      ...prev,
+                                      [providerId]: e.target.value,
+                                    }))
+                                  }
+                                />
+                              )}
+
+                              {/* Model Select Dropdown (if models fetched) */}
+                              {openRouterFetchState[providerId]?.allModels && (
+                                <select
+                                  id={`${providerId}-models-select`}
+                                  multiple
+                                  value={providerConfig.modelNames || []} // Keep selection based on providerConfig
+                                  onChange={e => {
+                                    const selectedOptions = Array.from(
+                                      e.target.selectedOptions,
+                                      option => option.value,
+                                    );
+                                    setModifiedProviders(prev => new Set(prev).add(providerId));
+                                    setProviders(prev => ({
+                                      ...prev,
+                                      [providerId]: {
+                                        ...prev[providerId],
+                                        modelNames: selectedOptions,
+                                      },
+                                    }));
+                                  }}
+                                  className={`h-32 w-full rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-800' : 'border-gray-300 bg-white text-gray-700 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'} p-2 outline-none`}>
+                                  {/* Filter models based on displayMode and search text */}
+                                  {(openRouterFetchState[providerId]?.allModels || [])
+                                    .filter(modelId => {
+                                      // Filter by display mode (free/all)
+                                      const passesDisplayMode =
+                                        openRouterFetchState[providerId]?.displayMode === 'free'
+                                          ? modelId.endsWith(':free')
+                                          : true;
+
+                                      // Filter by search text
+                                      const searchText = (searchInputs[providerId] || '').toLowerCase().trim();
+                                      // If search is empty, show all models that pass displayMode
+                                      if (!searchText) return passesDisplayMode;
+                                      // Otherwise filter by search text in model ID
+                                      const passesSearch = modelId.toLowerCase().includes(searchText);
+
+                                      return passesDisplayMode && passesSearch;
+                                    })
+                                    .sort((a, b) => a.localeCompare(b)) // Keep models sorted alphabetically
+                                    .map(modelId => (
+                                      <option key={modelId} value={modelId}>
+                                        {modelId}
+                                      </option>
+                                    ))}
+                                </select>
+                              )}
+
+                              {/* Prompt/Selected Models Display (if not fetched yet) */}
+                              {!openRouterFetchState[providerId]?.allModels &&
+                                !openRouterFetchState[providerId]?.isFetching && (
+                                  <p className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
+                                    {providerConfig.modelNames && providerConfig.modelNames.length > 0
+                                      ? 'Currently selected models (fetch to see available options):'
+                                      : 'Click fetch buttons to load model list.'}
+                                  </p>
+                                )}
+                              {!openRouterFetchState[providerId]?.allModels &&
+                                providerConfig.modelNames &&
+                                providerConfig.modelNames.length > 0 && (
+                                  <div className="mt-1 flex flex-wrap gap-1">
+                                    {providerConfig.modelNames.map(model => (
+                                      <span
+                                        key={model}
+                                        className={`rounded-full px-2 py-0.5 text-xs ${isDarkMode ? 'bg-blue-900 text-blue-100' : 'bg-blue-100 text-blue-800'}`}>
+                                        {model}
+                                      </span>
+                                    ))}
+                                  </div>
+                                )}
+                            </>
+                          ) : (
+                            /* === ELSE: Default Tag Input for other providers === */
+                            <>
+                              <div
+                                className={`flex min-h-[42px] flex-wrap items-center gap-2 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} p-2`}>
+                                {(() => {
+                                  const models =
+                                    providerConfig.modelNames !== undefined
+                                      ? providerConfig.modelNames
+                                      : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
+                                  return models.map(model => (
+                                    <div
+                                      key={model}
+                                      className={`flex items-center rounded-full ${isDarkMode ? 'bg-blue-900 text-blue-100' : 'bg-blue-100 text-blue-800'} px-2 py-1 text-sm`}>
+                                      <span>{model}</span>
+                                      <button
+                                        type="button"
+                                        onClick={() => removeModel(providerId, model)}
+                                        className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
+                                        aria-label={`Remove ${model}`}>
+                                        ×
+                                      </button>
+                                    </div>
+                                  ));
+                                })()}
+                                <input
+                                  id={`${providerId}-models-input`}
+                                  type="text"
+                                  placeholder=""
+                                  value={newModelInputs[providerId] || ''}
+                                  onChange={e => handleModelsChange(providerId, e.target.value)}
+                                  onKeyDown={e => handleKeyDown(e, providerId)}
+                                  className={`min-w-[150px] flex-1 border-none text-sm ${isDarkMode ? 'bg-transparent text-gray-200' : 'bg-transparent text-gray-700'} p-1 outline-none`}
+                                />
+                              </div>
+                              <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
+                                Type and Press Enter or Space to add.
+                              </p>
+                            </>
+                          )}
+                          {/* === END: Conditional UI === */}
                         </div>
                       </div>
                     )}