Adrien Denat coyotte508 HF staff commited on
Commit
8811ee0
1 Parent(s): e6bc023

Syntax highlighting + copy to clipboard button (#30)

Browse files

* add highlight.js for syntax highlighting + add CodeBlock and CopyToClipboard components

* use clipboard.js for CopyToClipboard button + add Tooltip component

* do not include Tailwind classes in template since it can't parse them

* remove hack to strip model messages "endoftext" for now since it breaks code blocks

* move styles.css to /styles/main.css

* fix import + remove unused component

* auto detect language if not returned by model

* remove clipboard.js dependency and use native API

* rename icons with Icon prefix

---------

Co-authored-by: Eliott C <coyotte508@gmail.com>

package-lock.json CHANGED
@@ -11,6 +11,7 @@
11
  "@huggingface/inference": "^2.0.0-rc2",
12
  "autoprefixer": "^10.4.14",
13
  "date-fns": "^2.29.3",
 
14
  "marked": "^4.3.0",
15
  "mongodb": "^5.3.0",
16
  "postcss": "^8.4.21",
@@ -2124,6 +2125,14 @@
2124
  "node": ">=8"
2125
  }
2126
  },
 
 
 
 
 
 
 
 
2127
  "node_modules/ignore": {
2128
  "version": "5.2.4",
2129
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
 
11
  "@huggingface/inference": "^2.0.0-rc2",
12
  "autoprefixer": "^10.4.14",
13
  "date-fns": "^2.29.3",
14
+ "highlight.js": "^11.7.0",
15
  "marked": "^4.3.0",
16
  "mongodb": "^5.3.0",
17
  "postcss": "^8.4.21",
 
2125
  "node": ">=8"
2126
  }
2127
  },
