plugin.tsx 12 KB

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