CodeBlock.svelte 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. <script lang="ts">
  2. import mermaid from 'mermaid';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { getContext, onMount, tick, onDestroy } from 'svelte';
  5. import { copyToClipboard } from '$lib/utils';
  6. import 'highlight.js/styles/github-dark.min.css';
  7. import PyodideWorker from '$lib/workers/pyodide.worker?worker';
  8. import CodeEditor from '$lib/components/common/CodeEditor.svelte';
  9. import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
  10. import { config } from '$lib/stores';
  11. import { executeCode } from '$lib/apis/utils';
  12. import { toast } from 'svelte-sonner';
  13. import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
  14. import ChevronUpDown from '$lib/components/icons/ChevronUpDown.svelte';
  15. import CommandLine from '$lib/components/icons/CommandLine.svelte';
  16. import Cube from '$lib/components/icons/Cube.svelte';
  17. const i18n = getContext('i18n');
  18. export let id = '';
  19. export let onSave = (e) => {};
  20. export let onUpdate = (e) => {};
  21. export let onPreview = (e) => {};
  22. export let save = false;
  23. export let run = true;
  24. export let preview = false;
  25. export let collapsed = false;
  26. export let token;
  27. export let lang = '';
  28. export let code = '';
  29. export let attributes = {};
  30. export let className = 'my-2';
  31. export let editorClassName = '';
  32. export let stickyButtonsClassName = 'top-0';
  33. let pyodideWorker = null;
  34. let _code = '';
  35. $: if (code) {
  36. updateCode();
  37. }
  38. const updateCode = () => {
  39. _code = code;
  40. };
  41. let _token = null;
  42. let mermaidHtml = null;
  43. let highlightedCode = null;
  44. let executing = false;
  45. let stdout = null;
  46. let stderr = null;
  47. let result = null;
  48. let files = null;
  49. let copied = false;
  50. let saved = false;
  51. const collapseCodeBlock = () => {
  52. collapsed = !collapsed;
  53. };
  54. const saveCode = () => {
  55. saved = true;
  56. code = _code;
  57. onSave(code);
  58. setTimeout(() => {
  59. saved = false;
  60. }, 1000);
  61. };
  62. const copyCode = async () => {
  63. copied = true;
  64. await copyToClipboard(code);
  65. setTimeout(() => {
  66. copied = false;
  67. }, 1000);
  68. };
  69. const previewCode = () => {
  70. onPreview(code);
  71. };
  72. const checkPythonCode = (str) => {
  73. // Check if the string contains typical Python syntax characters
  74. const pythonSyntax = [
  75. 'def ',
  76. 'else:',
  77. 'elif ',
  78. 'try:',
  79. 'except:',
  80. 'finally:',
  81. 'yield ',
  82. 'lambda ',
  83. 'assert ',
  84. 'nonlocal ',
  85. 'del ',
  86. 'True',
  87. 'False',
  88. 'None',
  89. ' and ',
  90. ' or ',
  91. ' not ',
  92. ' in ',
  93. ' is ',
  94. ' with '
  95. ];
  96. for (let syntax of pythonSyntax) {
  97. if (str.includes(syntax)) {
  98. return true;
  99. }
  100. }
  101. // If none of the above conditions met, it's probably not Python code
  102. return false;
  103. };
  104. const executePython = async (code) => {
  105. result = null;
  106. stdout = null;
  107. stderr = null;
  108. executing = true;
  109. if ($config?.code?.engine === 'jupyter') {
  110. const output = await executeCode(localStorage.token, code).catch((error) => {
  111. toast.error(`${error}`);
  112. return null;
  113. });
  114. if (output) {
  115. if (output['stdout']) {
  116. stdout = output['stdout'];
  117. const stdoutLines = stdout.split('\n');
  118. for (const [idx, line] of stdoutLines.entries()) {
  119. if (line.startsWith('data:image/png;base64')) {
  120. if (files) {
  121. files.push({
  122. type: 'image/png',
  123. data: line
  124. });
  125. } else {
  126. files = [
  127. {
  128. type: 'image/png',
  129. data: line
  130. }
  131. ];
  132. }
  133. if (stdout.startsWith(`${line}\n`)) {
  134. stdout = stdout.replace(`${line}\n`, ``);
  135. } else if (stdout.startsWith(`${line}`)) {
  136. stdout = stdout.replace(`${line}`, ``);
  137. }
  138. }
  139. }
  140. }
  141. if (output['result']) {
  142. result = output['result'];
  143. const resultLines = result.split('\n');
  144. for (const [idx, line] of resultLines.entries()) {
  145. if (line.startsWith('data:image/png;base64')) {
  146. if (files) {
  147. files.push({
  148. type: 'image/png',
  149. data: line
  150. });
  151. } else {
  152. files = [
  153. {
  154. type: 'image/png',
  155. data: line
  156. }
  157. ];
  158. }
  159. if (result.startsWith(`${line}\n`)) {
  160. result = result.replace(`${line}\n`, ``);
  161. } else if (result.startsWith(`${line}`)) {
  162. result = result.replace(`${line}`, ``);
  163. }
  164. }
  165. }
  166. }
  167. output['stderr'] && (stderr = output['stderr']);
  168. }
  169. executing = false;
  170. } else {
  171. executePythonAsWorker(code);
  172. }
  173. };
  174. const executePythonAsWorker = async (code) => {
  175. let packages = [
  176. code.includes('requests') ? 'requests' : null,
  177. code.includes('bs4') ? 'beautifulsoup4' : null,
  178. code.includes('numpy') ? 'numpy' : null,
  179. code.includes('pandas') ? 'pandas' : null,
  180. code.includes('sklearn') ? 'scikit-learn' : null,
  181. code.includes('scipy') ? 'scipy' : null,
  182. code.includes('re') ? 'regex' : null,
  183. code.includes('seaborn') ? 'seaborn' : null,
  184. code.includes('sympy') ? 'sympy' : null,
  185. code.includes('tiktoken') ? 'tiktoken' : null,
  186. code.includes('matplotlib') ? 'matplotlib' : null,
  187. code.includes('pytz') ? 'pytz' : null,
  188. code.includes('openai') ? 'openai' : null
  189. ].filter(Boolean);
  190. console.log(packages);
  191. pyodideWorker = new PyodideWorker();
  192. pyodideWorker.postMessage({
  193. id: id,
  194. code: code,
  195. packages: packages
  196. });
  197. setTimeout(() => {
  198. if (executing) {
  199. executing = false;
  200. stderr = 'Execution Time Limit Exceeded';
  201. pyodideWorker.terminate();
  202. }
  203. }, 60000);
  204. pyodideWorker.onmessage = (event) => {
  205. console.log('pyodideWorker.onmessage', event);
  206. const { id, ...data } = event.data;
  207. console.log(id, data);
  208. if (data['stdout']) {
  209. stdout = data['stdout'];
  210. const stdoutLines = stdout.split('\n');
  211. for (const [idx, line] of stdoutLines.entries()) {
  212. if (line.startsWith('data:image/png;base64')) {
  213. if (files) {
  214. files.push({
  215. type: 'image/png',
  216. data: line
  217. });
  218. } else {
  219. files = [
  220. {
  221. type: 'image/png',
  222. data: line
  223. }
  224. ];
  225. }
  226. if (stdout.startsWith(`${line}\n`)) {
  227. stdout = stdout.replace(`${line}\n`, ``);
  228. } else if (stdout.startsWith(`${line}`)) {
  229. stdout = stdout.replace(`${line}`, ``);
  230. }
  231. }
  232. }
  233. }
  234. if (data['result']) {
  235. result = data['result'];
  236. const resultLines = result.split('\n');
  237. for (const [idx, line] of resultLines.entries()) {
  238. if (line.startsWith('data:image/png;base64')) {
  239. if (files) {
  240. files.push({
  241. type: 'image/png',
  242. data: line
  243. });
  244. } else {
  245. files = [
  246. {
  247. type: 'image/png',
  248. data: line
  249. }
  250. ];
  251. }
  252. if (result.startsWith(`${line}\n`)) {
  253. result = result.replace(`${line}\n`, ``);
  254. } else if (result.startsWith(`${line}`)) {
  255. result = result.replace(`${line}`, ``);
  256. }
  257. }
  258. }
  259. }
  260. data['stderr'] && (stderr = data['stderr']);
  261. data['result'] && (result = data['result']);
  262. executing = false;
  263. };
  264. pyodideWorker.onerror = (event) => {
  265. console.log('pyodideWorker.onerror', event);
  266. executing = false;
  267. };
  268. };
  269. let debounceTimeout;
  270. const drawMermaidDiagram = async () => {
  271. try {
  272. if (await mermaid.parse(code)) {
  273. const { svg } = await mermaid.render(`mermaid-${uuidv4()}`, code);
  274. mermaidHtml = svg;
  275. }
  276. } catch (error) {
  277. console.log('Error:', error);
  278. }
  279. };
  280. const render = async () => {
  281. if (lang === 'mermaid' && (token?.raw ?? '').slice(-4).includes('```')) {
  282. (async () => {
  283. await drawMermaidDiagram();
  284. })();
  285. }
  286. onUpdate(token);
  287. };
  288. $: if (token) {
  289. if (JSON.stringify(token) !== JSON.stringify(_token)) {
  290. _token = token;
  291. }
  292. }
  293. $: if (_token) {
  294. render();
  295. }
  296. $: if (attributes) {
  297. onAttributesUpdate();
  298. }
  299. const onAttributesUpdate = () => {
  300. if (attributes?.output) {
  301. // Create a helper function to unescape HTML entities
  302. const unescapeHtml = (html) => {
  303. const textArea = document.createElement('textarea');
  304. textArea.innerHTML = html;
  305. return textArea.value;
  306. };
  307. try {
  308. // Unescape the HTML-encoded string
  309. const unescapedOutput = unescapeHtml(attributes.output);
  310. // Parse the unescaped string into JSON
  311. const output = JSON.parse(unescapedOutput);
  312. // Assign the parsed values to variables
  313. stdout = output.stdout;
  314. stderr = output.stderr;
  315. result = output.result;
  316. } catch (error) {
  317. console.error('Error:', error);
  318. }
  319. }
  320. };
  321. onMount(async () => {
  322. if (token) {
  323. onUpdate(token);
  324. }
  325. if (document.documentElement.classList.contains('dark')) {
  326. mermaid.initialize({
  327. startOnLoad: true,
  328. theme: 'dark',
  329. securityLevel: 'loose'
  330. });
  331. } else {
  332. mermaid.initialize({
  333. startOnLoad: true,
  334. theme: 'default',
  335. securityLevel: 'loose'
  336. });
  337. }
  338. });
  339. onDestroy(() => {
  340. if (pyodideWorker) {
  341. pyodideWorker.terminate();
  342. }
  343. });
  344. </script>
  345. <div>
  346. <div class="relative {className} flex flex-col rounded-lg" dir="ltr">
  347. {#if lang === 'mermaid'}
  348. {#if mermaidHtml}
  349. <SvgPanZoom
  350. className=" border border-gray-100 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
  351. svg={mermaidHtml}
  352. content={_token.text}
  353. />
  354. {:else}
  355. <pre class="mermaid">{code}</pre>
  356. {/if}
  357. {:else}
  358. <div class="text-text-300 absolute pl-4 py-1.5 text-xs font-medium dark:text-white">
  359. {lang}
  360. </div>
  361. <div
  362. class="sticky {stickyButtonsClassName} mb-1 py-1 pr-2.5 flex items-center justify-end z-10 text-xs text-black dark:text-white"
  363. >
  364. <div class="flex items-center gap-0.5 translate-y-[1px]">
  365. <button
  366. class="flex gap-1 items-center bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
  367. on:click={collapseCodeBlock}
  368. >
  369. <div class=" -translate-y-[0.5px]">
  370. <ChevronUpDown className="size-3" />
  371. </div>
  372. <div>
  373. {collapsed ? $i18n.t('Expand') : $i18n.t('Collapse')}
  374. </div>
  375. </button>
  376. {#if preview && ['html', 'svg'].includes(lang)}
  377. <button
  378. class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
  379. on:click={previewCode}
  380. >
  381. <div class=" -translate-y-[0.5px]">
  382. <Cube className="size-3" />
  383. </div>
  384. <div>
  385. {$i18n.t('Preview')}
  386. </div>
  387. </button>
  388. {/if}
  389. {#if ($config?.features?.enable_code_execution ?? true) && (lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code)))}
  390. {#if executing}
  391. <div class="run-code-button bg-none border-none p-1 cursor-not-allowed">
  392. {$i18n.t('Running')}
  393. </div>
  394. {:else if run}
  395. <button
  396. class="flex gap-1 items-center run-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
  397. on:click={async () => {
  398. code = _code;
  399. await tick();
  400. executePython(code);
  401. }}
  402. >
  403. <div class=" -translate-y-[0.5px]">
  404. <CommandLine className="size-3" />
  405. </div>
  406. <div>
  407. {$i18n.t('Run')}
  408. </div>
  409. </button>
  410. {/if}
  411. {/if}
  412. {#if save}
  413. <button
  414. class="save-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
  415. on:click={saveCode}
  416. >
  417. {saved ? $i18n.t('Saved') : $i18n.t('Save')}
  418. </button>
  419. {/if}
  420. <button
  421. class="copy-code-button bg-none border-none bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 transition rounded-md px-1.5 py-0.5"
  422. on:click={copyCode}>{copied ? $i18n.t('Copied') : $i18n.t('Copy')}</button
  423. >
  424. </div>
  425. </div>
  426. <div
  427. class="language-{lang} rounded-t-lg -mt-8 {editorClassName
  428. ? editorClassName
  429. : executing || stdout || stderr || result
  430. ? ''
  431. : 'rounded-b-lg'} overflow-hidden"
  432. >
  433. <div class=" pt-7 bg-gray-50 dark:bg-gray-850"></div>
  434. {#if !collapsed}
  435. <CodeEditor
  436. value={code}
  437. {id}
  438. {lang}
  439. onSave={() => {
  440. saveCode();
  441. }}
  442. onChange={(value) => {
  443. _code = value;
  444. }}
  445. />
  446. {:else}
  447. <div
  448. class="bg-gray-50 dark:bg-black dark:text-white rounded-b-lg! pt-2 pb-2 px-4 flex flex-col gap-2 text-xs"
  449. >
  450. <span class="text-gray-500 italic">
  451. {$i18n.t('{{COUNT}} hidden lines', {
  452. COUNT: code.split('\n').length
  453. })}
  454. </span>
  455. </div>
  456. {/if}
  457. </div>
  458. {#if !collapsed}
  459. <div
  460. id="plt-canvas-{id}"
  461. class="bg-gray-50 dark:bg-[#202123] dark:text-white max-w-full overflow-x-auto scrollbar-hidden"
  462. />
  463. {#if executing || stdout || stderr || result || files}
  464. <div
  465. class="bg-gray-50 dark:bg-[#202123] dark:text-white rounded-b-lg! py-4 px-4 flex flex-col gap-2"
  466. >
  467. {#if executing}
  468. <div class=" ">
  469. <div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
  470. <div class="text-sm">Running...</div>
  471. </div>
  472. {:else}
  473. {#if stdout || stderr}
  474. <div class=" ">
  475. <div class=" text-gray-500 text-xs mb-1">STDOUT/STDERR</div>
  476. <div
  477. class="text-sm {stdout?.split('\n')?.length > 100
  478. ? `max-h-96`
  479. : ''} overflow-y-auto"
  480. >
  481. {stdout || stderr}
  482. </div>
  483. </div>
  484. {/if}
  485. {#if result || files}
  486. <div class=" ">
  487. <div class=" text-gray-500 text-xs mb-1">RESULT</div>
  488. {#if result}
  489. <div class="text-sm">{`${JSON.stringify(result)}`}</div>
  490. {/if}
  491. {#if files}
  492. <div class="flex flex-col gap-2">
  493. {#each files as file}
  494. {#if file.type.startsWith('image')}
  495. <img src={file.data} alt="Output" class=" w-full max-w-[36rem]" />
  496. {/if}
  497. {/each}
  498. </div>
  499. {/if}
  500. </div>
  501. {/if}
  502. {/if}
  503. </div>
  504. {/if}
  505. {/if}
  506. {/if}
  507. </div>
  508. </div>