2128
+ "node_modules/highlight.js": {
2129
+ "version": "11.7.0",
2130
+ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.7.0.tgz",
2131
+ "integrity": "sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==",
2132
+ "engines": {
2133
+ "node": ">=12.0.0"
2134
+ }
2135
+ },
2136
  "node_modules/ignore": {
2137
  "version": "5.2.4",
2138
  "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
package.json CHANGED
@@ -34,6 +34,7 @@
34
  "@huggingface/inference": "^2.0.0-rc2",
35
  "autoprefixer": "^10.4.14",
36
  "date-fns": "^2.29.3",
 
37
  "marked": "^4.3.0",
38
  "mongodb": "^5.3.0",
39
  "postcss": "^8.4.21",
 
34
  "@huggingface/inference": "^2.0.0-rc2",
35
  "autoprefixer": "^10.4.14",
36
  "date-fns": "^2.29.3",
37
+ "highlight.js": "^11.7.0",
38
  "marked": "^4.3.0",
39
  "mongodb": "^5.3.0",
40
  "postcss": "^8.4.21",
src/lib/components/CopyToClipBoardBtn.svelte ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+
4
+ import IconCopy from './icons/IconCopy.svelte';
5
+ import Tooltip from './Tooltip.svelte';
6
+
7
+ export let classNames = '';
8
+ export let value: string;
9
+
10
+ let isSuccess = false;
11
+ let timeout: any;
12
+
13
+ const handleClick = async () => {
14
+ // writeText() can be unavailable or fail in some cases (iframe, etc) so we try/catch
15
+ try {
16
+ await navigator.clipboard.writeText(value);
17
+
18
+ isSuccess = true;
19
+ if (timeout) {
20
+ clearTimeout(timeout);
21
+ }
22
+ timeout = setTimeout(() => {
23
+ isSuccess = false;
24
+ }, 1000);
25
+ } catch (err) {
26
+ console.error(err);
27
+ }
28
+ };
29
+
30
+ onDestroy(() => {
31
+ if (timeout) {
32
+ clearTimeout(timeout);
33
+ }
34
+ });
35
+ </script>
36
+
37
+ <button
38
+ class="btn text-sm rounded-lg border py-2 px-2 shadow-sm border-gray-200 active:shadow-inner dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-400 transition-all {classNames}
39
+ {!isSuccess && 'text-gray-200 dark:text-gray-200'}
40
+ {isSuccess && 'text-green-500'}
41
+ "
42
+ title={'Copy to clipboard'}
43
+ type="button"
44
+ on:click={handleClick}
45
+ >
46
+ <span class="relative">
47
+ <IconCopy />
48
+ <Tooltip classNames={isSuccess ? 'opacity-100' : 'opacity-0'} />
49
+ </span>
50
+ </button>
src/lib/components/Tooltip.svelte ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ export let classNames = "";
3
+ export let label = "Copied";
4
+ export let position = "left-1/2 top-full transform -translate-x-1/2 translate-y-2";
5
+ </script>
6
+
7
+ <div
8
+ class="
9
+ pointer-events-none absolute rounded bg-black py-1 px-2 font-normal leading-tight text-white shadow transition-opacity
10
+ {position}
11
+ {classNames}
12
+ "
13
+ >
14
+ <div
15
+ class="absolute bottom-full left-1/2 h-0 w-0 -translate-x-1/2 transform border-4 border-t-0 border-black"
16
+ style="
17
+ border-left-color: transparent;
18
+ border-right-color: transparent;
19
+ "
20
+ />
21
+ {label}
22
+ </div>
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -1,8 +1,74 @@
1
  <script lang="ts">
2
  import { marked } from 'marked';
3
- import type { Message } from '$lib/types/Message';
 
 
 
4
 
5
  export let message: Message;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  </script>
7
 
8
  {#if message.from === 'assistant'}
@@ -14,8 +80,9 @@
14
  />
15
  <div
16
  class="group relative rounded-2xl px-5 py-3.5 border border-gray-100 bg-gradient-to-br from-gray-50 dark:from-gray-800/40 dark:border-gray-800 prose text-gray-600 dark:text-gray-300"
 
17
  >
18
- {@html marked(message.content, { gfm: true })}
19
  </div>
20
  </div>
21
  {/if}
 
1
  <script lang="ts">
2
  import { marked } from 'marked';
3
+ import type { Message } from '$lib/Types';
4
+ import { afterUpdate } from 'svelte';
5
+
6
+ import CopyToClipBoardBtn from '../CopyToClipBoardBtn.svelte';
7
 
8
  export let message: Message;
9
+ let html = '';
10
+ let el: HTMLElement;
11
+
12
+ const renderer = new marked.Renderer();
13
+
14
+ // Add wrapper to code blocks
15
+ renderer.code = (code, lang) => {
16
+ return `
17
+ <div class="code-block">
18
+ <pre>
19
+ <code class="language-${lang}">${code}</code>
20
+ </pre>
21
+ </div>
22
+ `.replaceAll('\t', '');
23
+ };
24
+
25
+ const handleParsed = (err: Error | null, parsedHtml: string) => {
26
+ if (err) {
27
+ console.error(err);
28
+ } else {
29
+ html = parsedHtml;
30
+ }
31
+ };
32
+
33
+ const options: marked.MarkedOptions = {
34
+ ...marked.getDefaults(),
35
+ gfm: true,
36
+ highlight: (code, lang, callback) => {
37
+ import('highlight.js').then(
38
+ ({ default: hljs }) => {
39
+ const language = hljs.getLanguage(lang);
40
+ callback?.(null, hljs.highlightAuto(code, language?.aliases).value);
41
+ },
42
+ (err) => {
43
+ console.error(err);
44
+ callback?.(err);
45
+ }
46
+ );
47
+ },
48
+ renderer
49
+ };
50
+
51
+ $: marked(message.content, options, handleParsed);
52
+
53
+ afterUpdate(() => {
54
+ if (el) {
55
+ const codeBlocks = el.querySelectorAll('.code-block');
56
+
57
+ // Add copy to clipboard button to each code block
58
+ codeBlocks.forEach((block) => {
59
+ if (block.classList.contains('has-copy-btn')) return;
60
+
61
+ new CopyToClipBoardBtn({
62
+ target: block,
63
+ props: {
64
+ value: (block as HTMLElement).innerText ?? '',
65
+ classNames: 'absolute top-2 right-2'
66
+ }
67
+ });
68
+ block.classList.add('has-copy-btn');
69
+ });
70
+ }
71
+ });
72
  </script>
73
 
74
  {#if message.from === 'assistant'}
 
80
  />
81
  <div
82
  class="group relative rounded-2xl px-5 py-3.5 border border-gray-100 bg-gradient-to-br from-gray-50 dark:from-gray-800/40 dark:border-gray-800 prose text-gray-600 dark:text-gray-300"
83
+ bind:this={el}
84
  >
85
+ {@html html}
86
  </div>
87
  </div>
88
  {/if}
src/lib/components/icons/{Chevron.svelte → IconChevron.svelte} RENAMED
File without changes
src/lib/components/icons/IconCopy.svelte ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ export let classNames = "";
3
+ </script>
4
+
5
+ <svg
6
+ class={classNames}
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ aria-hidden="true"
9
+ fill="currentColor"
10
+ focusable="false"
11
+ role="img"
12
+ width="1em"
13
+ height="1em"
14
+ preserveAspectRatio="xMidYMid meet"
15
+ viewBox="0 0 32 32"
16
+ >
17
+ <path
18
+ d="M28,10V28H10V10H28m0-2H10a2,2,0,0,0-2,2V28a2,2,0,0,0,2,2H28a2,2,0,0,0,2-2V10a2,2,0,0,0-2-2Z"
19
+ transform="translate(0)"
20
+ />
21
+ <path d="M4,18H2V4A2,2,0,0,1,4,2H18V4H4Z" transform="translate(0)" /><rect fill="none" width="32" height="32" />
22
+ </svg>
src/routes/+layout.svelte CHANGED
@@ -1,5 +1,5 @@
1
  <script lang="ts">
2
- import '../styles.css';
3
  import type { LayoutData } from './$types';
4
 
5
  export let data: LayoutData;
 
1
  <script lang="ts">
2
+ import '../styles/main.css';
3
  import type { LayoutData } from './$types';
4
 
5
  export let data: LayoutData;
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -46,12 +46,8 @@
46
  // First token has a space at the beginning, trim it
47
  messages = [...messages, { from: 'assistant', content: data.token.text.trimStart() }];
48
  } else {
49
- const isEndOfText = endOfTextRegex.test(data.token.text);
50
-
51
- lastMessage.content += isEndOfText ? data.token.text.replace('<', '') : data.token.text;
52
  messages = [...messages];
53
-
54
- if (isEndOfText) break;
55
  }
56
  }
57
  }
 
