Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 595bb66

Browse files
keegangeorgeSamSaffron
authored andcommitted
DEV: Use markdown-it for better img markdown processing
1 parent 55e5bf8 commit 595bb66

File tree

3 files changed

+80
-73
lines changed

3 files changed

+80
-73
lines changed

assets/javascripts/discourse/components/modal/diff-modal.gjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export default class ModalDiffModal extends Component {
145145
"streamable-content"
146146
(if this.isStreaming "streaming")
147147
(if @model.showResultAsDiff "inline-diff")
148+
(if this.diffStreamer.isThinking "thinking")
148149
}}
149150
>
150151
{{~#if @model.showResultAsDiff~}}

assets/javascripts/discourse/lib/diff-streamer.gjs

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { tracked } from "@glimmer/tracking";
22
import { later } from "@ember/runloop";
33
import loadJSDiff from "discourse/lib/load-js-diff";
4-
import { IMAGE_MARKDOWN_REGEX } from "discourse/lib/uploads";
4+
import { parseAsync } from "discourse/lib/text";
55

66
const DEFAULT_CHAR_TYPING_DELAY = 30;
77

@@ -12,6 +12,7 @@ export default class DiffStreamer {
1212
@tracked diff = "";
1313
@tracked suggestion = "";
1414
@tracked isDone = false;
15+
@tracked isThinking = false;
1516

1617
typingTimer = null;
1718
currentWordIndex = 0;
@@ -37,6 +38,7 @@ export default class DiffStreamer {
3738
this.isDone = !!result?.done;
3839

3940
if (newText.length < this.lastResultText.length) {
41+
this.isThinking = false;
4042
// reset if text got shorter (e.g., reset or new input)
4143
this.words = [];
4244
this.suggestion = "";
@@ -45,11 +47,17 @@ export default class DiffStreamer {
4547
}
4648

4749
const diffText = newText.slice(this.lastResultText.length);
50+
4851
if (!diffText.trim()) {
4952
this.lastResultText = newText;
5053
return;
5154
}
5255

56+
if (await this.#isIncompleteMarkdown(diffText)) {
57+
this.isThinking = true;
58+
return;
59+
}
60+
5361
const newWords = this.#tokenizeMarkdownAware(diffText);
5462

5563
if (newWords.length > 0) {
@@ -63,32 +71,6 @@ export default class DiffStreamer {
6371
this.lastResultText = newText;
6472
}
6573

66-
#tokenizeMarkdownAware(text) {
67-
const tokens = [];
68-
let lastIndex = 0;
69-
70-
let match;
71-
while ((match = IMAGE_MARKDOWN_REGEX.exec(text)) !== null) {
72-
const matchStart = match.index;
73-
74-
if (lastIndex < matchStart) {
75-
const preceding = text.slice(lastIndex, matchStart);
76-
tokens.push(...(preceding.match(/\S+\s*|\s+/g) || []));
77-
}
78-
79-
tokens.push(match[0]);
80-
81-
lastIndex = IMAGE_MARKDOWN_REGEX.lastIndex;
82-
}
83-
84-
if (lastIndex < text.length) {
85-
const trailing = text.slice(lastIndex);
86-
tokens.push(...(trailing.match(/\S+\s*|\s+/g) || []));
87-
}
88-
89-
return tokens;
90-
}
91-
9274
reset() {
9375
this.diff = "";
9476
this.suggestion = "";
@@ -103,16 +85,34 @@ export default class DiffStreamer {
10385
}
10486
}
10587

88+
async #isIncompleteMarkdown(text) {
89+
const tokens = await parseAsync(text);
90+
91+
const hasImage = tokens.some((t) => t.type === "image");
92+
const hasLink = tokens.some((t) => t.type === "link_open");
93+
94+
if (hasImage || hasLink) {
95+
return false;
96+
}
97+
98+
const maybeUnfinishedImage =
99+
/!\[[^\]]*$/.test(text) || /!\[[^\]]*]\(upload:\/\/[^\s)]+$/.test(text);
100+
101+
const maybeUnfinishedLink =
102+
/\[[^\]]*$/.test(text) || /\[[^\]]*]\([^\s)]+$/.test(text);
103+
104+
return maybeUnfinishedImage || maybeUnfinishedLink;
105+
}
106+
106107
async #streamNextChar() {
107108
if (this.currentWordIndex < this.words.length) {
108109
const currentToken = this.words[this.currentWordIndex];
109110

110-
const isMarkdownToken =
111-
currentToken.startsWith("![") || currentToken.startsWith("[");
112-
113-
if (isMarkdownToken) {
114-
this.suggestion += currentToken;
111+
const nextChar = currentToken.charAt(this.currentCharIndex);
112+
this.suggestion += nextChar;
113+
this.currentCharIndex++;
115114

115+
if (this.currentCharIndex >= currentToken.length) {
116116
this.currentWordIndex++;
117117
this.currentCharIndex = 0;
118118

@@ -126,31 +126,9 @@ export default class DiffStreamer {
126126
if (this.currentWordIndex === 1) {
127127
this.diff = this.diff.replace(/^\s+/, "");
128128
}
129-
130-
this.typingTimer = later(this, this.#streamNextChar, this.typingDelay);
131-
} else {
132-
const nextChar = currentToken.charAt(this.currentCharIndex);
133-
this.suggestion += nextChar;
134-
this.currentCharIndex++;
135-
136-
if (this.currentCharIndex >= currentToken.length) {
137-
this.currentWordIndex++;
138-
this.currentCharIndex = 0;
139-
140-
const originalDiff = this.jsDiff.diffWordsWithSpace(
141-
this.selectedText,
142-
this.suggestion
143-
);
144-
145-
this.diff = this.#formatDiffWithTags(originalDiff);
146-
147-
if (this.currentWordIndex === 1) {
148-
this.diff = this.diff.replace(/^\s+/, "");
149-
}
150-
}
151-
152-
this.typingTimer = later(this, this.#streamNextChar, this.typingDelay);
153129
}
130+
131+
this.typingTimer = later(this, this.#streamNextChar, this.typingDelay);
154132
} else {
155133
if (!this.suggestion || !this.selectedText || !this.jsDiff) {
156134
return;
@@ -167,6 +145,33 @@ export default class DiffStreamer {
167145
}
168146
}
169147

148+
#tokenizeMarkdownAware(text) {
149+
const tokens = [];
150+
let lastIndex = 0;
151+
const regex = /!\[[^\]]*]\(upload:\/\/[^\s)]+\)/g;
152+
153+
let match;
154+
while ((match = regex.exec(text)) !== null) {
155+
const matchStart = match.index;
156+
157+
if (lastIndex < matchStart) {
158+
const before = text.slice(lastIndex, matchStart);
159+
tokens.push(...(before.match(/\S+\s*|\s+/g) || []));
160+
}
161+
162+
tokens.push(match[0]);
163+
164+
lastIndex = regex.lastIndex;
165+
}
166+
167+
if (lastIndex < text.length) {
168+
const rest = text.slice(lastIndex);
169+
tokens.push(...(rest.match(/\S+\s*|\s+/g) || []));
170+
}
171+
172+
return tokens;
173+
}
174+
170175
#wrapChunk(text, type) {
171176
if (type === "added") {
172177
return `<ins>${text}</ins>`;

assets/stylesheets/common/streaming.scss

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,34 @@ article.streaming .cooked {
8383
@keyframes mark-blink {
8484
0%,
8585
100% {
86-
border-color: var(--highlight-high);
86+
border-color: transparent;
8787
}
8888

8989
50% {
90-
border-color: transparent;
90+
border-color: var(--highlight-high);
9191
}
9292
}
9393

94+
@keyframes fade-in-highlight {
95+
from {
96+
opacity: 0.5;
97+
}
98+
99+
to {
100+
opacity: 1;
101+
}
102+
}
103+
104+
mark.highlight {
105+
background-color: var(--highlight-high);
106+
animation: fade-in-highlight 0.5s ease-in-out forwards;
107+
}
108+
94109
.composer-ai-helper-modal__suggestion.thinking mark.highlight {
95110
animation: mark-blink 1s step-start 0s infinite;
111+
animation-name: mark-blink;
96112
}
97113

98-
// TODO: cleanup and move around to proper files
99114
.composer-ai-helper-modal__loading {
100115
white-space: pre-wrap;
101116
}
@@ -113,17 +128,3 @@ article.streaming .cooked {
113128
display: inline;
114129
}
115130
}
116-
117-
mark.highlight {
118-
background-color: var(--highlight-high);
119-
animation: fadeInHighlight 0.5s ease-in-out forwards;
120-
}
121-
122-
@keyframes fadeInHighlight {
123-
from {
124-
opacity: 0.5;
125-
}
126-
to {
127-
opacity: 1;
128-
}
129-
}

0 commit comments

Comments
 (0)