plugin.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import { useDebouncedCallback } from "use-debounce";
  2. import OpenAPIClientAxios from "openapi-client-axios";
  3. import yaml from "js-yaml";
  4. import { PLUGINS_REPO_URL } from "../constant";
  5. import { IconButton } from "./button";
  6. import { ErrorBoundary } from "./error";
  7. import styles from "./mask.module.scss";
  8. import pluginStyles from "./plugin.module.scss";
  9. import EditIcon from "../icons/edit.svg";
  10. import AddIcon from "../icons/add.svg";
  11. import CloseIcon from "../icons/close.svg";
  12. import DeleteIcon from "../icons/delete.svg";
  13. import ConfirmIcon from "../icons/confirm.svg";
  14. import ReloadIcon from "../icons/reload.svg";
  15. import GithubIcon from "../icons/github.svg";
  16. import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
  17. import {
  18. PasswordInput,
  19. List,
  20. ListItem,
  21. Modal,
  22. showConfirm,
  23. showToast,
  24. } from "./ui-lib";
  25. import Locale from "../locales";
  26. import { useNavigate } from "react-router-dom";
  27. import { useState } from "react";
  28. import clsx from "clsx";
  29. export function PluginPage() {
  30. const navigate = useNavigate();
  31. const pluginStore = usePluginStore();
  32. const allPlugins = pluginStore.getAll();
  33. const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
  34. const [searchText, setSearchText] = useState("");
  35. const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
  36. // refactored already, now it accurate
  37. const onSearch = (text: string) => {
  38. setSearchText(text);
  39. if (text.length > 0) {
  40. const result = allPlugins.filter(
  41. (m) => m?.title.toLowerCase().includes(text.toLowerCase()),
  42. );
  43. setSearchPlugins(result);
  44. } else {
  45. setSearchPlugins(allPlugins);
  46. }
  47. };
  48. const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
  49. const editingPlugin = pluginStore.get(editingPluginId);
  50. const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
  51. const closePluginModal = () => setEditingPluginId(undefined);
  52. const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
  53. const content = e.target.innerText;
  54. try {
  55. const api = new OpenAPIClientAxios({
  56. definition: yaml.load(content) as any,
  57. });
  58. api
  59. .init()
  60. .then(() => {
  61. if (content != editingPlugin.content) {
  62. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  63. plugin.content = content;
  64. const tool = FunctionToolService.add(plugin, true);
  65. plugin.title = tool.api.definition.info.title;
  66. plugin.version = tool.api.definition.info.version;
  67. });
  68. }
  69. })
  70. .catch((e) => {
  71. console.error(e);
  72. showToast(Locale.Plugin.EditModal.Error);
  73. });
  74. } catch (e) {
  75. console.error(e);
  76. showToast(Locale.Plugin.EditModal.Error);
  77. }
  78. }, 100).bind(null, editingPlugin);
  79. const [loadUrl, setLoadUrl] = useState<string>("");
  80. const loadFromUrl = (loadUrl: string) =>
  81. fetch(loadUrl)
  82. .catch((e) => {
  83. const p = new URL(loadUrl);
  84. return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
  85. headers: {
  86. "X-Base-URL": p.origin,
  87. },
  88. });
  89. })
  90. .then((res) => res.text())
  91. .then((content) => {
  92. try {
  93. return JSON.stringify(JSON.parse(content), null, " ");
  94. } catch (e) {
  95. return content;
  96. }
  97. })
  98. .then((content) => {
  99. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  100. plugin.content = content;
  101. const tool = FunctionToolService.add(plugin, true);
  102. plugin.title = tool.api.definition.info.title;
  103. plugin.version = tool.api.definition.info.version;
  104. });
  105. })
  106. .catch((e) => {
  107. showToast(Locale.Plugin.EditModal.Error);
  108. });
  109. return (
  110. <ErrorBoundary>
  111. <div className={styles["mask-page"]}>
  112. <div className="window-header">
  113. <div className="window-header-title">
  114. <div className="window-header-main-title">
  115. {Locale.Plugin.Page.Title}
  116. </div>
  117. <div className="window-header-submai-title">
  118. {Locale.Plugin.Page.SubTitle(plugins.length)}
  119. </div>
  120. </div>
  121. <div className="window-actions">
  122. <div className="window-action-button">
  123. <a
  124. href={PLUGINS_REPO_URL}
  125. target="_blank"
  126. rel="noopener noreferrer"
  127. >
  128. <IconButton icon={<GithubIcon />} bordered />
  129. </a>
  130. </div>
  131. <div className="window-action-button">
  132. <IconButton
  133. icon={<CloseIcon />}
  134. bordered
  135. onClick={() => navigate(-1)}
  136. />
  137. </div>
  138. </div>
  139. </div>
  140. <div className={styles["mask-page-body"]}>
  141. <div className={styles["mask-filter"]}>
  142. <input
  143. type="text"
  144. className={styles["search-bar"]}
  145. placeholder={Locale.Plugin.Page.Search}
  146. autoFocus
  147. onInput={(e) => onSearch(e.currentTarget.value)}
  148. />
  149. <IconButton
  150. className={styles["mask-create"]}
  151. icon={<AddIcon />}
  152. text={Locale.Plugin.Page.Create}
  153. bordered
  154. onClick={() => {
  155. const createdPlugin = pluginStore.create();
  156. setEditingPluginId(createdPlugin.id);
  157. }}
  158. />
  159. </div>
  160. <div>
  161. {plugins.length == 0 && (
  162. <div
  163. style={{
  164. display: "flex",
  165. margin: "60px auto",
  166. alignItems: "center",
  167. justifyContent: "center",
  168. }}
  169. >
  170. {Locale.Plugin.Page.Find}
  171. <a
  172. href={PLUGINS_REPO_URL}
  173. target="_blank"
  174. rel="noopener noreferrer"
  175. style={{ marginLeft: 16 }}
  176. >
  177. <IconButton icon={<GithubIcon />} bordered />
  178. </a>
  179. </div>
  180. )}
  181. {plugins.map((m) => (
  182. <div className={styles["mask-item"]} key={m.id}>
  183. <div className={styles["mask-header"]}>
  184. <div className={styles["mask-icon"]}></div>
  185. <div className={styles["mask-title"]}>
  186. <div className={styles["mask-name"]}>
  187. {m.title}@<small>{m.version}</small>
  188. </div>
  189. <div className={clsx(styles["mask-info"], "one-line")}>
  190. {Locale.Plugin.Item.Info(
  191. FunctionToolService.add(m).length,
  192. )}
  193. </div>
  194. </div>
  195. </div>
  196. <div className={styles["mask-actions"]}>
  197. <IconButton
  198. icon={<EditIcon />}
  199. text={Locale.Plugin.Item.Edit}
  200. onClick={() => setEditingPluginId(m.id)}
  201. />
  202. {!m.builtin && (
  203. <IconButton
  204. icon={<DeleteIcon />}
  205. text={Locale.Plugin.Item.Delete}
  206. onClick={async () => {
  207. if (
  208. await showConfirm(Locale.Plugin.Item.DeleteConfirm)
  209. ) {
  210. pluginStore.delete(m.id);
  211. }
  212. }}
  213. />
  214. )}
  215. </div>
  216. </div>
  217. ))}
  218. </div>
  219. </div>
  220. </div>
  221. {editingPlugin && (
  222. <div className="modal-mask">
  223. <Modal
  224. title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
  225. onClose={closePluginModal}
  226. actions={[
  227. <IconButton
  228. icon={<ConfirmIcon />}
  229. text={Locale.UI.Confirm}
  230. key="export"
  231. bordered
  232. onClick={() => setEditingPluginId("")}
  233. />,
  234. ]}
  235. >
  236. <List>
  237. <ListItem title={Locale.Plugin.EditModal.Auth}>
  238. <select
  239. value={editingPlugin?.authType}
  240. onChange={(e) => {
  241. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  242. plugin.authType = e.target.value;
  243. });
  244. }}
  245. >
  246. <option value="">{Locale.Plugin.Auth.None}</option>
  247. <option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
  248. <option value="basic">{Locale.Plugin.Auth.Basic}</option>
  249. <option value="custom">{Locale.Plugin.Auth.Custom}</option>
  250. </select>
  251. </ListItem>
  252. {["bearer", "basic", "custom"].includes(
  253. editingPlugin.authType as string,
  254. ) && (
  255. <ListItem title={Locale.Plugin.Auth.Location}>
  256. <select
  257. value={editingPlugin?.authLocation}
  258. onChange={(e) => {
  259. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  260. plugin.authLocation = e.target.value;
  261. });
  262. }}
  263. >
  264. <option value="header">
  265. {Locale.Plugin.Auth.LocationHeader}
  266. </option>
  267. <option value="query">
  268. {Locale.Plugin.Auth.LocationQuery}
  269. </option>
  270. <option value="body">
  271. {Locale.Plugin.Auth.LocationBody}
  272. </option>
  273. </select>
  274. </ListItem>
  275. )}
  276. {editingPlugin.authType == "custom" && (
  277. <ListItem title={Locale.Plugin.Auth.CustomHeader}>
  278. <input
  279. type="text"
  280. value={editingPlugin?.authHeader}
  281. onChange={(e) => {
  282. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  283. plugin.authHeader = e.target.value;
  284. });
  285. }}
  286. ></input>
  287. </ListItem>
  288. )}
  289. {["bearer", "basic", "custom"].includes(
  290. editingPlugin.authType as string,
  291. ) && (
  292. <ListItem title={Locale.Plugin.Auth.Token}>
  293. <PasswordInput
  294. type="text"
  295. value={editingPlugin?.authToken}
  296. onChange={(e) => {
  297. pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
  298. plugin.authToken = e.currentTarget.value;
  299. });
  300. }}
  301. ></PasswordInput>
  302. </ListItem>
  303. )}
  304. </List>
  305. <List>
  306. <ListItem title={Locale.Plugin.EditModal.Content}>
  307. <div className={pluginStyles["plugin-schema"]}>
  308. <input
  309. type="text"
  310. style={{ minWidth: 200 }}
  311. onInput={(e) => setLoadUrl(e.currentTarget.value)}
  312. ></input>
  313. <IconButton
  314. icon={<ReloadIcon />}
  315. text={Locale.Plugin.EditModal.Load}
  316. bordered
  317. onClick={() => loadFromUrl(loadUrl)}
  318. />
  319. </div>
  320. </ListItem>
  321. <ListItem
  322. subTitle={
  323. <div
  324. className={clsx(
  325. "markdown-body",
  326. pluginStyles["plugin-content"],
  327. )}
  328. dir="auto"
  329. >
  330. <pre>
  331. <code
  332. contentEditable={true}
  333. dangerouslySetInnerHTML={{
  334. __html: editingPlugin.content,
  335. }}
  336. onBlur={onChangePlugin}
  337. ></code>
  338. </pre>
  339. </div>
  340. }
  341. ></ListItem>
  342. {editingPluginTool?.tools.map((tool, index) => (
  343. <ListItem
  344. key={index}
  345. title={tool?.function?.name}
  346. subTitle={tool?.function?.description}
  347. />
  348. ))}
  349. </List>
  350. </Modal>
  351. </div>
  352. )}
  353. </ErrorBoundary>
  354. );
  355. }