CodeBlock.svelte 14 KB

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