ModelSettings.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  1. import { useEffect, useState, useRef, useCallback } from 'react';
  2. import type { KeyboardEvent } from 'react';
  3. import { Button } from '@extension/ui';
  4. import {
  5. llmProviderStore,
  6. agentModelStore,
  7. AgentNameEnum,
  8. llmProviderModelNames,
  9. ProviderTypeEnum,
  10. getDefaultDisplayNameFromProviderId,
  11. getDefaultProviderConfig,
  12. getDefaultAgentModelParams,
  13. } from '@extension/storage';
  14. interface ModelSettingsProps {
  15. isDarkMode?: boolean;
  16. }
  17. export const ModelSettings = ({ isDarkMode = false }: ModelSettingsProps) => {
  18. const [providers, setProviders] = useState<
  19. Record<
  20. string,
  21. {
  22. apiKey: string;
  23. baseUrl?: string;
  24. name?: string;
  25. modelNames?: string[];
  26. type?: ProviderTypeEnum;
  27. createdAt?: number;
  28. }
  29. >
  30. >({});
  31. const [modifiedProviders, setModifiedProviders] = useState<Set<string>>(new Set());
  32. const [providersFromStorage, setProvidersFromStorage] = useState<Set<string>>(new Set());
  33. const [selectedModels, setSelectedModels] = useState<Record<AgentNameEnum, string>>({
  34. [AgentNameEnum.Navigator]: '',
  35. [AgentNameEnum.Planner]: '',
  36. [AgentNameEnum.Validator]: '',
  37. });
  38. const [modelParameters, setModelParameters] = useState<Record<AgentNameEnum, { temperature: number; topP: number }>>({
  39. [AgentNameEnum.Navigator]: { temperature: 0, topP: 0 },
  40. [AgentNameEnum.Planner]: { temperature: 0, topP: 0 },
  41. [AgentNameEnum.Validator]: { temperature: 0, topP: 0 },
  42. });
  43. const [newModelInputs, setNewModelInputs] = useState<Record<string, string>>({});
  44. const [isProviderSelectorOpen, setIsProviderSelectorOpen] = useState(false);
  45. const newlyAddedProviderRef = useRef<string | null>(null);
  46. const [nameErrors, setNameErrors] = useState<Record<string, string>>({});
  47. // Create a non-async wrapper for use in render functions
  48. const [availableModels, setAvailableModels] = useState<
  49. Array<{ provider: string; providerName: string; model: string }>
  50. >([]);
  51. useEffect(() => {
  52. const loadProviders = async () => {
  53. try {
  54. const allProviders = await llmProviderStore.getAllProviders();
  55. console.log('allProviders', allProviders);
  56. // Track which providers are from storage
  57. const fromStorage = new Set(Object.keys(allProviders));
  58. setProvidersFromStorage(fromStorage);
  59. // Only use providers from storage, don't add default ones
  60. setProviders(allProviders);
  61. } catch (error) {
  62. console.error('Error loading providers:', error);
  63. // Set empty providers on error
  64. setProviders({});
  65. // No providers from storage on error
  66. setProvidersFromStorage(new Set());
  67. }
  68. };
  69. loadProviders();
  70. }, []);
  71. // Load existing agent models and parameters on mount
  72. useEffect(() => {
  73. const loadAgentModels = async () => {
  74. try {
  75. const models: Record<AgentNameEnum, string> = {
  76. [AgentNameEnum.Planner]: '',
  77. [AgentNameEnum.Navigator]: '',
  78. [AgentNameEnum.Validator]: '',
  79. };
  80. for (const agent of Object.values(AgentNameEnum)) {
  81. const config = await agentModelStore.getAgentModel(agent);
  82. if (config) {
  83. models[agent] = config.modelName;
  84. if (config.parameters?.temperature !== undefined || config.parameters?.topP !== undefined) {
  85. setModelParameters(prev => ({
  86. ...prev,
  87. [agent]: {
  88. temperature: config.parameters?.temperature ?? prev[agent].temperature,
  89. topP: config.parameters?.topP ?? prev[agent].topP,
  90. },
  91. }));
  92. }
  93. }
  94. }
  95. setSelectedModels(models);
  96. } catch (error) {
  97. console.error('Error loading agent models:', error);
  98. }
  99. };
  100. loadAgentModels();
  101. }, []);
  102. // Auto-focus the input field when a new provider is added
  103. useEffect(() => {
  104. // Only focus if we have a newly added provider reference
  105. if (newlyAddedProviderRef.current && providers[newlyAddedProviderRef.current]) {
  106. const providerId = newlyAddedProviderRef.current;
  107. const config = providers[providerId];
  108. // For custom providers, focus on the name input
  109. if (config.type === ProviderTypeEnum.CustomOpenAI) {
  110. const nameInput = document.getElementById(`${providerId}-name`);
  111. if (nameInput) {
  112. nameInput.focus();
  113. }
  114. } else {
  115. // For default providers, focus on the API key input
  116. const apiKeyInput = document.getElementById(`${providerId}-api-key`);
  117. if (apiKeyInput) {
  118. apiKeyInput.focus();
  119. }
  120. }
  121. // Clear the ref after focusing
  122. newlyAddedProviderRef.current = null;
  123. }
  124. }, [providers]);
  125. // Add a click outside handler to close the dropdown
  126. useEffect(() => {
  127. const handleClickOutside = (event: MouseEvent) => {
  128. const target = event.target as HTMLElement;
  129. if (isProviderSelectorOpen && !target.closest('.provider-selector-container')) {
  130. setIsProviderSelectorOpen(false);
  131. }
  132. };
  133. document.addEventListener('mousedown', handleClickOutside);
  134. return () => {
  135. document.removeEventListener('mousedown', handleClickOutside);
  136. };
  137. }, [isProviderSelectorOpen]);
  138. // Create a memoized version of getAvailableModels
  139. const getAvailableModelsCallback = useCallback(async () => {
  140. const models: Array<{ provider: string; providerName: string; model: string }> = [];
  141. try {
  142. // Load providers directly from storage
  143. const storedProviders = await llmProviderStore.getAllProviders();
  144. // Only use providers that are actually in storage
  145. for (const [provider, config] of Object.entries(storedProviders)) {
  146. const providerModels =
  147. config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  148. models.push(
  149. ...providerModels.map(model => ({
  150. provider,
  151. providerName: config.name || provider,
  152. model,
  153. })),
  154. );
  155. }
  156. } catch (error) {
  157. console.error('Error loading providers for model selection:', error);
  158. }
  159. return models;
  160. }, []);
  161. // Update available models whenever providers change
  162. useEffect(() => {
  163. const updateAvailableModels = async () => {
  164. const models = await getAvailableModelsCallback();
  165. setAvailableModels(models);
  166. };
  167. updateAvailableModels();
  168. }, [getAvailableModelsCallback]); // Only depends on the callback
  169. const handleApiKeyChange = (provider: string, apiKey: string, baseUrl?: string) => {
  170. setModifiedProviders(prev => new Set(prev).add(provider));
  171. setProviders(prev => ({
  172. ...prev,
  173. [provider]: {
  174. ...prev[provider],
  175. apiKey: apiKey.trim(),
  176. baseUrl: baseUrl !== undefined ? baseUrl.trim() : prev[provider]?.baseUrl,
  177. },
  178. }));
  179. };
  180. const handleNameChange = (provider: string, name: string) => {
  181. setModifiedProviders(prev => new Set(prev).add(provider));
  182. setProviders(prev => {
  183. const updated = {
  184. ...prev,
  185. [provider]: {
  186. ...prev[provider],
  187. name: name.trim(),
  188. },
  189. };
  190. return updated;
  191. });
  192. };
  193. const handleModelsChange = (provider: string, modelsString: string) => {
  194. setNewModelInputs(prev => ({
  195. ...prev,
  196. [provider]: modelsString,
  197. }));
  198. };
  199. const addModel = (provider: string, model: string) => {
  200. if (!model.trim()) return;
  201. setModifiedProviders(prev => new Set(prev).add(provider));
  202. setProviders(prev => {
  203. const providerData = prev[provider] || {};
  204. // Get current models - either from provider config or default models
  205. let currentModels = providerData.modelNames;
  206. if (currentModels === undefined) {
  207. currentModels = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
  208. }
  209. // Don't add duplicates
  210. if (currentModels.includes(model.trim())) return prev;
  211. return {
  212. ...prev,
  213. [provider]: {
  214. ...providerData,
  215. modelNames: [...currentModels, model.trim()],
  216. },
  217. };
  218. });
  219. // Clear the input
  220. setNewModelInputs(prev => ({
  221. ...prev,
  222. [provider]: '',
  223. }));
  224. };
  225. const removeModel = (provider: string, modelToRemove: string) => {
  226. setModifiedProviders(prev => new Set(prev).add(provider));
  227. setProviders(prev => {
  228. const providerData = prev[provider] || {};
  229. // If modelNames doesn't exist in the provider data yet, we need to initialize it
  230. // with the default models from llmProviderModelNames first
  231. if (!providerData.modelNames) {
  232. const defaultModels = llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  233. const filteredModels = defaultModels.filter(model => model !== modelToRemove);
  234. return {
  235. ...prev,
  236. [provider]: {
  237. ...providerData,
  238. modelNames: filteredModels,
  239. },
  240. };
  241. }
  242. // If modelNames already exists, just filter out the model to remove
  243. return {
  244. ...prev,
  245. [provider]: {
  246. ...providerData,
  247. modelNames: providerData.modelNames.filter(model => model !== modelToRemove),
  248. },
  249. };
  250. });
  251. };
  252. const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, provider: string) => {
  253. if (e.key === 'Enter' || e.key === ' ') {
  254. e.preventDefault();
  255. const value = newModelInputs[provider] || '';
  256. addModel(provider, value);
  257. }
  258. };
  259. const getButtonProps = (provider: string) => {
  260. const isInStorage = providersFromStorage.has(provider);
  261. const isModified = modifiedProviders.has(provider);
  262. const isCustom = providers[provider]?.type === ProviderTypeEnum.CustomOpenAI;
  263. // For deletion, we only care if it's in storage and not modified
  264. if (isInStorage && !isModified) {
  265. return {
  266. theme: isDarkMode ? 'dark' : 'light',
  267. variant: 'danger' as const,
  268. children: 'Delete',
  269. disabled: false,
  270. };
  271. }
  272. // For saving, we need to check if it has the required inputs
  273. // Only custom providers can be saved without an API key
  274. const hasInput = isCustom || Boolean(providers[provider]?.apiKey?.trim());
  275. return {
  276. theme: isDarkMode ? 'dark' : 'light',
  277. variant: 'primary' as const,
  278. children: 'Save',
  279. disabled: !hasInput || !isModified,
  280. };
  281. };
  282. const handleSave = async (provider: string) => {
  283. try {
  284. // Check if name contains spaces for custom providers
  285. if (providers[provider].type === ProviderTypeEnum.CustomOpenAI && providers[provider].name?.includes(' ')) {
  286. setNameErrors(prev => ({
  287. ...prev,
  288. [provider]: 'Spaces are not allowed in provider names. Please use underscores or other characters instead.',
  289. }));
  290. return;
  291. }
  292. // Check if base URL is required but missing for custom_openai
  293. if (
  294. providers[provider].type === ProviderTypeEnum.CustomOpenAI &&
  295. (!providers[provider].baseUrl || !providers[provider].baseUrl.trim())
  296. ) {
  297. alert('Base URL is required for custom OpenAI-compatible API providers');
  298. return;
  299. }
  300. // Check if API key is required but empty for built-in providers (except custom)
  301. const isCustom = providers[provider].type === ProviderTypeEnum.CustomOpenAI;
  302. if (!isCustom && (!providers[provider].apiKey || !providers[provider].apiKey.trim())) {
  303. alert('API key is required for this provider');
  304. return;
  305. }
  306. // Ensure modelNames is provided
  307. let modelNames = providers[provider].modelNames;
  308. if (!modelNames) {
  309. // Use default model names if not explicitly set
  310. modelNames = [...(llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [])];
  311. }
  312. // The provider store will handle filling in the missing fields
  313. await llmProviderStore.setProvider(provider, {
  314. apiKey: providers[provider].apiKey || '',
  315. baseUrl: providers[provider].baseUrl,
  316. name: providers[provider].name,
  317. modelNames: modelNames,
  318. type: providers[provider].type,
  319. createdAt: providers[provider].createdAt,
  320. });
  321. // Clear any name errors on successful save
  322. setNameErrors(prev => {
  323. const newErrors = { ...prev };
  324. delete newErrors[provider];
  325. return newErrors;
  326. });
  327. // Add to providersFromStorage since it's now saved
  328. setProvidersFromStorage(prev => new Set(prev).add(provider));
  329. setModifiedProviders(prev => {
  330. const next = new Set(prev);
  331. next.delete(provider);
  332. return next;
  333. });
  334. // Refresh available models
  335. const models = await getAvailableModelsCallback();
  336. setAvailableModels(models);
  337. } catch (error) {
  338. console.error('Error saving API key:', error);
  339. }
  340. };
  341. const handleDelete = async (provider: string) => {
  342. try {
  343. // Delete the provider from storage regardless of its API key value
  344. await llmProviderStore.removeProvider(provider);
  345. // Remove from providersFromStorage
  346. setProvidersFromStorage(prev => {
  347. const next = new Set(prev);
  348. next.delete(provider);
  349. return next;
  350. });
  351. // Remove from providers state
  352. setProviders(prev => {
  353. const next = { ...prev };
  354. delete next[provider];
  355. return next;
  356. });
  357. // Also remove from modifiedProviders if it's there
  358. setModifiedProviders(prev => {
  359. const next = new Set(prev);
  360. next.delete(provider);
  361. return next;
  362. });
  363. // Refresh available models
  364. const models = await getAvailableModelsCallback();
  365. setAvailableModels(models);
  366. } catch (error) {
  367. console.error('Error deleting provider:', error);
  368. }
  369. };
  370. const handleCancelProvider = (providerId: string) => {
  371. // Remove the provider from the state
  372. setProviders(prev => {
  373. const next = { ...prev };
  374. delete next[providerId];
  375. return next;
  376. });
  377. // Remove from modified providers
  378. setModifiedProviders(prev => {
  379. const next = new Set(prev);
  380. next.delete(providerId);
  381. return next;
  382. });
  383. };
  384. const handleModelChange = async (agentName: AgentNameEnum, modelValue: string) => {
  385. // modelValue will be in format "provider>model"
  386. const [provider, model] = modelValue.split('>');
  387. // Set parameters based on provider type
  388. const newParameters = getDefaultAgentModelParams(provider, agentName);
  389. setModelParameters(prev => ({
  390. ...prev,
  391. [agentName]: newParameters,
  392. }));
  393. setSelectedModels(prev => ({
  394. ...prev,
  395. [agentName]: model,
  396. }));
  397. try {
  398. if (model) {
  399. await agentModelStore.setAgentModel(agentName, {
  400. provider,
  401. modelName: model,
  402. parameters: newParameters,
  403. });
  404. } else {
  405. // Reset storage if no model is selected
  406. await agentModelStore.resetAgentModel(agentName);
  407. }
  408. } catch (error) {
  409. console.error('Error saving agent model:', error);
  410. }
  411. };
  412. const handleParameterChange = async (agentName: AgentNameEnum, paramName: 'temperature' | 'topP', value: number) => {
  413. const newParameters = {
  414. ...modelParameters[agentName],
  415. [paramName]: value,
  416. };
  417. setModelParameters(prev => ({
  418. ...prev,
  419. [agentName]: newParameters,
  420. }));
  421. // Only update if we have a selected model
  422. if (selectedModels[agentName]) {
  423. try {
  424. // Find provider
  425. let provider: string | undefined;
  426. for (const [providerKey, providerConfig] of Object.entries(providers)) {
  427. const modelNames =
  428. providerConfig.modelNames || llmProviderModelNames[providerKey as keyof typeof llmProviderModelNames] || [];
  429. if (modelNames.includes(selectedModels[agentName])) {
  430. provider = providerKey;
  431. break;
  432. }
  433. }
  434. if (provider) {
  435. await agentModelStore.setAgentModel(agentName, {
  436. provider,
  437. modelName: selectedModels[agentName],
  438. parameters: newParameters,
  439. });
  440. }
  441. } catch (error) {
  442. console.error('Error saving agent parameters:', error);
  443. }
  444. }
  445. };
  446. const renderModelSelect = (agentName: AgentNameEnum) => (
  447. <div
  448. className={`rounded-lg border ${isDarkMode ? 'border-gray-700 bg-slate-800' : 'border-gray-200 bg-gray-50'} p-4`}>
  449. <h3 className={`mb-2 text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  450. {agentName.charAt(0).toUpperCase() + agentName.slice(1)}
  451. </h3>
  452. <p className={`mb-4 text-sm font-normal ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  453. {getAgentDescription(agentName)}
  454. </p>
  455. <div className="space-y-4">
  456. {/* Model Selection */}
  457. <div className="flex items-center">
  458. <label
  459. htmlFor={`${agentName}-model`}
  460. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  461. Model
  462. </label>
  463. <select
  464. id={`${agentName}-model`}
  465. 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`}
  466. disabled={availableModels.length === 0}
  467. value={
  468. selectedModels[agentName]
  469. ? `${getProviderForModel(selectedModels[agentName])}>${selectedModels[agentName]}`
  470. : ''
  471. }
  472. onChange={e => handleModelChange(agentName, e.target.value)}>
  473. <option key="default" value="">
  474. Choose model
  475. </option>
  476. {availableModels.map(({ provider, providerName, model }) => (
  477. <option key={`${provider}>${model}`} value={`${provider}>${model}`}>
  478. {`${providerName} > ${model}`}
  479. </option>
  480. ))}
  481. </select>
  482. </div>
  483. {/* Temperature Slider */}
  484. <div className="flex items-center">
  485. <label
  486. htmlFor={`${agentName}-temperature`}
  487. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  488. Temperature
  489. </label>
  490. <div className="flex flex-1 items-center space-x-2">
  491. <input
  492. id={`${agentName}-temperature`}
  493. type="range"
  494. min="0"
  495. max="2"
  496. step="0.01"
  497. value={modelParameters[agentName].temperature}
  498. onChange={e => handleParameterChange(agentName, 'temperature', Number.parseFloat(e.target.value))}
  499. style={{
  500. 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%)`,
  501. }}
  502. className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
  503. />
  504. <div className="flex items-center space-x-2">
  505. <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
  506. {modelParameters[agentName].temperature.toFixed(2)}
  507. </span>
  508. <input
  509. type="number"
  510. min="0"
  511. max="2"
  512. step="0.01"
  513. value={modelParameters[agentName].temperature}
  514. onChange={e => {
  515. const value = Number.parseFloat(e.target.value);
  516. if (!Number.isNaN(value) && value >= 0 && value <= 2) {
  517. handleParameterChange(agentName, 'temperature', value);
  518. }
  519. }}
  520. 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`}
  521. aria-label={`${agentName} temperature number input`}
  522. />
  523. </div>
  524. </div>
  525. </div>
  526. {/* Top P Slider */}
  527. <div className="flex items-center">
  528. <label
  529. htmlFor={`${agentName}-topP`}
  530. className={`w-24 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  531. Top P
  532. </label>
  533. <div className="flex flex-1 items-center space-x-2">
  534. <input
  535. id={`${agentName}-topP`}
  536. type="range"
  537. min="0"
  538. max="1"
  539. step="0.001"
  540. value={modelParameters[agentName].topP}
  541. onChange={e => handleParameterChange(agentName, 'topP', Number.parseFloat(e.target.value))}
  542. style={{
  543. 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%)`,
  544. }}
  545. className={`flex-1 ${isDarkMode ? 'accent-blue-500' : 'accent-blue-400'} h-1 appearance-none rounded-full`}
  546. />
  547. <div className="flex items-center space-x-2">
  548. <span className={`w-12 text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
  549. {modelParameters[agentName].topP.toFixed(3)}
  550. </span>
  551. <input
  552. type="number"
  553. min="0"
  554. max="1"
  555. step="0.001"
  556. value={modelParameters[agentName].topP}
  557. onChange={e => {
  558. const value = Number.parseFloat(e.target.value);
  559. if (!Number.isNaN(value) && value >= 0 && value <= 1) {
  560. handleParameterChange(agentName, 'topP', value);
  561. }
  562. }}
  563. 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`}
  564. aria-label={`${agentName} top P number input`}
  565. />
  566. </div>
  567. </div>
  568. </div>
  569. </div>
  570. </div>
  571. );
  572. const getAgentDescription = (agentName: AgentNameEnum) => {
  573. switch (agentName) {
  574. case AgentNameEnum.Navigator:
  575. return 'Navigates websites and performs actions';
  576. case AgentNameEnum.Planner:
  577. return 'Develops and refines strategies to complete tasks';
  578. case AgentNameEnum.Validator:
  579. return 'Checks if tasks are completed successfully';
  580. default:
  581. return '';
  582. }
  583. };
  584. const getMaxCustomProviderNumber = () => {
  585. let maxNumber = 0;
  586. for (const providerId of Object.keys(providers)) {
  587. if (providerId.startsWith('custom_openai_')) {
  588. const match = providerId.match(/custom_openai_(\d+)/);
  589. if (match) {
  590. const number = Number.parseInt(match[1], 10);
  591. maxNumber = Math.max(maxNumber, number);
  592. }
  593. }
  594. }
  595. return maxNumber;
  596. };
  597. const addCustomProvider = () => {
  598. const nextNumber = getMaxCustomProviderNumber() + 1;
  599. const providerId = `custom_openai_${nextNumber}`;
  600. setProviders(prev => ({
  601. ...prev,
  602. [providerId]: {
  603. apiKey: '',
  604. name: `CustomProvider${nextNumber}`,
  605. type: ProviderTypeEnum.CustomOpenAI,
  606. baseUrl: '',
  607. modelNames: [],
  608. createdAt: Date.now(),
  609. },
  610. }));
  611. setModifiedProviders(prev => new Set(prev).add(providerId));
  612. // Set the newly added provider ref
  613. newlyAddedProviderRef.current = providerId;
  614. // Scroll to the newly added provider after render
  615. setTimeout(() => {
  616. const providerElement = document.getElementById(`provider-${providerId}`);
  617. if (providerElement) {
  618. providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  619. }
  620. }, 100);
  621. };
  622. const addBuiltInProvider = (provider: string) => {
  623. // Get the default provider configuration
  624. const config = getDefaultProviderConfig(provider);
  625. // Add the provider to the state
  626. setProviders(prev => ({
  627. ...prev,
  628. [provider]: config,
  629. }));
  630. // Mark as modified so it shows up in the UI
  631. setModifiedProviders(prev => new Set(prev).add(provider));
  632. // Set the newly added provider ref
  633. newlyAddedProviderRef.current = provider;
  634. // Scroll to the newly added provider after render
  635. setTimeout(() => {
  636. const providerElement = document.getElementById(`provider-${provider}`);
  637. if (providerElement) {
  638. providerElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  639. }
  640. }, 100);
  641. };
  642. // Sort providers to ensure newly added providers appear at the bottom
  643. const getSortedProviders = () => {
  644. // Filter providers to only include those from storage and newly added providers
  645. const filteredProviders = Object.entries(providers).filter(([providerId]) => {
  646. // Include if it's from storage
  647. if (providersFromStorage.has(providerId)) {
  648. return true;
  649. }
  650. // Include if it's a newly added provider (has been modified)
  651. if (modifiedProviders.has(providerId)) {
  652. return true;
  653. }
  654. // Exclude providers that aren't from storage and haven't been modified
  655. return false;
  656. });
  657. // Sort the filtered providers
  658. return filteredProviders.sort(([keyA, configA], [keyB, configB]) => {
  659. // First, separate newly added providers from stored providers
  660. const isNewA = !providersFromStorage.has(keyA) && modifiedProviders.has(keyA);
  661. const isNewB = !providersFromStorage.has(keyB) && modifiedProviders.has(keyB);
  662. // If one is new and one is stored, new ones go to the end
  663. if (isNewA && !isNewB) return 1;
  664. if (!isNewA && isNewB) return -1;
  665. // If both are new or both are stored, sort by createdAt
  666. if (configA.createdAt && configB.createdAt) {
  667. return configA.createdAt - configB.createdAt; // Sort in ascending order (oldest first)
  668. }
  669. // If only one has createdAt, put the one without createdAt at the end
  670. if (configA.createdAt) return -1;
  671. if (configB.createdAt) return 1;
  672. // If neither has createdAt, sort by type and then name
  673. const isCustomA = configA.type === ProviderTypeEnum.CustomOpenAI;
  674. const isCustomB = configB.type === ProviderTypeEnum.CustomOpenAI;
  675. if (isCustomA && !isCustomB) {
  676. return 1; // Custom providers come after non-custom
  677. }
  678. if (!isCustomA && isCustomB) {
  679. return -1; // Non-custom providers come before custom
  680. }
  681. // Sort alphabetically by name within each group
  682. return (configA.name || keyA).localeCompare(configB.name || keyB);
  683. });
  684. };
  685. const handleProviderSelection = (providerType: string) => {
  686. // Close the dropdown immediately
  687. setIsProviderSelectorOpen(false);
  688. // Handle custom provider
  689. if (providerType === ProviderTypeEnum.CustomOpenAI) {
  690. addCustomProvider();
  691. return;
  692. }
  693. // Handle built-in supported providers
  694. addBuiltInProvider(providerType);
  695. };
  696. const getProviderForModel = (modelName: string): string => {
  697. for (const [provider, config] of Object.entries(providers)) {
  698. const modelNames =
  699. config.modelNames || llmProviderModelNames[provider as keyof typeof llmProviderModelNames] || [];
  700. if (modelNames.includes(modelName)) {
  701. return provider;
  702. }
  703. }
  704. return '';
  705. };
  706. return (
  707. <section className="space-y-6">
  708. {/* LLM Providers Section */}
  709. <div
  710. className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
  711. <h2 className={`mb-4 text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
  712. LLM Providers
  713. </h2>
  714. <div className="space-y-6">
  715. {getSortedProviders().length === 0 ? (
  716. <div className="py-8 text-center text-gray-500">
  717. <p className="mb-4">No providers configured yet. Add a provider to get started.</p>
  718. </div>
  719. ) : (
  720. getSortedProviders().map(([providerId, providerConfig]) => (
  721. <div
  722. key={providerId}
  723. id={`provider-${providerId}`}
  724. 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'}` : ''}`}>
  725. <div className="flex items-center justify-between">
  726. <h3 className={`text-lg font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  727. {providerConfig.name || providerId}
  728. </h3>
  729. <div className="flex space-x-2">
  730. {/* Show Cancel button for newly added providers */}
  731. {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
  732. <Button variant="secondary" onClick={() => handleCancelProvider(providerId)}>
  733. Cancel
  734. </Button>
  735. )}
  736. <Button
  737. variant={getButtonProps(providerId).variant}
  738. disabled={getButtonProps(providerId).disabled}
  739. onClick={() =>
  740. providersFromStorage.has(providerId) && !modifiedProviders.has(providerId)
  741. ? handleDelete(providerId)
  742. : handleSave(providerId)
  743. }>
  744. {getButtonProps(providerId).children}
  745. </Button>
  746. </div>
  747. </div>
  748. {/* Show message for newly added providers */}
  749. {modifiedProviders.has(providerId) && !providersFromStorage.has(providerId) && (
  750. <div className={`mb-2 text-sm ${isDarkMode ? 'text-teal-300' : 'text-teal-700'}`}>
  751. <p>This provider is newly added. Enter your API key and click Save to configure it.</p>
  752. </div>
  753. )}
  754. <div className="space-y-3">
  755. {/* Name input (only for custom_openai) - moved to top for prominence */}
  756. {providerConfig.type === ProviderTypeEnum.CustomOpenAI && (
  757. <div className="flex flex-col">
  758. <div className="flex items-center">
  759. <label
  760. htmlFor={`${providerId}-name`}
  761. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  762. Name
  763. </label>
  764. <input
  765. id={`${providerId}-name`}
  766. type="text"
  767. placeholder="Provider name"
  768. value={providerConfig.name || ''}
  769. onChange={e => {
  770. console.log('Name input changed:', e.target.value);
  771. handleNameChange(providerId, e.target.value);
  772. }}
  773. className={`flex-1 rounded-md border p-2 text-sm ${
  774. nameErrors[providerId]
  775. ? isDarkMode
  776. ? 'border-red-700 bg-slate-700 text-gray-200 focus:border-red-600 focus:ring-2 focus:ring-red-900'
  777. : 'border-red-300 bg-gray-50 focus:border-red-400 focus:ring-2 focus:ring-red-200'
  778. : isDarkMode
  779. ? 'border-blue-700 bg-slate-700 text-gray-200 focus:border-blue-600 focus:ring-2 focus:ring-blue-900'
  780. : 'border-blue-300 bg-gray-50 focus:border-blue-400 focus:ring-2 focus:ring-blue-200'
  781. } outline-none`}
  782. />
  783. </div>
  784. {nameErrors[providerId] ? (
  785. <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-red-400' : 'text-red-500'}`}>
  786. {nameErrors[providerId]}
  787. </p>
  788. ) : (
  789. <p className={`ml-20 mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  790. Provider name (spaces are not allowed when saving)
  791. </p>
  792. )}
  793. </div>
  794. )}
  795. {/* API Key input with label */}
  796. <div className="flex items-center">
  797. <label
  798. htmlFor={`${providerId}-api-key`}
  799. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  800. Key{providerConfig.type !== ProviderTypeEnum.CustomOpenAI ? '*' : ''}
  801. </label>
  802. <input
  803. id={`${providerId}-api-key`}
  804. type="password"
  805. placeholder={
  806. providerConfig.type === ProviderTypeEnum.CustomOpenAI
  807. ? `${providerConfig.name || providerId} API key (optional)`
  808. : `${providerConfig.name || providerId} API key (required)`
  809. }
  810. value={providerConfig.apiKey || ''}
  811. onChange={e => handleApiKeyChange(providerId, e.target.value, providerConfig.baseUrl)}
  812. 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`}
  813. />
  814. </div>
  815. {/* Display API key for newly added providers */}
  816. {modifiedProviders.has(providerId) &&
  817. !providersFromStorage.has(providerId) &&
  818. providerConfig.apiKey && (
  819. <div className="ml-20 mt-1">
  820. <p
  821. className={`font-mono text-sm break-words ${isDarkMode ? 'text-emerald-400' : 'text-emerald-600'}`}>
  822. {providerConfig.apiKey}
  823. </p>
  824. </div>
  825. )}
  826. {/* Base URL input (for custom_openai and ollama) */}
  827. {(providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
  828. providerConfig.type === ProviderTypeEnum.Ollama) && (
  829. <div className="flex flex-col">
  830. <div className="flex items-center">
  831. <label
  832. htmlFor={`${providerId}-base-url`}
  833. className={`w-20 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  834. Base URL
  835. {providerConfig.type === ProviderTypeEnum.CustomOpenAI ||
  836. providerConfig.type === ProviderTypeEnum.Ollama
  837. ? '*'
  838. : ''}
  839. </label>
  840. <input
  841. id={`${providerId}-base-url`}
  842. type="text"
  843. placeholder={
  844. providerConfig.type === ProviderTypeEnum.CustomOpenAI
  845. ? 'Required for custom OpenAI-compatible API providers'
  846. : 'Ollama base URL'
  847. }
  848. value={providerConfig.baseUrl || ''}
  849. onChange={e => handleApiKeyChange(providerId, providerConfig.apiKey || '', e.target.value)}
  850. 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`}
  851. />
  852. </div>
  853. </div>
  854. )}
  855. {/* Models input field with tags */}
  856. <div className="flex items-start">
  857. <label
  858. htmlFor={`${providerId}-models`}
  859. className={`w-20 pt-2 text-sm font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
  860. Models
  861. </label>
  862. <div className="flex-1">
  863. <div
  864. 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`}>
  865. {/* Display existing models as tags */}
  866. {(() => {
  867. // Get models from provider config or default models
  868. const models =
  869. providerConfig.modelNames !== undefined
  870. ? providerConfig.modelNames
  871. : llmProviderModelNames[providerId as keyof typeof llmProviderModelNames] || [];
  872. return models.map(model => (
  873. <div
  874. key={model}
  875. 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`}>
  876. <span>{model}</span>
  877. <button
  878. type="button"
  879. onClick={() => removeModel(providerId, model)}
  880. className={`ml-1 font-bold ${isDarkMode ? 'text-blue-300 hover:text-blue-100' : 'text-blue-600 hover:text-blue-800'}`}
  881. aria-label={`Remove ${model}`}>
  882. ×
  883. </button>
  884. </div>
  885. ));
  886. })()}
  887. {/* Input for new models */}
  888. <input
  889. id={`${providerId}-models`}
  890. type="text"
  891. placeholder=""
  892. value={newModelInputs[providerId] || ''}
  893. onChange={e => handleModelsChange(providerId, e.target.value)}
  894. onKeyDown={e => handleKeyDown(e, providerId)}
  895. 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`}
  896. />
  897. </div>
  898. <p className={`mt-1 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
  899. Type and Press Enter or Space to add a model
  900. </p>
  901. </div>
  902. </div>
  903. {/* Ollama reminder at the bottom of the section */}
  904. {providerConfig.type === ProviderTypeEnum.Ollama && (
  905. <div
  906. className={`mt-4 rounded-md border ${isDarkMode ? 'border-slate-600 bg-slate-700' : 'border-blue-100 bg-blue-50'} p-3`}>
  907. <p className={`text-sm ${isDarkMode ? 'text-gray-200' : 'text-gray-700'}`}>
  908. <strong>Remember:</strong> Add{' '}
  909. <code
  910. className={`rounded italic ${isDarkMode ? 'bg-slate-600 px-1 py-0.5' : 'bg-blue-100 px-1 py-0.5'}`}>
  911. OLLAMA_ORIGINS=chrome-extension://*
  912. </code>{' '}
  913. environment variable for the Ollama server.
  914. <a
  915. href="https://github.com/ollama/ollama/issues/6489"
  916. target="_blank"
  917. rel="noopener noreferrer"
  918. className={`ml-1 ${isDarkMode ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-800'}`}>
  919. Learn more
  920. </a>
  921. </p>
  922. </div>
  923. )}
  924. </div>
  925. {/* Add divider except for the last item */}
  926. {Object.keys(providers).indexOf(providerId) < Object.keys(providers).length - 1 && (
  927. <div className={`mt-4 border-t ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`} />
  928. )}
  929. </div>
  930. ))
  931. )}
  932. {/* Add Provider button and dropdown */}
  933. <div className="provider-selector-container relative pt-4">
  934. <Button
  935. variant="secondary"
  936. onClick={() => setIsProviderSelectorOpen(prev => !prev)}
  937. className={`flex w-full items-center justify-center font-medium ${
  938. isDarkMode
  939. ? 'border-blue-700 bg-blue-600 text-white hover:bg-blue-500'
  940. : 'border-blue-200 bg-blue-100 text-blue-800 hover:bg-blue-200'
  941. }`}>
  942. <span className="mr-2 text-sm">+</span> <span className="text-sm">Add New Provider</span>
  943. </Button>
  944. {isProviderSelectorOpen && (
  945. <div
  946. className={`absolute z-10 mt-2 w-full overflow-hidden rounded-md border ${
  947. isDarkMode
  948. ? 'border-blue-600 bg-slate-700 shadow-lg shadow-slate-900/50'
  949. : 'border-blue-200 bg-white shadow-xl shadow-blue-100/50'
  950. }`}>
  951. <div className="py-1">
  952. {/* Map through provider types to create buttons */}
  953. {Object.values(ProviderTypeEnum)
  954. // Filter out CustomOpenAI and already added providers
  955. .filter(
  956. type =>
  957. type !== ProviderTypeEnum.CustomOpenAI &&
  958. !providersFromStorage.has(type) &&
  959. !modifiedProviders.has(type),
  960. )
  961. .map(type => (
  962. <button
  963. key={type}
  964. type="button"
  965. className={`flex w-full items-center px-4 py-3 text-left text-sm ${
  966. isDarkMode
  967. ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
  968. : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
  969. } transition-colors duration-150`}
  970. onClick={() => handleProviderSelection(type)}>
  971. <span className="font-medium">{getDefaultDisplayNameFromProviderId(type)}</span>
  972. </button>
  973. ))}
  974. {/* Custom provider button (always shown) */}
  975. <button
  976. type="button"
  977. className={`flex w-full items-center px-4 py-3 text-left text-sm ${
  978. isDarkMode
  979. ? 'text-blue-200 hover:bg-blue-600/30 hover:text-white'
  980. : 'text-blue-700 hover:bg-blue-100 hover:text-blue-800'
  981. } transition-colors duration-150`}
  982. onClick={() => handleProviderSelection(ProviderTypeEnum.CustomOpenAI)}>
  983. <span className="font-medium">OpenAI-compatible API Provider</span>
  984. </button>
  985. </div>
  986. </div>
  987. )}
  988. </div>
  989. </div>
  990. </div>
  991. {/* Updated Agent Models Section */}
  992. <div
  993. className={`rounded-lg border ${isDarkMode ? 'border-slate-700 bg-slate-800' : 'border-blue-100 bg-gray-50'} p-6 text-left shadow-sm`}>
  994. <h2 className={`mb-4 text-left text-xl font-semibold ${isDarkMode ? 'text-gray-200' : 'text-gray-800'}`}>
  995. Model Selection
  996. </h2>
  997. <div className="space-y-4">
  998. {[AgentNameEnum.Planner, AgentNameEnum.Navigator, AgentNameEnum.Validator].map(agentName => (
  999. <div key={agentName}>{renderModelSelect(agentName)}</div>
  1000. ))}
  1001. </div>
  1002. </div>
  1003. </section>
  1004. );
  1005. };