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

Commit a77e722

Browse files
committed
DEV: Improve diff streaming with safety checker
1 parent 22ccf29 commit a77e722

File tree

3 files changed

+112
-9
lines changed

3 files changed

+112
-9
lines changed

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default class ModalDiffModal extends Component {
2222
@service messageBus;
2323

2424
@tracked loading = false;
25+
@tracked finalResult = "";
2526
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
2627
@tracked suggestion = "";
2728
@tracked
@@ -65,6 +66,10 @@ export default class ModalDiffModal extends Component {
6566
async updateResult(result) {
6667
this.loading = false;
6768

69+
if (result.done) {
70+
this.finalResult = result.result;
71+
}
72+
6873
if (this.args.model.showResultAsDiff) {
6974
this.diffStreamer.updateResult(result, "result");
7075
} else {
@@ -105,10 +110,14 @@ export default class ModalDiffModal extends Component {
105110
);
106111
}
107112

108-
if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
113+
const finalResult =
114+
this.finalResult?.length > 0
115+
? this.finalResult
116+
: this.diffStreamer.suggestion;
117+
if (this.args.model.showResultAsDiff && finalResult) {
109118
this.args.model.toolbarEvent.replaceText(
110119
this.args.model.selectedText,
111-
this.diffStreamer.suggestion
120+
finalResult
112121
);
113122
}
114123
}

lib/ai_helper/assistant.rb

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,16 @@ def stream_prompt(completion_prompt, input, user, channel, force_default_locale:
181181

182182
streamed_diff = parse_diff(input, partial_response) if completion_prompt.diff?
183183

184-
# Throttle the updates and
185-
# checking length prevents partial tags
186-
# that aren't sanitized correctly yet (i.e. '<output')
187-
# from being sent in the stream
184+
# Throttle updates and check for safe stream points
188185
if (streamed_result.length > 10 && (Time.now - start > 0.3)) || Rails.env.test?
189-
payload = { result: sanitize_result(streamed_result), diff: streamed_diff, done: false }
190-
publish_update(channel, payload, user)
191-
start = Time.now
186+
sanitized = sanitize_result(streamed_result)
187+
188+
# Skip publishing if inside markdown or HTML link context
189+
if DiscourseAi::Utils::DiffUtils::SafetyChecker.safe_to_stream?(sanitized)
190+
payload = { result: sanitized, diff: streamed_diff, done: false }
191+
publish_update(channel, payload, user)
192+
start = Time.now
193+
end
192194
end
193195
end
194196

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# frozen_string_literal: true
2+
3+
require "cgi"
4+
5+
module DiscourseAi
6+
module Utils
7+
module DiffUtils
8+
class SafetyChecker
9+
def self.safe_to_stream?(html_text)
10+
new(html_text).safe?
11+
end
12+
13+
def initialize(html_text)
14+
@original_html = html_text
15+
@text = sanitize(html_text)
16+
end
17+
18+
def safe?
19+
return false if unclosed_markdown_links?
20+
return false if unclosed_raw_html_tag?
21+
return false if trailing_incomplete_url?
22+
return false if unclosed_backticks?
23+
return false if unbalanced_bold_or_italic?
24+
return false if incomplete_image_markdown?
25+
return false if unbalanced_quote_blocks?
26+
return false if unclosed_triple_backticks?
27+
return false if partial_emoji?
28+
29+
true
30+
end
31+
32+
private
33+
34+
def sanitize(html)
35+
text = html.gsub(%r{</?[^>]+>}, "") # remove tags like <span>, <del>, etc.
36+
CGI.unescapeHTML(text)
37+
end
38+
39+
def unclosed_markdown_links?
40+
open_bracket = @text.rindex("[")
41+
close_bracket = @text.rindex("]")
42+
open_paren = @text.rindex("(")
43+
close_paren = @text.rindex(")")
44+
open_bracket && open_paren && (close_bracket.nil? || close_paren.nil?)
45+
end
46+
47+
def unclosed_raw_html_tag?
48+
last_lt = @text.rindex("<")
49+
last_gt = @text.rindex(">")
50+
last_lt && (!last_gt || last_gt < last_lt)
51+
end
52+
53+
def trailing_incomplete_url?
54+
last_word = @text.split(/\s/).last
55+
last_word =~ %r{\Ahttps?://[^\s]*\z} && last_word !~ /[)\].,!?:;'"]\z/
56+
end
57+
58+
def unclosed_backticks?
59+
@text.count("`").odd?
60+
end
61+
62+
def unbalanced_bold_or_italic?
63+
@text.scan(/\*\*/).count.odd? || @text.scan(/\*(?!\*)/).count.odd? ||
64+
@text.scan(/_/).count.odd?
65+
end
66+
67+
def incomplete_image_markdown?
68+
last_image = @text[/!\[.*?\]\(.*?$/, 0]
69+
last_image && last_image[-1] != ")"
70+
end
71+
72+
def unbalanced_quote_blocks?
73+
opens = @text.scan(/\[quote(=.*?)?\]/i).count
74+
closes = @text.scan(%r{\[/quote\]}i).count
75+
opens > closes
76+
end
77+
78+
def unclosed_triple_backticks?
79+
@text.scan(/```/).count.odd?
80+
end
81+
82+
def partial_emoji?
83+
@text
84+
.scan(/:[a-z0-9_+.-]*:?/i)
85+
.any? do |match|
86+
match.count(":") == 1 || (match[-1] != ":" && match =~ /:[a-z0-9_+-]+\.\z/i)
87+
end
88+
end
89+
end
90+
end
91+
end
92+
end

0 commit comments

Comments
 (0)