46
  // First token has a space at the beginning, trim it
47
  messages = [...messages, { from: 'assistant', content: data.token.text.trimStart() }];
48
  } else {
49
+ lastMessage.content += data.token.text;
 
 
50
  messages = [...messages];
 
 
51
  }
52
  }
53
  }
src/styles/highlight-js.css ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import 'highlight.js/styles/atom-one-light';
2
+
3
+ /* Dark Theme */
4
+ /*
5
+ Night Owl for highlight.js (c) Carl Baxter <carl@cbax.tech>
6
+ An adaptation of Sarah Drasner's Night Owl VS Code Theme
7
+ https://github.com/sdras/night-owl-vscode-theme
8
+ Copyright (c) 2018 Sarah Drasner
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+ */
25
+
26
+ .dark .hljs {
27
+ display: block;
28
+ overflow-x: auto;
29
+ padding: 0.5em;
30
+ background: #011627;
31
+ color: #d6deeb;
32
+ }
33
+
34
+ /* General Purpose */
35
+ .dark .hljs-keyword {
36
+ color: #c792ea;
37
+ font-style: italic;
38
+ }
39
+ .dark .hljs-built_in {
40
+ color: #addb67;
41
+ font-style: italic;
42
+ }
43
+ .dark .hljs-type {
44
+ color: #82aaff;
45
+ }
46
+ .dark .hljs-literal {
47
+ color: #ff5874;
48
+ }
49
+ .dark .hljs-number {
50
+ color: #f78c6c;
51
+ }
52
+ .dark .hljs-regexp {
53
+ color: #5ca7e4;
54
+ }
55
+ .dark .hljs-string {
56
+ color: #ecc48d;
57
+ }
58
+ .dark .hljs-subst {
59
+ color: #d3423e;
60
+ }
61
+ .dark .hljs-symbol {
62
+ color: #82aaff;
63
+ }
64
+ .dark .hljs-class {
65
+ color: #ffcb8b;
66
+ }
67
+ .dark .hljs-function {
68
+ color: #82aaff;
69
+ }
70
+ .dark .hljs-title {
71
+ color: #dcdcaa;
72
+ font-style: italic;
73
+ }
74
+ .dark .hljs-params {
75
+ color: #7fdbca;
76
+ }
77
+
78
+ /* Meta */
79
+ .dark .hljs-comment {
80
+ color: #637777;
81
+ font-style: italic;
82
+ }
83
+ .dark .hljs-doctag {
84
+ color: #7fdbca;
85
+ }
86
+ .dark .hljs-meta {
87
+ color: #82aaff;
88
+ }
89
+ .dark .hljs-meta-keyword {
90
+ color: #82aaff;
91
+ }
92
+ .dark .hljs-meta-string {
93
+ color: #ecc48d;
94
+ }
95
+
96
+ /* Tags, attributes, config */
97
+ .dark .hljs-section {
98
+ color: #82b1ff;
99
+ }
100
+ .dark .hljs-tag,
101
+ .dark .hljs-name,
102
+ .dark .hljs-builtin-name {
103
+ color: #7fdbca;
104
+ }
105
+ .dark .hljs-attr {
106
+ color: #7fdbca;
107
+ }
108
+ .dark .hljs-attribute {
109
+ color: #80cbc4;
110
+ }
111
+ .dark .hljs-variable {
112
+ color: #addb67;
113
+ }
114
+
115
+ /* Markup */
116
+ .dark .hljs-bullet {
117
+ color: #d9f5dd;
118
+ }
119
+ .dark .hljs-code {
120
+ color: #80cbc4;
121
+ }
122
+ .dark .hljs-emphasis {
123
+ color: #c792ea;
124
+ font-style: italic;
125
+ }
126
+ .dark .hljs-strong {
127
+ color: #addb67;
128
+ font-weight: bold;
129
+ }
130
+ .dark .hljs-formula {
131
+ color: #c792ea;
132
+ }
133
+ .dark .hljs-link {
134
+ color: #ff869a;
135
+ }
136
+ .dark .hljs-quote {
137
+ color: #697098;
138
+ font-style: italic;
139
+ }
140
+
141
+ /* CSS */
142
+ .dark .hljs-selector-tag {
143
+ color: #ff6363;
144
+ }
145
+
146
+ .dark .hljs-selector-id {
147
+ color: #fad430;
148
+ }
149
+
150
+ .dark .hljs-selector-class {
151
+ color: #addb67;
152
+ font-style: italic;
153
+ }
154
+
155
+ .dark .hljs-selector-attr,
156
+ .dark .hljs-selector-pseudo {
157
+ color: #c792ea;
158
+ font-style: italic;
159
+ }
160
+
161
+ /* Templates */
162
+ .dark .hljs-template-tag {
163
+ color: #c792ea;
164
+ }
165
+ .dark .hljs-template-variable {
166
+ color: #addb67;
167
+ }
168
+
169
+ /* diff */
170
+ .dark .hljs-addition {
171
+ color: #addb67ff;
172
+ font-style: italic;
173
+ }
174
+
175
+ .dark .hljs-deletion {
176
+ color: #ef535090;
177
+ font-style: italic;
178
+ }
src/{styles.css → styles/main.css} RENAMED
@@ -1,9 +1,25 @@
 
 
1
  @tailwind base;
