CodeBlock.svelte 14 KB

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