ModelSettings.tsx 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577
  1. /*
  2. * Changes:
  3. * - Added a searchable select component with filtering capability for model selection
  4. * - Implemented keyboard navigation and accessibility for the custom dropdown
  5. * - Added search functionality that filters models based on user input
  6. * - Added keyboard event handlers to close dropdowns with Escape key
  7. * - Styling for both light and dark mode themes
  8. */
  9. import { useEffect, useState, useRef, useCallback } from 'react';
  10. import type { KeyboardEvent } from 'react';
  11. import { Button } from '@extension/ui';
  12. import {
  13. llmProviderStore,
  14. agentModelStore,
  15. AgentNameEnum,
  16. llmProviderModelNames,
  17. ProviderTypeEnum,
  18. getDefaultDisplayNameFromProviderId,
  19. getDefaultProviderConfig,
  20. getDefaultAgentModelParams,
  21. type ProviderConfig,
  22. } from '@extension/storage';
  23. // Helper function to check if a model is an O-series model
  24. function isOpenAIOModel(modelName: string): boolean {
  25. if (modelName.startsWith('openai/')) {
  26. return modelName.startsWith('openai/o');
  27. }
  28. return modelName.startsWith('o');
  29. }
  30. interface ModelSettingsProps {
  31. isDarkMode?: boolean; // Controls dark/light theme styling
  32. }
  33. export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
  34. const [providers, setProviders] = useState<Record<string, ProviderConfig>>({});
  35. const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
  36. const [providersFromStorage, setProvidersFromStorage] = useState<Set<string>>(new Set());
  37. const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
  38. [AgentNameEnum.Navigator]: '',
  39. [AgentNameEnum.Planner]: '',
  40. [AgentNameEnum.Validator]: '',
  41. });
  42. const [modelParameters, setModelParameters] = useState<Record<AgentNameEnum, { temperature: number; topP: number }>>({
  43. [AgentNameEnum.Navigator]: { temperature: 0, topP: 0 },
  44. [AgentNameEnum.Planner]: { temperature: 0, topP: 0 },
  45. [AgentNameEnum.Validator]: { temperature: 0, topP: 0 },
  46. });
  47. // State for reasoning effort for O-series models
  48. const [reasoningEffort, setReasoningEffort] = useState<Record<AgentNameEnum, 'low' | 'medium' | 'high' | undefined>>({
  49. [AgentNameEnum.Navigator]: undefined,
  50. [AgentNameEnum.Planner]: undefined,
  51. [AgentNameEnum.Validator]: undefined,
  52. });
  53. const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
  54. const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false);
  55. const newlyAddedProviderRef = useRef<string | null>(null);
  56. const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
  57. // Add state for tracking API key visibility
  58. const [visibleApiKeys, setVisibleApiKeys] = useState<Record<string, boolean>>({});
  59. // Create a non-async wrapper for use in render functions
  60. const [availableModels, setAvailableModels] = useState<
  61. Array<{ provider: string; providerName: string; model: string }>
  62. >([]);
  63. // State for model input handling
  64. useEffect(() => {
  65. const loadProviders = async () => {
  66. try {
  67. const allProviders = await llmProviderStore.getAllProviders();
  68. console.log('allProviders', allProviders);
  69. // Track which providers are from storage
  70. const fromStorage = new Set(Object.keys(allProviders));
  71. setProvidersFromStorage(fromStorage);
  72. // Only use providers from storage, don't add default ones
  73. setProviders(allProviders);
  74. } catch (error) {
  75. console.error('Error loading providers:', error);
  76. // Set empty providers on error
  77. setProviders({});
  78. // No providers from storage on error
  79. setProvidersFromStorage(new Set());
  80. }
  81. };
  82. loadProviders();
  83. }, []);
  84. // Load existing agent models and parameters on mount
  85. useEffect(() => {
  86. const loadAgentModels = async () => {
  87. try {
  88. const models: Record<AgentNameEnum, string> = {
  89. [AgentNameEnum.Planner]: '',
  90. [AgentNameEnum.Navigator]: '',
  91. [AgentNameEnum.Validator]: '',
  92. };
  93. for (const agent of Object.values(AgentNameEnum)) {
  94. const config = await agentModelStore.getAgentModel(agent);
  95. if (config) {
  96. models[agent] = config.modelName;
  97. if (config.parameters?.temperature !== undefined || config.parameters?.topP !== undefined) {
  98. setModelParameters(prev => ({
  99. ...prev,
  100. [agent]: {
  101. temperature: config.parameters?.temperature ?? prev[agent].temperature,
  102. topP: config.parameters?.topP ?? prev[agent].topP,
  103. },
  104. }));
  105. }
  106. // Also load reasoningEffort if available
  107. if (config.reasoningEffort) {
  108. setReasoningEffort(prev => ({
  109. ...prev,
  110. [agent]: config.reasoningEffort as 'low' | 'medium' | 'high',
  111. }));
  112. }
  113. }
  114. }
  115. setSelectedModels(models);
  116. } catch (error) {
  117. console.error('Error loading agent models:', error);
  118. }
  119. };
  120. loadAgentModels();
  121. }, []);
  122. // Auto-focus the input field when a new provider is added
  123. useEffect(() => {
  124. // Only focus if we have a newly added provider reference
  125. if (newlyAddedProviderRef.current && providers[newlyAddedProviderRef.current]) {
  126. const providerId = newlyAddedProviderRef.current;
  127. const config = providers[providerId];
  128. // For custom providers, focus on the name input
  129. if (config.type === ProviderTypeEnum.CustomOpenAI) {
  130. const nameInput = document.getElementById(`${providerId}-name`);
  131. if (nameInput) {
  132. nameInput.focus();
  133. }
  134. } else {
  135. // For default providers, focus on the API key input
  136. const apiKeyInput = document.getElementById(`${providerId}-api-key`);
  137. if (apiKeyInput) {
  138. apiKeyInput.focus();
  139. }
  140. }
  141. // Clear the ref after focusing
  142. newlyAddedProviderRef.current = null;
  143. }
  144. }, [providers]);
  145. // Add a click outside handler to close the dropdown
  146. useEffect(() => {
  147. const handleClickOutside = (event: MouseEvent) => {
  148. const target = event.target as HTMLElement;
  149. if (isProviderSelectorOpen && !target.closest('.provider-selector-container')) {
  150. setIsProviderSelectorOpen(false);
  151. }
  152. };
  153. document.addEventListener('mousedown', handleClickOutside);
  154. return () => {
  155. document.removeEventListener('mousedown', handleClickOutside);
  156. };
  157. }, [isProviderSelectorOpen]);
  158. // Create a memoized version of getAvailableModels
  159. const getAvailableModelsCallback = useCallback(async () => {
  160. const models: Array<{ provider: string; providerName: string; model: string }> = [];
  161. try {
  162. // Load providers directly from storage
  163. const storedProviders = await llmProviderStore.getAllProviders();
  164. // Only use providers that are actually in storage
  165. for (const [provider, config] of Object.entries(storedProviders)) {
  166. if (config.type === ProviderTypeEnum.AzureOpenAI) {
  167. // Handle Azure providers specially - use deployment names as models
  168. const deploymentNames = config.azureDeploymentNames || [];
  169. models.push(
  170. ...deploymentNames.map(deployment => ({
  171. provider,
  172. providerName: config.name || provider,
  173. model: deployment,
  174. })),
  175. );
  176. } else {
  177. // Standard handling for non-Azure providers
  178. const providerModels =
  179. config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  180. models.push(
  181. ...providerModels.map(model => ({
  182. provider,
  183. providerName: config.name || provider,
  184. model,
  185. })),
  186. );
  187. }
  188. }
  189. } catch (error) {
  190. console.error('Error loading providers for model selection:', error);
  191. }
  192. return models;
  193. }, []);
  194. // Update available models whenever providers change
  195. useEffect(() => {
  196. const updateAvailableModels = async () => {
  197. const models = await getAvailableModelsCallback();
  198. setAvailableModels(models);
  199. };
  200. updateAvailableModels();
  201. }, [getAvailableModelsCallback]); // Only depends on the callback
  202. const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => {
  203. setModifiedProviders(prev => new Set(prev).add(provider));
  204. setProviders(prev => ({
  205. ...prev,
  206. [provider]: {
  207. ...prev[provider],
  208. apiKey: apiKey.trim(),
  209. baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
  210. },
  211. }));
  212. };
  213. // Add a toggle handler for API key visibility
  214. const toggleApiKeyVisibility = (provider: string) => {
  215. setVisibleApiKeys(prev => ({
  216. ...prev,
  217. [provider]: !prev[provider],
  218. }));
  219. };
  220. const handleNameChange = (provider: string, name: string) => {
  221. setModifiedProviders(prev => new Set(prev).add(provider));
  222. setProviders(prev => {
  223. const updated = {
  224. ...prev,
  225. [provider]: {
  226. ...prev[provider],
  227. name: name.trim(),
  228. },
  229. };
  230. return updated;
  231. });
  232. };
  233. const handleModelsChange = (provider: string, modelsString: string) => {
  234. setNewModelInputs(prev => ({
  235. ...prev,
  236. [provider]: modelsString,
  237. }));
  238. };
  239. const addModel = (provider: string, model: string) => {
  240. if (!model.trim()) return;
  241. setModifiedProviders(prev => new Set(prev).add(provider));
  242. setProviders(prev => {
  243. const providerData = prev[provider] || {};
  244. // Get current models - either from provider config or default models
  245. let currentModels = providerData.modelNames;
  246. if (currentModels === undefined) {
  247. currentModels = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
  248. }
  249. // Don't add duplicates
  250. if (currentModels.includes(model.trim())) return prev;
  251. return {
  252. ...prev,
  253. [provider]: {
  254. ...providerData,
  255. modelNames: [...currentModels, model.trim()],
  256. },
  257. };
  258. });
  259. // Clear the input
  260. setNewModelInputs(prev => ({
  261. ...prev,
  262. [provider]: '',
  263. }));
  264. };
  265. const removeModel = (provider: string, modelToRemove: string) => {
  266. setModifiedProviders(prev => new Set(prev).add(provider));
  267. setProviders(prev => {
  268. const providerData = prev[provider] || {};
  269. // If modelNames doesn't exist in the provider data yet, we need to initialize it
  270. // with the default models from llmProviderModelNames first
  271. if (!providerData.modelNames) {
  272. const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  273. const filteredModels = defaultModels.filter(model => model !== modelToRemove);
  274. return {
  275. ...prev,
  276. [provider]: {
  277. ...providerData,
  278. modelNames: filteredModels,
  279. },
  280. };
  281. }
  282. // If modelNames already exists, just filter out the model to remove
  283. return {
  284. ...prev,
  285. [provider]: {
  286. ...providerData,
  287. modelNames: providerData.modelNames.filter(model => model !== modelToRemove),
  288. },
  289. };
  290. });
  291. };
  292. const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, provider: string) => {
  293. if (e.key === 'Enter' || e.key === ' ') {
  294. e.preventDefault();
  295. const value = newModelInputs[provider] || '';
  296. addModel(provider, value);
  297. }
  298. };
  299. const getButtonProps = (provider: string) => {
  300. const isInStorage = providersFromStorage.has(provider);
  301. const isModified = modifiedProviders.has(provider);
  302. // For deletion, we only care if it's in storage and not modified
  303. if (isInStorage && !isModified) {
  304. return {
  305. theme: isDarkMode ? 'dark' : 'light',
  306. variant: 'danger' as const,
  307. children: 'Delete',
  308. disabled: false,
  309. };
  310. }
  311. // For saving, we need to check if it has the required inputs
  312. let hasInput = false;
  313. const providerType = providers[provider]?.type;
  314. const config = providers[provider];
  315. if (providerType === ProviderTypeEnum.CustomOpenAI) {
  316. hasInput = Boolean(config?.baseUrl?.trim()); // Custom needs Base URL, name checked elsewhere
  317. } else if (providerType === ProviderTypeEnum.Ollama) {
  318. hasInput = Boolean(config?.baseUrl?.trim()); // Ollama needs Base URL
  319. } else if (providerType === ProviderTypeEnum.AzureOpenAI) {
  320. // Azure needs API Key, Endpoint, Deployment Names, and API Version
  321. hasInput =
  322. Boolean(config?.apiKey?.trim()) &&
  323. Boolean(config?.baseUrl?.trim()) &&
  324. Boolean(config?.azureDeploymentNames?.length) &&
  325. Boolean(config?.azureApiVersion?.trim());
  326. } else if (providerType === ProviderTypeEnum.OpenRouter) {
  327. // OpenRouter needs API Key and optionally Base URL (has default)
  328. hasInput = Boolean(config?.apiKey?.trim()) && Boolean(config?.baseUrl?.trim());
  329. } else {
  330. // Other built-in providers just need API Key
  331. hasInput = Boolean(config?.apiKey?.trim());
  332. }
  333. return {
  334. theme: isDarkMode ? 'dark' : 'light',
  335. variant: 'primary' as const,
  336. children: 'Save',
  337. disabled: !hasInput || !isModified,
  338. };
  339. };
  340. const handleSave = async (provider: string) => {
  341. try {
  342. // Check if name contains spaces for custom providers
  343. if (providers[provider].type === ProviderTypeEnum.CustomOpenAI && providers[provider].name?.includes(' ')) {
  344. setNameErrors(prev => ({
  345. ...prev,
  346. [provider]: 'Spaces are not allowed in provider names. Please use underscores or other characters instead.',
  347. }));
  348. return;
  349. }
  350. // Check if base URL is required but missing for custom_openai, ollama, azure_openai or openrouter
  351. if (
  352. (providers[provider].type === ProviderTypeEnum.CustomOpenAI ||
  353. providers[provider].type === ProviderTypeEnum.Ollama ||
  354. providers[provider].type === ProviderTypeEnum.AzureOpenAI ||
  355. providers[provider].type === ProviderTypeEnum.OpenRouter) &&
  356. (!providers[provider].baseUrl || !providers[provider].baseUrl.trim())
  357. ) {
  358. alert(`Base URL is required for ${getDefaultDisplayNameFromProviderId(provider)}. Please enter it.`);
  359. return;
  360. }
  361. // Ensure modelNames is provided
  362. let modelNames = providers[provider].modelNames;
  363. if (!modelNames) {
  364. // Use default model names if not explicitly set
  365. modelNames = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
  366. }
  367. // Prepare data for saving using the correctly typed config from state
  368. // We can directly pass the relevant parts of the state config
  369. // Create a copy to avoid modifying state directly if needed, though setProvider likely handles it
  370. const configToSave: Partial<ProviderConfig> = { ...providers[provider] }; // Use Partial to allow deleting modelNames
  371. // Explicitly set required fields that might be missing in partial state updates (though unlikely now)
  372. configToSave.apiKey = providers[provider].apiKey || '';
  373. configToSave.name = providers[provider].name || getDefaultDisplayNameFromProviderId(provider);
  374. configToSave.type = providers[provider].type;
  375. configToSave.createdAt = providers[provider].createdAt || Date.now();
  376. // baseUrl, azureDeploymentName, azureApiVersion should be correctly set by handlers
  377. if (providers[provider].type === ProviderTypeEnum.AzureOpenAI) {
  378. // Ensure modelNames is NOT included for Azure
  379. configToSave.modelNames = undefined;
  380. } else {
  381. // Ensure modelNames IS included for non-Azure
  382. // Use existing modelNames from state, or default if somehow missing
  383. configToSave.modelNames =
  384. providers[provider].modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  385. }
  386. // Pass the cleaned config to setProvider
  387. // Cast to ProviderConfig as we've ensured necessary fields based on type
  388. await llmProviderStore.setProvider(provider, configToSave as ProviderConfig);
  389. // Clear any name errors on successful save
  390. setNameErrors(prev => {
  391. const newErrors = { ...prev };
  392. delete newErrors[provider];
  393. return newErrors;
  394. });
  395. // Add to providersFromStorage since it's now saved
  396. setProvidersFromStorage(prev => new Set(prev).add(provider));
  397. setModifiedProviders(prev => {
  398. const next = new Set(prev);
  399. next.delete(provider);
  400. return next;
  401. });
  402. // Refresh available models
  403. const models = await getAvailableModelsCallback();
  404. setAvailableModels(models);
  405. } catch (error) {
  406. console.error('Error saving API key:', error);
  407. }
  408. };
  409. const handleDelete = async (provider: string) => {
  410. try {
  411. // Delete the provider from storage regardless of its API key value
  412. await llmProviderStore.removeProvider(provider);
  413. // Remove from providersFromStorage
  414. setProvidersFromStorage(prev => {
  415. const next = new Set(prev);
  416. next.delete(provider);
  417. return next;
  418. });
  419. // Remove from providers state
  420. setProviders(prev => {
  421. const next = { ...prev };
  422. delete next[provider];
  423. return next;
  424. });
  425. // Also remove from modifiedProviders if it's there
  426. setModifiedProviders(prev => {
  427. const next = new Set(prev);
  428. next.delete(provider);
  429. return next;
  430. });
  431. // Refresh available models
  432. const models = await getAvailableModelsCallback();
  433. setAvailableModels(models);
  434. } catch (error) {
  435. console.error('Error deleting provider:', error);
  436. }
  437. };
  438. const handleCancelProvider = (providerId: string) => {
  439. // Remove the provider from the state
  440. setProviders(prev => {
  441. const next = { ...prev };
  442. delete next[providerId];
  443. return next;
  444. });
  445. // Remove from modified providers
  446. setModifiedProviders(prev => {
  447. const next = new Set(prev);
  448. next.delete(providerId);
  449. return next;
  450. });
  451. };
  452. const handleModelChange = async (agentName: AgentNameEnum, modelValue: string) => {
  453. // modelValue will be in format "provider>model"
  454. const [provider, model] = modelValue.split('>');
  455. console.log(`[handleModelChange] Setting ${agentName} model: provider=${provider}, model=${model}`);
  456. // Set parameters based on provider type
  457. const newParameters = getDefaultAgentModelParams(provider, agentName);
  458. setModelParameters(prev => ({
  459. ...prev,
  460. [agentName]: newParameters,
  461. }));
  462. setSelectedModels(prev => ({
  463. ...prev,
  464. [agentName]: model,
  465. }));
  466. try {
  467. if (model) {
  468. const providerConfig = providers[provider];
  469. // For Azure, verify the model is in the deployment names list
  470. if (providerConfig && providerConfig.type === ProviderTypeEnum.AzureOpenAI) {
  471. console.log(`[handleModelChange] Azure model selected: ${model}`);
  472. }
  473. // Reset reasoning effort if switching models
  474. if (isOpenAIOModel(model)) {
  475. // Keep existing reasoning effort if already set for O-series models
  476. setReasoningEffort(prev => ({
  477. ...prev,
  478. [agentName]: prev[agentName] || 'medium', // Default to medium if not set
  479. }));
  480. } else {
  481. // Clear reasoning effort for non-O-series models
  482. setReasoningEffort(prev => ({
  483. ...prev,
  484. [agentName]: undefined,
  485. }));
  486. }
  487. await agentModelStore.setAgentModel(agentName, {
  488. provider,
  489. modelName: model,
  490. parameters: newParameters,
  491. reasoningEffort: isOpenAIOModel(model) ? reasoningEffort[agentName] || 'medium' : undefined,
  492. });
  493. } else {
  494. // Reset storage if no model is selected
  495. await agentModelStore.resetAgentModel(agentName);
  496. }
  497. } catch (error) {
  498. console.error('Error saving agent model:', error);
  499. }
  500. };
  501. const handleReasoningEffortChange = async (agentName: AgentNameEnum, value: 'low' | 'medium' | 'high') => {
  502. setReasoningEffort(prev => ({
  503. ...prev,
  504. [agentName]: value,
  505. }));
  506. // Only update if we have a selected model
  507. if (selectedModels[agentName] && isOpenAIOModel(selectedModels[agentName])) {
  508. try {
  509. // Find provider
  510. const provider = getProviderForModel(selectedModels[agentName]);
  511. if (provider) {
  512. await agentModelStore.setAgentModel(agentName, {
  513. provider,
  514. modelName: selectedModels[agentName],
  515. parameters: modelParameters[agentName],
  516. reasoningEffort: value,
  517. });
  518. }
  519. } catch (error) {
  520. console.error('Error saving reasoning effort:', error);
  521. }
  522. }
  523. };
  524. const handleParameterChange = async (agentName: AgentNameEnum, paramName: 'temperature' | 'topP', value: number) => {
  525. const newParameters = {
  526. ...modelParameters[agentName],
  527. [paramName]: value,
  528. };
  529. setModelParameters(prev => ({
  530. ...prev,
  531. [agentName]: newParameters,
  532. }));
  533. // Only update if we have a selected model
  534. if (selectedModels[agentName]) {
  535. try {
  536. // Find provider
  537. let provider: string | undefined;
  538. for (const [providerKey, providerConfig] of Object.entries(providers)) {
  539. if (providerConfig.type === ProviderTypeEnum.AzureOpenAI) {
  540. // Check Azure deployment names
  541. const deploymentNames = providerConfig.azureDeploymentNames || [];
  542. if (deploymentNames.includes(selectedModels[agentName])) {
  543. provider = providerKey;
  544. break;
  545. }
  546. } else {
  547. // Check standard model names for non-Azure providers
  548. const modelNames =
  549. providerConfig.modelNames ||
  550. llmProviderModelNames[providerKey as keyof typeof llmProviderModelNames] ||
  551. [];
  552. if (modelNames.includes(selectedModels[agentName])) {
  553. provider = providerKey;
  554. break;
  555. }
  556. }
  557. }
  558. if (provider) {
  559. await agentModelStore.setAgentModel(agentName, {
  560. provider,
  561. modelName: selectedModels[agentName],
  562. parameters: newParameters,
  563. });
  564. }
  565. } catch (error) {
  566. console.error('Error saving agent parameters:', error);
  567. }
  568. }
  569. };
  570. const renderModelSelect = (agentName: AgentNameEnum) => (
  571. <div
  572. className={`rounded-lg border ${isDarkMode ? 'border-gray-700 bg-slate-800' : 'border-gray-200 bg-gray-50'} p-4`}>
  573. <h3 className={`mb-2 text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  574. {agentName.charAt(0).toUpperCase() + agentName.slice(1)}
  575. </h3>
  576. <p className={`mb-4 text-sm font-normal ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  577. {getAgentDescription(agentName)}
  578. </p>
  579. <div className="space-y-4">
  580. {/* Model Selection */}
  581. <div className="flex items-center">
  582. <label
  583. htmlFor={`${agentName}-model`}
  584. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  585. Model
  586. </label>
  587. <select
  588. id={`${agentName}-model`}
  589. className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}
  590. disabled={availableModels.length === 0}
  591. value={
  592. selectedModels[agentName]
  593. ? `${getProviderForModel(selectedModels[agentName])}>${selectedModels[agentName]}`
  594. : ''
  595. }
  596. onChange={e => handleModelChange(agentName, e.target.value)}>
  597. <option key="default" value="">
  598. Choose model
  599. </option>
  600. {availableModels.map(({ provider, providerName, model }) => (
  601. <option key={`${provider}>${model}`} value={`${provider}>${model}`}>
  602. {`${providerName} > ${model}`}
  603. </option>
  604. ))}
  605. </select>
  606. </div>
  607. {/* Temperature Slider */}
  608. <div className="flex items-center">
  609. <label
  610. htmlFor={`${agentName}-temperature`}
  611. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  612. Temperature
  613. </label>
  614. <div className="flex flex-1 items-center space-x-2">
  615. <input
  616. id={`${agentName}-temperature`}
  617. type="range"
  618. min="0"
  619. max="2"
  620. step="0.01"
  621. value={modelParameters[agentName].temperature}
  622. onChange={e => handleParameterChange(agentName, 'temperature', Number.parseFloat(e.target.value))}
  623. style={{
  624. background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${(modelParameters[agentName].temperature / 2) * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`,
  625. }}
  626. className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
  627. />
  628. <div className="flex items-center space-x-2">
  629. <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
  630. {modelParameters[agentName].temperature.toFixed(2)}
  631. </span>
  632. <input
  633. type="number"
  634. min="0"
  635. max="2"
  636. step="0.01"
  637. value={modelParameters[agentName].temperature}
  638. onChange={e => {
  639. const value = Number.parseFloat(e.target.value);
  640. if (!Number.isNaN(value) && value >= 0 && value <= 2) {
  641. handleParameterChange(agentName, 'temperature', value);
  642. }
  643. }}
  644. className={`w-20 rounded-md border ${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'} px-2 py-1 text-sm`}
  645. aria-label={`${agentName} temperature number input`}
  646. />
  647. </div>
  648. </div>
  649. </div>
  650. {/* Top P Slider */}
  651. <div className="flex items-center">
  652. <label
  653. htmlFor={`${agentName}-topP`}
  654. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  655. Top P
  656. </label>
  657. <div className="flex flex-1 items-center space-x-2">
  658. <input
  659. id={`${agentName}-topP`}
  660. type="range"
  661. min="0"
  662. max="1"
  663. step="0.001"
  664. value={modelParameters[agentName].topP}
  665. onChange={e => handleParameterChange(agentName, 'topP', Number.parseFloat(e.target.value))}
  666. style={{
  667. background: `linear-gradient(to right, ${isDarkMode ? '#3b82f6' : '#60a5fa'} 0%, ${isDarkMode ? '#3b82f6' : '#60a5fa'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} ${modelParameters[agentName].topP * 100}%, ${isDarkMode ? '#475569' : '#cbd5e1'} 100%)`,
  668. }}
  669. className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
  670. />
  671. <div className="flex items-center space-x-2">
  672. <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
  673. {modelParameters[agentName].topP.toFixed(3)}
  674. </span>
  675. <input
  676. type="number"
  677. min="0"
  678. max="1"
  679. step="0.001"
  680. value={modelParameters[agentName].topP}
  681. onChange={e => {
  682. const value = Number.parseFloat(e.target.value);
  683. if (!Number.isNaN(value) && value >= 0 && value <= 1) {
  684. handleParameterChange(agentName, 'topP', value);
  685. }
  686. }}
  687. className={`w-20 rounded-md border ${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'} px-2 py-1 text-sm`}
  688. aria-label={`${agentName} top P number input`}
  689. />
  690. </div>
  691. </div>
  692. </div>
  693. {/* Reasoning Effort Selector (only for O-series models) */}
  694. {selectedModels[agentName] && isOpenAIOModel(selectedModels[agentName]) && (
  695. <div className="flex items-center">
  696. <label
  697. htmlFor={`${agentName}-reasoning-effort`}
  698. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  699. Reasoning
  700. </label>
  701. <div className="flex flex-1 items-center space-x-2">
  702. <select
  703. id={`${agentName}-reasoning-effort`}
  704. value={reasoningEffort[agentName] || 'medium'}
  705. onChange={e => handleReasoningEffortChange(agentName, e.target.value as 'low' | 'medium' | 'high')}
  706. className={`flex-1 rounded-md border text-sm ${isDarkMode ? 'border-slate-600 bg-slate-700 text-gray-200' : 'border-gray-300 bg-white text-gray-700'} px-3 py-2`}>
  707. <option value="low">Low (Faster)</option>
  708. <option value="medium">Medium (Balanced)</option>
  709. <option value="high">High (More thorough)</option>
  710. </select>
  711. </div>
  712. </div>
  713. )}
  714. </div>
  715. </div>
  716. );
  717. const getAgentDescription = (agentName: AgentNameEnum) => {
  718. switch (agentName) {
  719. case AgentNameEnum.Navigator:
  720. return 'Navigates websites and performs actions';
  721. case AgentNameEnum.Planner:
  722. return 'Develops and refines strategies to complete tasks';
  723. case AgentNameEnum.Validator:
  724. return 'Checks if tasks are completed successfully';
  725. default:
  726. return '';
  727. }
  728. };
  729. const getMaxCustomProviderNumber = () => {
  730. let maxNumber = 0;
  731. for (const providerId of Object.keys(providers)) {
  732. if (providerId.startsWith('custom_openai_')) {
  733. const match = providerId.match(/custom_openai_(\d+)/);
  734. if (match) {
  735. const number = Number.parseInt(match[1], 10);
  736. maxNumber = Math.max(maxNumber, number);
  737. }
  738. }
  739. }
  740. return maxNumber;
  741. };
  742. const addCustomProvider = () => {
  743. const nextNumber = getMaxCustomProviderNumber() + 1;
  744. const providerId = `custom_openai_${nextNumber}`;
  745. setProviders(prev => ({
  746. ...prev,
  747. [providerId]: {
  748. apiKey: '',
  749. name: `CustomProvider${nextNumber}`,
  750. type: ProviderTypeEnum.CustomOpenAI,
  751. baseUrl: '',
  752. modelNames: [],
  753. createdAt: Date.now(),
  754. },
  755. }));
  756. setModifiedProviders(prev => new Set(prev).add(providerId));
  757. // Set the newly added provider ref
  758. newlyAddedProviderRef.current = providerId;
  759. // Scroll to the newly added provider after render
  760. setTimeout(() => {
  761. const providerElement = document.getElementById(`provider-${providerId}`);
  762. if (providerElement) {
  763. providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  764. }
  765. }, 100);
  766. };
  767. const addBuiltInProvider = (provider: string) => {
  768. // Get the default provider configuration
  769. const config = getDefaultProviderConfig(provider);
  770. // Add the provider to the state
  771. setProviders(prev => ({
  772. ...prev,
  773. [provider]: config,
  774. }));
  775. // Mark as modified so it shows up in the UI
  776. setModifiedProviders(prev => new Set(prev).add(provider));
  777. // Set the newly added provider ref
  778. newlyAddedProviderRef.current = provider;
  779. // Scroll to the newly added provider after render
  780. setTimeout(() => {
  781. const providerElement = document.getElementById(`provider-${provider}`);
  782. if (providerElement) {
  783. providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  784. }
  785. }, 100);
  786. };
  787. // Sort providers to ensure newly added providers appear at the bottom
  788. const getSortedProviders = () => {
  789. // Filter providers to only include those from storage and newly added providers
  790. const filteredProviders = Object.entries(providers).filter(([providerId, config]) => {
  791. // ALSO filter out any provider missing a config or type, to satisfy TS
  792. if (!config || !config.type) {
  793. console.warn(`Filtering out provider ${providerId} with missing config or type.`);
  794. return false;
  795. }
  796. // Include if it's from storage
  797. if (providersFromStorage.has(providerId)) {
  798. return true;
  799. }
  800. // Include if it's a newly added provider (has been modified)
  801. if (modifiedProviders.has(providerId)) {
  802. return true;
  803. }
  804. // Exclude providers that aren't from storage and haven't been modified
  805. return false;
  806. });
  807. // Sort the filtered providers
  808. return filteredProviders.sort(([keyA, configA], [keyB, configB]) => {
  809. // Separate newly added providers from stored providers
  810. const isNewA = !providersFromStorage.has(keyA) && modifiedProviders.has(keyA);
  811. const isNewB = !providersFromStorage.has(keyB) && modifiedProviders.has(keyB);
  812. // If one is new and one is stored, new ones go to the end
  813. if (isNewA && !isNewB) return 1;
  814. if (!isNewA && isNewB) return -1;
  815. // If both are new or both are stored, sort by createdAt
  816. if (configA.createdAt && configB.createdAt) {
  817. return configA.createdAt - configB.createdAt; // Sort in ascending order (oldest first)
  818. }
  819. // If only one has createdAt, put the one without createdAt at the end
  820. if (configA.createdAt) return -1;
  821. if (configB.createdAt) return 1;
  822. // If neither has createdAt, sort by type and then name
  823. const isCustomA = configA.type === ProviderTypeEnum.CustomOpenAI;
  824. const isCustomB = configB.type === ProviderTypeEnum.CustomOpenAI;
  825. if (isCustomA && !isCustomB) {
  826. return 1; // Custom providers come after non-custom
  827. }
  828. if (!isCustomA && isCustomB) {
  829. return -1; // Non-custom providers come before custom
  830. }
  831. // Sort alphabetically by name within each group
  832. return (configA.name || keyA).localeCompare(configB.name || keyB);
  833. });
  834. };
  835. const handleProviderSelection = (providerType: string) => {
  836. // Close the dropdown immediately
  837. setIsProviderSelectorOpen(false);
  838. // Handle custom provider
  839. if (providerType === ProviderTypeEnum.CustomOpenAI) {
  840. addCustomProvider();
  841. return;
  842. }
  843. // Handle Azure OpenAI specially to allow multiple instances
  844. if (providerType === ProviderTypeEnum.AzureOpenAI) {
  845. addAzureProvider();
  846. return;
  847. }
  848. // Handle built-in supported providers
  849. addBuiltInProvider(providerType);
  850. };
  851. // New function to add Azure providers with unique IDs
  852. const addAzureProvider = () => {
  853. // Count existing Azure providers
  854. const azureProviders = Object.keys(providers).filter(
  855. key => key === ProviderTypeEnum.AzureOpenAI || key.startsWith(`${ProviderTypeEnum.AzureOpenAI}_`),
  856. );
  857. const nextNumber = azureProviders.length + 1;
  858. // Create unique ID
  859. const providerId =
  860. nextNumber === 1 ? ProviderTypeEnum.AzureOpenAI : `${ProviderTypeEnum.AzureOpenAI}_${nextNumber}`;
  861. // Create config with appropriate name
  862. const config = getDefaultProviderConfig(ProviderTypeEnum.AzureOpenAI);
  863. config.name = `Azure OpenAI ${nextNumber}`;
  864. // Add to providers
  865. setProviders(prev => ({
  866. ...prev,
  867. [providerId]: config,
  868. }));
  869. setModifiedProviders(prev => new Set(prev).add(providerId));
  870. newlyAddedProviderRef.current = providerId;
  871. // Scroll to the newly added provider after render
  872. setTimeout(() => {
  873. const providerElement = document.getElementById(`provider-${providerId}`);
  874. if (providerElement) {
  875. providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  876. }
  877. }, 100);
  878. };
  879. const getProviderForModel = (modelName: string): string => {
  880. for (const [provider, config] of Object.entries(providers)) {
  881. // Check Azure deployment names
  882. if (config.type === ProviderTypeEnum.AzureOpenAI) {
  883. const deploymentNames = config.azureDeploymentNames || [];
  884. if (deploymentNames.includes(modelName)) {
  885. return provider;
  886. }
  887. } else {
  888. // Check regular model names for non-Azure providers
  889. const modelNames =
  890. config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  891. if (modelNames.includes(modelName)) {
  892. return provider;
  893. }
  894. }
  895. }
  896. return '';
  897. };
  898. // Add and remove Azure deployments
  899. const addAzureDeployment = (provider: string, deploymentName: string) => {
  900. if (!deploymentName.trim()) return;
  901. setModifiedProviders(prev => new Set(prev).add(provider));
  902. setProviders(prev => {
  903. const providerData = prev[provider] || {};
  904. // Initialize or use existing deploymentNames array
  905. const deploymentNames = providerData.azureDeploymentNames || [];
  906. // Don't add duplicates
  907. if (deploymentNames.includes(deploymentName.trim())) return prev;
  908. return {
  909. ...prev,
  910. [provider]: {
  911. ...providerData,
  912. azureDeploymentNames: [...deploymentNames, deploymentName.trim()],
  913. },
  914. };
  915. });
  916. // Clear the input
  917. setNewModelInputs(prev => ({
  918. ...prev,
  919. [provider]: '',
  920. }));
  921. };
  922. const removeAzureDeployment = (provider: string, deploymentToRemove: string) => {
  923. setModifiedProviders(prev => new Set(prev).add(provider));
  924. setProviders(prev => {
  925. const providerData = prev[provider] || {};
  926. // Get current deployments
  927. const deploymentNames = providerData.azureDeploymentNames || [];
  928. // Filter out the deployment to remove
  929. const filteredDeployments = deploymentNames.filter(name => name !== deploymentToRemove);
  930. return {
  931. ...prev,
  932. [provider]: {
  933. ...providerData,
  934. azureDeploymentNames: filteredDeployments,
  935. },
  936. };
  937. });
  938. };
  939. const handleAzureApiVersionChange = (provider: string, apiVersion: string) => {
  940. setModifiedProviders(prev => new Set(prev).add(provider));
  941. setProviders(prev => ({
  942. ...prev,
  943. [provider]: {
  944. ...prev[provider],
  945. azureApiVersion: apiVersion.trim(),
  946. },
  947. }));
  948. };
  949. return (
  950. <section className="space-y-6">
  951. {/* LLM Providers Section */}
  952. <div
  953. className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
  954. <h2 className={`mb-4 text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
  955. LLM Providers
  956. </h2>
  957. <div className="space-y-6">
  958. {getSortedProviders().length === 0 ? (
  959. <div className="py-8 text-center text-gray-500">
  960. <p className="mb-4">No providers configured yet. Add a provider to get started.</p>
  961. </div>
  962. ) : (
  963. getSortedProviders().map(([providerId, providerConfig]) => {
  964. // Add type guard to satisfy TypeScript
  965. if (!providerConfig || !providerConfig.type) {
  966. console.warn(`Skipping rendering for providerId ${providerId} due to missing config or type`);
  967. return null; // Skip rendering this item if config/type is somehow missing
  968. }
  969. return (
  970. <div
  971. key={providerId}
  972. id={`provider-${providerId}`}
  973. className={`space-y-4 ${modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) ? `rounded-lg border p-4 ${isDarkMode ? 'border-blue-700 bg-slate-700' : 'border-blue-200 bg-blue-50/70'}` : ''}`}>
  974. <div className="flex items-center justify-between">
  975. <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  976. {providerConfig.name || providerId}
  977. </h3>
  978. <div className="flex space-x-2">
  979. {/* Show Cancel button for newly added providers */}
  980. {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
  981. <Button variant="secondary" onClick={() => handleCancelProvider(providerId)}>
  982. Cancel
  983. </Button>
  984. )}
  985. <Button
  986. variant={getButtonProps(providerId).variant}
  987. disabled={getButtonProps(providerId).disabled}
  988. onClick={() =>
  989. providersFromStorage.has(providerId) && !modifiedProviders.has(providerId)
  990. ? handleDelete(providerId)
  991. : handleSave(providerId)
  992. }>
  993. {getButtonProps(providerId).children}
  994. </Button>
  995. </div>
  996. </div>
  997. {/* Show message for newly added providers */}
  998. {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
  999. <div className={`mb-2 text-sm ${isDarkMode ? 'text-teal-300' : 'text-teal-700'}`}>
  1000. <p>This provider is newly added. Enter your API key and click Save to configure it.</p>
  1001. </div>
  1002. )}
  1003. <div className="space-y-3">
  1004. {/* Name input (only for custom_openai) - moved to top for prominence */}
  1005. {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
  1006. <div className="flex flex-col">
  1007. <div className="flex items-center">
  1008. <label
  1009. htmlFor={`${providerId}-name`}
  1010. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1011. Name
  1012. </label>
  1013. <input
  1014. id={`${providerId}-name`}
  1015. type="text"
  1016. placeholder="Provider name"
  1017. value={providerConfig.name || ''}
  1018. onChange={e => {
  1019. console.log('Name input changed:', e.target.value);
  1020. handleNameChange(providerId, e.target.value);
  1021. }}
  1022. className={`flex-1 rounded-md border p-2 text-sm ${
  1023. nameErrors[providerId]
  1024. ? isDarkMode
  1025. ? 'border-red-700 bg-slate-700 text-gray-200 focus:border-red-600 focus:ring-2 focus:ring-red-900'
  1026. : 'border-red-300 bg-gray-50 focus:border-red-400 focus:ring-2 focus:ring-red-200'
  1027. : isDarkMode
  1028. ? 'border-blue-700 bg-slate-700 text-gray-200 focus:border-blue-600 focus:ring-2 focus:ring-blue-900'
  1029. : 'border-blue-300 bg-gray-50 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'
  1030. } outline-none`}
  1031. />
  1032. </div>
  1033. {nameErrors[providerId] ? (
  1034. <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-red-400' : 'text-red-500'}`}>
  1035. {nameErrors[providerId]}
  1036. </p>
  1037. ) : (
  1038. <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  1039. Provider name (spaces are not allowed when saving)
  1040. </p>
  1041. )}
  1042. </div>
  1043. )}
  1044. {/* API Key input with label */}
  1045. <div className="flex items-center">
  1046. <label
  1047. htmlFor={`${providerId}-api-key`}
  1048. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1049. API Key
  1050. {/* Show asterisk only if required */}
  1051. {providerConfig.type !== ProviderTypeEnum.CustomOpenAI &&
  1052. providerConfig.type !== ProviderTypeEnum.Ollama
  1053. ? '*'
  1054. : ''}
  1055. </label>
  1056. <div className="relative flex-1">
  1057. <input
  1058. id={`${providerId}-api-key`}
  1059. type="password"
  1060. placeholder={
  1061. providerConfig.type === ProviderTypeEnum.CustomOpenAI
  1062. ? `${providerConfig.name || providerId} API key (optional)`
  1063. : providerConfig.type === ProviderTypeEnum.Ollama
  1064. ? 'API Key (leave empty for Ollama)'
  1065. : `${providerConfig.name || providerId} API key (required)`
  1066. }
  1067. value={providerConfig.apiKey || ''}
  1068. onChange={e => handleApiKeyChange(providerId, e.target.value, providerConfig.baseUrl)}
  1069. className={`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`}
  1070. />
  1071. {/* Show eye button only for newly added providers */}
  1072. {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
  1073. <button
  1074. type="button"
  1075. className={`absolute right-2 top-1/2 -translate-y-1/2 ${
  1076. isDarkMode ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'
  1077. }`}
  1078. onClick={() => toggleApiKeyVisibility(providerId)}
  1079. aria-label={visibleApiKeys[providerId] ? 'Hide API key' : 'Show API key'}>
  1080. <svg
  1081. xmlns="http://www.w3.org/2000/svg"
  1082. viewBox="0 0 24 24"
  1083. fill="none"
  1084. stroke="currentColor"
  1085. strokeWidth="2"
  1086. strokeLinecap="round"
  1087. strokeLinejoin="round"
  1088. className="size-5"
  1089. aria-hidden="true">
  1090. <title>{visibleApiKeys[providerId] ? 'Hide API key' : 'Show API key'}</title>
  1091. {visibleApiKeys[providerId] ? (
  1092. <>
  1093. <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
  1094. <circle cx="12" cy="12" r="3" />
  1095. <line x1="2" y1="22" x2="22" y2="2" />
  1096. </>
  1097. ) : (
  1098. <>
  1099. <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
  1100. <circle cx="12" cy="12" r="3" />
  1101. </>
  1102. )}
  1103. </svg>
  1104. </button>
  1105. )}
  1106. </div>
  1107. </div>
  1108. {/* Display API key for newly added providers only when visible */}
  1109. {modifiedProviders.has(providerId) &&
  1110. !providersFromStorage.has(providerId) &&
  1111. visibleApiKeys[providerId] &&
  1112. providerConfig.apiKey && (
  1113. <div className="ml-20 mt-1">
  1114. <p
  1115. className={`break-words font-mono text-sm ${isDarkMode ? 'text-emerald-400' : 'text-emerald-600'}`}>
  1116. {providerConfig.apiKey}
  1117. </p>
  1118. </div>
  1119. )}
  1120. {/* Base URL input (for custom_openai, ollama, azure_openai, and openrouter) */}
  1121. {(providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
  1122. providerConfig.type === ProviderTypeEnum.Ollama ||
  1123. providerConfig.type === ProviderTypeEnum.AzureOpenAI ||
  1124. providerConfig.type === ProviderTypeEnum.OpenRouter) && (
  1125. <div className="flex flex-col">
  1126. <div className="flex items-center">
  1127. <label
  1128. htmlFor={`${providerId}-base-url`}
  1129. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1130. {/* Adjust Label based on provider */}
  1131. {providerConfig.type === ProviderTypeEnum.AzureOpenAI ? 'Endpoint' : 'Base URL'}
  1132. {/* Show asterisk only if required */}
  1133. {/* OpenRouter has a default, so not strictly required, but needed for save button */}
  1134. {providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
  1135. providerConfig.type === ProviderTypeEnum.AzureOpenAI
  1136. ? '*'
  1137. : ''}
  1138. </label>
  1139. <input
  1140. id={`${providerId}-base-url`}
  1141. type="text"
  1142. placeholder={
  1143. providerConfig.type === ProviderTypeEnum.CustomOpenAI
  1144. ? 'Required OpenAI-compatible API endpoint'
  1145. : providerConfig.type === ProviderTypeEnum.AzureOpenAI
  1146. ? // Updated Azure placeholder
  1147. 'https://YOUR_RESOURCE_NAME.openai.azure.com/'
  1148. : providerConfig.type === ProviderTypeEnum.OpenRouter
  1149. ? 'OpenRouter Base URL (optional, defaults to https://openrouter.ai/api/v1)'
  1150. : 'Ollama base URL'
  1151. }
  1152. value={providerConfig.baseUrl || ''}
  1153. onChange={e => handleApiKeyChange(providerId, providerConfig.apiKey || '', e.target.value)}
  1154. className={`flex-1 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`}
  1155. />
  1156. </div>
  1157. </div>
  1158. )}
  1159. {/* Azure Deployment Name input as tags/chips like OpenRouter models */}
  1160. {(providerConfig.type as ProviderTypeEnum) === ProviderTypeEnum.AzureOpenAI && (
  1161. <div className="flex items-start">
  1162. <label
  1163. htmlFor={`${providerId}-azure-deployment`}
  1164. className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1165. Deployment*
  1166. </label>
  1167. <div className="flex-1 space-y-2">
  1168. <div
  1169. 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`}>
  1170. {/* Show azure deployments */}
  1171. {(providerConfig.azureDeploymentNames || []).length > 0
  1172. ? (providerConfig.azureDeploymentNames || []).map((deploymentName: string) => (
  1173. <div
  1174. key={deploymentName}
  1175. 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`}>
  1176. <span>{deploymentName}</span>
  1177. <button
  1178. type="button"
  1179. onClick={() => removeAzureDeployment(providerId, deploymentName)}
  1180. className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
  1181. aria-label={`Remove ${deploymentName}`}>
  1182. ×
  1183. </button>
  1184. </div>
  1185. ))
  1186. : null}
  1187. <input
  1188. id={`${providerId}-azure-deployment-input`}
  1189. type="text"
  1190. placeholder="Enter Azure model name (e.g. gpt-4o, gpt-4o-mini)"
  1191. value={newModelInputs[providerId] || ''}
  1192. onChange={e => handleModelsChange(providerId, e.target.value)}
  1193. onKeyDown={e => {
  1194. if (e.key === 'Enter' || e.key === ' ') {
  1195. e.preventDefault();
  1196. const value = newModelInputs[providerId] || '';
  1197. if (value.trim()) {
  1198. addAzureDeployment(providerId, value.trim());
  1199. // Clear the input
  1200. setNewModelInputs(prev => ({
  1201. ...prev,
  1202. [providerId]: '',
  1203. }));
  1204. }
  1205. }
  1206. }}
  1207. 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`}
  1208. />
  1209. </div>
  1210. <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  1211. Type model name and press Enter or Space to set. Deployment name should match OpenAI model
  1212. name (e.g., gpt-4o) for best compatibility.
  1213. </p>
  1214. </div>
  1215. </div>
  1216. )}
  1217. {/* NEW: Azure API Version input */}
  1218. {(providerConfig.type as ProviderTypeEnum) === ProviderTypeEnum.AzureOpenAI && (
  1219. <div className="flex items-center">
  1220. <label
  1221. htmlFor={`${providerId}-azure-version`}
  1222. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1223. API Version*
  1224. </label>
  1225. <input
  1226. id={`${providerId}-azure-version`}
  1227. type="text"
  1228. placeholder="e.g., 2024-02-15-preview" // Common example
  1229. value={providerConfig.azureApiVersion || ''}
  1230. onChange={e => handleAzureApiVersionChange(providerId, e.target.value)}
  1231. className={`flex-1 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`}
  1232. />
  1233. </div>
  1234. )}
  1235. {/* Models input section (for non-Azure providers) */}
  1236. {(providerConfig.type as ProviderTypeEnum) !== ProviderTypeEnum.AzureOpenAI && (
  1237. <div className="flex items-start">
  1238. <label
  1239. htmlFor={`${providerId}-models-label`}
  1240. className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  1241. Models
  1242. </label>
  1243. <div className="flex-1 space-y-2">
  1244. {/* Conditional UI for OpenRouter */}
  1245. {(providerConfig.type as ProviderTypeEnum) === ProviderTypeEnum.OpenRouter ? (
  1246. <>
  1247. <div
  1248. 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`}>
  1249. {providerConfig.modelNames && providerConfig.modelNames.length > 0 ? (
  1250. providerConfig.modelNames.map(model => (
  1251. <div
  1252. key={model}
  1253. 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`}>
  1254. <span>{model}</span>
  1255. <button
  1256. type="button"
  1257. onClick={() => removeModel(providerId, model)}
  1258. className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
  1259. aria-label={`Remove ${model}`}>
  1260. ×
  1261. </button>
  1262. </div>
  1263. ))
  1264. ) : (
  1265. <span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  1266. No models selected. Add model names manually if needed.
  1267. </span>
  1268. )}
  1269. <input
  1270. id={`${providerId}-models-input`}
  1271. type="text"
  1272. placeholder=""
  1273. value={newModelInputs[providerId] || ''}
  1274. onChange={e => handleModelsChange(providerId, e.target.value)}
  1275. onKeyDown={e => handleKeyDown(e, providerId)}
  1276. 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`}
  1277. />
  1278. </div>
  1279. <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  1280. Type and Press Enter or Space to add.
  1281. </p>
  1282. </>
  1283. ) : (
  1284. /* Default Tag Input for other providers */
  1285. <>
  1286. <div
  1287. 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`}>
  1288. {(() => {
  1289. const models =
  1290. providerConfig.modelNames !== undefined
  1291. ? providerConfig.modelNames
  1292. : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
  1293. return models.map(model => (
  1294. <div
  1295. key={model}
  1296. 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`}>
  1297. <span>{model}</span>
  1298. <button
  1299. type="button"
  1300. onClick={() => removeModel(providerId, model)}
  1301. className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
  1302. aria-label={`Remove ${model}`}>
  1303. ×
  1304. </button>
  1305. </div>
  1306. ));
  1307. })()}
  1308. <input
  1309. id={`${providerId}-models-input`}
  1310. type="text"
  1311. placeholder=""
  1312. value={newModelInputs[providerId] || ''}
  1313. onChange={e => handleModelsChange(providerId, e.target.value)}
  1314. onKeyDown={e => handleKeyDown(e, providerId)}
  1315. 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`}
  1316. />
  1317. </div>
  1318. <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  1319. Type and Press Enter or Space to add.
  1320. </p>
  1321. </>
  1322. )}
  1323. {/* === END: Conditional UI === */}
  1324. </div>
  1325. </div>
  1326. )}
  1327. {/* Ollama reminder at the bottom of the section */}
  1328. {providerConfig.type === ProviderTypeEnum.Ollama && (
  1329. <div
  1330. className={`mt-4 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700' : 'border-blue-100 bg-blue-50'} p-3`}>
  1331. <p className={`text-sm ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
  1332. <strong>Remember:</strong> Add{' '}
  1333. <code
  1334. className={`rounded italic ${isDarkMode ? 'bg-slate-600 px-1 py-0.5' : 'bg-blue-100 px-1 py-0.5'}`}>
  1335. OLLAMA_ORIGINS=chrome-extension://*
  1336. </code>{' '}
  1337. environment variable for the Ollama server.
  1338. <a
  1339. href="https://github.com/ollama/ollama/issues/6489"
  1340. target="_blank"
  1341. rel="noopener noreferrer"
  1342. className={`ml-1 ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800'}`}>
  1343. Learn more
  1344. </a>
  1345. </p>
  1346. </div>
  1347. )}
  1348. </div>
  1349. {/* Add divider except for the last item */}
  1350. {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
  1351. <div className={`mt-4 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`} />
  1352. )}
  1353. </div>
  1354. );
  1355. })
  1356. )}
  1357. {/* Add Provider button and dropdown */}
  1358. <div className="provider-selector-container relative pt-4">
  1359. <Button
  1360. variant="secondary"
  1361. onClick={() => setIsProviderSelectorOpen(prev => !prev)}
  1362. className={`flex w-full items-center justify-center font-medium ${
  1363. isDarkMode
  1364. ? 'border-blue-700 bg-blue-600 text-white hover:bg-blue-500'
  1365. : 'border-blue-200 bg-blue-100 text-blue-800 hover:bg-blue-200'
  1366. }`}>
  1367. <span className="mr-2 text-sm">+</span> <span className="text-sm">Add New Provider</span>
  1368. </Button>
  1369. {isProviderSelectorOpen && (
  1370. <div
  1371. className={`absolute z-10 mt-2 w-full overflow-hidden rounded-md border ${
  1372. isDarkMode
  1373. ? 'border-blue-600 bg-slate-700 shadow-lg shadow-slate-900/50'
  1374. : 'border-blue-200 bg-white shadow-xl shadow-blue-100/50'
  1375. }`}>
  1376. <div className="py-1">
  1377. {/* Map through provider types to create buttons */}
  1378. {Object.values(ProviderTypeEnum)
  1379. // Allow Azure to appear multiple times, but filter out other already added providers
  1380. .filter(
  1381. type =>
  1382. type === ProviderTypeEnum.AzureOpenAI || // Always show Azure
  1383. (type !== ProviderTypeEnum.CustomOpenAI &&
  1384. !providersFromStorage.has(type) &&
  1385. !modifiedProviders.has(type)),
  1386. )
  1387. .map(type => (
  1388. <button
  1389. key={type}
  1390. type="button"
  1391. className={`flex w-full items-center px-4 py-3 text-left text-sm ${
  1392. isDarkMode
  1393. ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
  1394. : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
  1395. } transition-colors duration-150`}
  1396. onClick={() => handleProviderSelection(type)}>
  1397. <span className="font-medium">{getDefaultDisplayNameFromProviderId(type)}</span>
  1398. </button>
  1399. ))}
  1400. {/* Custom provider button (always shown) */}
  1401. <button
  1402. type="button"
  1403. className={`flex w-full items-center px-4 py-3 text-left text-sm ${
  1404. isDarkMode
  1405. ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
  1406. : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
  1407. } transition-colors duration-150`}
  1408. onClick={() => handleProviderSelection(ProviderTypeEnum.CustomOpenAI)}>
  1409. <span className="font-medium">OpenAI-compatible API Provider</span>
  1410. </button>
  1411. </div>
  1412. </div>
  1413. )}
  1414. </div>
  1415. </div>
  1416. </div>
  1417. {/* Updated Agent Models Section */}
  1418. <div
  1419. className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
  1420. <h2 className={`mb-4 text-left text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
  1421. Model Selection
  1422. </h2>
  1423. <div className="space-y-4">
  1424. {[AgentNameEnum.Planner, AgentNameEnum.Navigator, AgentNameEnum.Validator].map(agentName => (
  1425. <div key={agentName}>{renderModelSelect(agentName)}</div>
  1426. ))}
  1427. </div>
  1428. </div>
  1429. </section>
  1430. );
  1431. };