2
  @tailwind components;
3
  @tailwind utilities;
4
 
 
 
 
 
 
 
5
  @layer utilities {
6
  .scrollbar-custom {
7
  @apply !scrollbar-thin !scrollbar-w-1 !scrollbar-thumb-rounded-full !scrollbar-track-transparent !scrollbar-thumb-black/10 dark:!scrollbar-thumb-white/10;
8
  }
 
 
 
 
 
 
 
 
9
  }
 
1
+ @import './highlight-js.css';
2
+
3
  @tailwind base;
4
  @tailwind components;
5
  @tailwind utilities;
6
 
7
+ @layer components {
8
+ .btn {
9
+ @apply cursor-pointer select-none inline-flex justify-center items-center whitespace-nowrap border focus:outline-none focus:ring;
10
+ }
11
+ }
12
+
13
  @layer utilities {
14
  .scrollbar-custom {
15
  @apply !scrollbar-thin !scrollbar-w-1 !scrollbar-thumb-rounded-full !scrollbar-track-transparent !scrollbar-thumb-black/10 dark:!scrollbar-thumb-white/10;
16
  }
17
+
18
+ .code-block {
19
+ @apply relative bg-gray-100 dark:bg-gray-950 rounded-lg my-4;
20
+ }
21
+
22
+ .code-block > pre {
23
+ @apply overflow-auto px-5 py-3.5 text-gray-500 dark:text-gray-400;
24
+ }
25
  }