AddServerModal.svelte 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { getContext, onMount } from 'svelte';
  4. const i18n = getContext('i18n');
  5. import { settings } from '$lib/stores';
  6. import Modal from '$lib/components/common/Modal.svelte';
  7. import Plus from '$lib/components/icons/Plus.svelte';
  8. import Minus from '$lib/components/icons/Minus.svelte';
  9. import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
  10. import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
  11. import Tooltip from '$lib/components/common/Tooltip.svelte';
  12. import Switch from '$lib/components/common/Switch.svelte';
  13. import Tags from './common/Tags.svelte';
  14. import { getToolServerData } from '$lib/apis';
  15. import { verifyToolServerConnection } from '$lib/apis/configs';
  16. import AccessControl from './workspace/common/AccessControl.svelte';
  17. import Spinner from '$lib/components/common/Spinner.svelte';
  18. import XMark from '$lib/components/icons/XMark.svelte';
  19. export let onSubmit: Function = () => {};
  20. export let onDelete: Function = () => {};
  21. export let show = false;
  22. export let edit = false;
  23. export let direct = false;
  24. export let connection = null;
  25. let url = '';
  26. let path = 'openapi.json';
  27. let type = 'openapi'; // 'openapi', 'mcp'
  28. let auth_type = 'bearer';
  29. let key = '';
  30. let accessControl = {};
  31. let id = '';
  32. let name = '';
  33. let description = '';
  34. let enable = true;
  35. let loading = false;
  36. const verifyHandler = async () => {
  37. if (url === '') {
  38. toast.error($i18n.t('Please enter a valid URL'));
  39. return;
  40. }
  41. if (path === '') {
  42. toast.error($i18n.t('Please enter a valid path'));
  43. return;
  44. }
  45. if (direct) {
  46. const res = await getToolServerData(
  47. auth_type === 'bearer' ? key : localStorage.token,
  48. path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
  49. ).catch((err) => {
  50. toast.error($i18n.t('Connection failed'));
  51. });
  52. if (res) {
  53. toast.success($i18n.t('Connection successful'));
  54. console.debug('Connection successful', res);
  55. }
  56. } else {
  57. const res = await verifyToolServerConnection(localStorage.token, {
  58. url,
  59. path,
  60. type,
  61. auth_type,
  62. key,
  63. config: {
  64. enable: enable,
  65. access_control: accessControl
  66. },
  67. info: {
  68. id,
  69. name,
  70. description
  71. }
  72. }).catch((err) => {
  73. toast.error($i18n.t('Connection failed'));
  74. });
  75. if (res) {
  76. toast.success($i18n.t('Connection successful'));
  77. console.debug('Connection successful', res);
  78. }
  79. }
  80. };
  81. const submitHandler = async () => {
  82. loading = true;
  83. // remove trailing slash from url
  84. url = url.replace(/\/$/, '');
  85. const connection = {
  86. url,
  87. path,
  88. type,
  89. auth_type,
  90. key,
  91. config: {
  92. enable: enable,
  93. access_control: accessControl
  94. },
  95. info: {
  96. id: id,
  97. name: name,
  98. description: description
  99. }
  100. };
  101. await onSubmit(connection);
  102. loading = false;
  103. show = false;
  104. // reset form
  105. url = '';
  106. path = 'openapi.json';
  107. type = 'openapi';
  108. key = '';
  109. auth_type = 'bearer';
  110. id = '';
  111. name = '';
  112. description = '';
  113. enable = true;
  114. accessControl = null;
  115. };
  116. const init = () => {
  117. if (connection) {
  118. url = connection.url;
  119. path = connection?.path ?? 'openapi.json';
  120. type = connection?.type ?? 'openapi';
  121. auth_type = connection?.auth_type ?? 'bearer';
  122. key = connection?.key ?? '';
  123. id = connection.info?.id ?? '';
  124. name = connection.info?.name ?? '';
  125. description = connection.info?.description ?? '';
  126. enable = connection.config?.enable ?? true;
  127. accessControl = connection.config?.access_control ?? null;
  128. }
  129. };
  130. $: if (show) {
  131. init();
  132. }
  133. onMount(() => {
  134. init();
  135. });
  136. </script>
  137. <Modal size="sm" bind:show>
  138. <div>
  139. <div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
  140. <h1 class=" text-lg font-medium self-center font-primary">
  141. {#if edit}
  142. {$i18n.t('Edit Connection')}
  143. {:else}
  144. {$i18n.t('Add Connection')}
  145. {/if}
  146. </h1>
  147. <button
  148. class="self-center"
  149. aria-label={$i18n.t('Close Configure Connection Modal')}
  150. on:click={() => {
  151. show = false;
  152. }}
  153. >
  154. <XMark className={'size-5'} />
  155. </button>
  156. </div>
  157. <div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
  158. <div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
  159. <form
  160. class="flex flex-col w-full"
  161. on:submit={(e) => {
  162. e.preventDefault();
  163. submitHandler();
  164. }}
  165. >
  166. <div class="px-1">
  167. {#if !direct}
  168. <div class="flex gap-2 mb-1.5">
  169. <div class="flex w-full justify-between items-center">
  170. <div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
  171. <div class="">
  172. <button
  173. on:click={() => {
  174. type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
  175. }}
  176. type="button"
  177. class=" text-xs text-gray-700 dark:text-gray-300"
  178. >
  179. {#if ['', 'openapi'].includes(type)}
  180. {$i18n.t('OpenAPI')}
  181. {:else if type === 'mcp'}
  182. {$i18n.t('MCP')}
  183. {/if}
  184. </button>
  185. </div>
  186. </div>
  187. </div>
  188. {/if}
  189. <div class="flex gap-2">
  190. <div class="flex flex-col w-full">
  191. <div class="flex justify-between mb-0.5">
  192. <label
  193. for="api-base-url"
  194. class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  195. >{$i18n.t('URL')}</label
  196. >
  197. </div>
  198. <div class="flex flex-1 items-center">
  199. <input
  200. id="api-base-url"
  201. class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  202. type="text"
  203. bind:value={url}
  204. placeholder={$i18n.t('API Base URL')}
  205. autocomplete="off"
  206. required
  207. />
  208. <Tooltip
  209. content={$i18n.t('Verify Connection')}
  210. className="shrink-0 flex items-center mr-1"
  211. >
  212. <button
  213. class="self-center p-1 bg-transparent hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
  214. on:click={() => {
  215. verifyHandler();
  216. }}
  217. aria-label={$i18n.t('Verify Connection')}
  218. type="button"
  219. >
  220. <svg
  221. xmlns="http://www.w3.org/2000/svg"
  222. viewBox="0 0 20 20"
  223. fill="currentColor"
  224. class="w-4 h-4"
  225. aria-hidden="true"
  226. >
  227. <path
  228. fill-rule="evenodd"
  229. d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
  230. clip-rule="evenodd"
  231. />
  232. </svg>
  233. </button>
  234. </Tooltip>
  235. <Tooltip content={enable ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
  236. <Switch bind:state={enable} />
  237. </Tooltip>
  238. </div>
  239. {#if ['', 'openapi'].includes(type)}
  240. <div class="flex-1 flex items-center">
  241. <label for="url-or-path" class="sr-only"
  242. >{$i18n.t('openapi.json URL or Path')}</label
  243. >
  244. <input
  245. class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  246. type="text"
  247. id="url-or-path"
  248. bind:value={path}
  249. placeholder={$i18n.t('openapi.json URL or Path')}
  250. autocomplete="off"
  251. required
  252. />
  253. </div>
  254. {/if}
  255. </div>
  256. </div>
  257. {#if ['', 'openapi'].includes(type)}
  258. <div
  259. class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  260. >
  261. {$i18n.t(`WebUI will make requests to "{{url}}"`, {
  262. url: path.includes('://')
  263. ? path
  264. : `${url}${path.startsWith('/') ? '' : '/'}${path}`
  265. })}
  266. </div>
  267. {/if}
  268. <div class="flex gap-2 mt-2">
  269. <div class="flex flex-col w-full">
  270. <label
  271. for="select-bearer-or-session"
  272. class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  273. >{$i18n.t('Auth')}</label
  274. >
  275. <div class="flex gap-2">
  276. <div class="flex-shrink-0 self-start">
  277. <select
  278. id="select-bearer-or-session"
  279. class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  280. bind:value={auth_type}
  281. >
  282. <option value="none">{$i18n.t('None')}</option>
  283. <option value="bearer">{$i18n.t('Bearer')}</option>
  284. <option value="session">{$i18n.t('Session')}</option>
  285. {#if !direct}
  286. <option value="system_oauth">{$i18n.t('OAuth')}</option>
  287. {/if}
  288. </select>
  289. </div>
  290. <div class="flex flex-1 items-center">
  291. {#if auth_type === 'bearer'}
  292. <SensitiveInput
  293. bind:value={key}
  294. placeholder={$i18n.t('API Key')}
  295. required={false}
  296. />
  297. {:else if auth_type === 'none'}
  298. <div
  299. class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  300. >
  301. {$i18n.t('No authentication')}
  302. </div>
  303. {:else if auth_type === 'session'}
  304. <div
  305. class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  306. >
  307. {$i18n.t('Forwards system user session credentials to authenticate')}
  308. </div>
  309. {:else if auth_type === 'system_oauth'}
  310. <div
  311. class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  312. >
  313. {$i18n.t('Forwards system user OAuth access token to authenticate')}
  314. </div>
  315. {/if}
  316. </div>
  317. </div>
  318. </div>
  319. </div>
  320. {#if !direct}
  321. <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
  322. <div class="flex gap-2">
  323. <div class="flex flex-col w-full">
  324. <label
  325. for="enter-id"
  326. class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  327. >{$i18n.t('ID')}
  328. <span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
  329. >{$i18n.t('Optional')}</span
  330. >
  331. </label>
  332. <div class="flex-1">
  333. <input
  334. id="enter-id"
  335. class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  336. type="text"
  337. bind:value={id}
  338. placeholder={$i18n.t('Enter ID')}
  339. autocomplete="off"
  340. />
  341. </div>
  342. </div>
  343. </div>
  344. <div class="flex gap-2 mt-2">
  345. <div class="flex flex-col w-full">
  346. <label
  347. for="enter-name"
  348. class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
  349. >{$i18n.t('Name')}
  350. </label>
  351. <div class="flex-1">
  352. <input
  353. id="enter-name"
  354. class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  355. type="text"
  356. bind:value={name}
  357. placeholder={$i18n.t('Enter name')}
  358. autocomplete="off"
  359. required
  360. />
  361. </div>
  362. </div>
  363. </div>
  364. <div class="flex flex-col w-full mt-2">
  365. <label
  366. for="description"
  367. class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
  368. >{$i18n.t('Description')}</label
  369. >
  370. <div class="flex-1">
  371. <input
  372. id="description"
  373. class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
  374. type="text"
  375. bind:value={description}
  376. placeholder={$i18n.t('Enter description')}
  377. autocomplete="off"
  378. />
  379. </div>
  380. </div>
  381. <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
  382. <div class="my-2 -mx-2">
  383. <div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
  384. <AccessControl bind:accessControl />
  385. </div>
  386. </div>
  387. {/if}
  388. </div>
  389. <div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
  390. {#if edit}
  391. <button
  392. class="px-3.5 py-1.5 text-sm font-medium dark:bg-black dark:hover:bg-gray-900 dark:text-white bg-white text-black hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
  393. type="button"
  394. on:click={() => {
  395. onDelete();
  396. show = false;
  397. }}
  398. >
  399. {$i18n.t('Delete')}
  400. </button>
  401. {/if}
  402. <button
  403. class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
  404. ? ' cursor-not-allowed'
  405. : ''}"
  406. type="submit"
  407. disabled={loading}
  408. >
  409. {$i18n.t('Save')}
  410. {#if loading}
  411. <div class="ml-2 self-center">
  412. <Spinner />
  413. </div>
  414. {/if}
  415. </button>
  416. </div>
  417. </form>
  418. </div>
  419. </div>
  420. </div>
  421. </Modal>