Files
discourse-ai/assets/javascripts/discourse/components/modal/diff-modal.gjs
T
Sam 2d6ec5e1e6 FIX: apply diffs more consistently (#1367)
* FIX: apply diffs more consistently

1. Do escaping direct in diff streamer, that way HTML tags and other unsafe chars can be displayed and fixed
2. Add safeguard to ensure streaming always stops when it was terminated elsewhere

* lint

* bug unsubscribe should unsubscribe
2025-05-24 15:19:48 +10:00

225 lines
6.4 KiB
Plaintext

import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import CookText from "discourse/components/cook-text";
import DButton from "discourse/components/d-button";
import DModal from "discourse/components/d-modal";
import concatClass from "discourse/helpers/concat-class";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { escapeExpression } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import DiffStreamer from "../../lib/diff-streamer";
import SmoothStreamer from "../../lib/smooth-streamer";
import AiIndicatorWave from "../ai-indicator-wave";
export default class ModalDiffModal extends Component {
@service currentUser;
@service messageBus;
@tracked loading = false;
@tracked finalResult = "";
@tracked escapedSelectedText = escapeExpression(this.args.model.selectedText);
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
@tracked suggestion = "";
@tracked
smoothStreamer = new SmoothStreamer(
() => this.suggestion,
(newValue) => (this.suggestion = newValue)
);
constructor() {
super(...arguments);
this.suggestChanges();
}
get diffResult() {
if (this.diffStreamer.diff?.length > 0) {
return this.diffStreamer.diff;
}
// Prevents flash by showing the
// original text when the diff is empty
return this.escapedSelectedText;
}
get isStreaming() {
// diffStreamer stops "streaming" when it is finished with a chunk
return (
this.diffStreamer.isStreaming ||
!this.diffStreamer.isDone ||
this.smoothStreamer.isStreaming
);
}
get primaryBtnLabel() {
return this.loading
? i18n("discourse_ai.ai_helper.context_menu.loading")
: i18n("discourse_ai.ai_helper.context_menu.confirm");
}
get primaryBtnDisabled() {
return this.loading || this.isStreaming;
}
@bind
subscribe() {
const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
this.messageBus.subscribe(channel, this.updateResult);
}
@bind
unsubscribe() {
const channel = "/discourse-ai/ai-helper/stream_composer_suggestion";
this.messageBus.unsubscribe(channel, this.updateResult);
}
@action
async updateResult(result) {
this.loading = false;
if (result.done) {
this.finalResult = result.result;
}
if (result.done) {
this.loading = false;
}
if (this.args.model.showResultAsDiff) {
this.diffStreamer.updateResult(result, "result");
} else {
this.smoothStreamer.updateResult(result, "result");
}
}
@action
async suggestChanges() {
this.smoothStreamer.resetStreaming();
this.diffStreamer.reset();
try {
this.loading = true;
return await ajax("/discourse-ai/ai-helper/stream_suggestion", {
method: "POST",
data: {
location: "composer",
mode: this.args.model.mode,
text: this.args.model.selectedText,
custom_prompt: this.args.model.customPromptValue,
force_default_locale: true,
client_id: this.messageBus.clientId,
},
});
} catch (e) {
popupAjaxError(e);
}
}
@action
triggerConfirmChanges() {
this.args.closeModal();
if (this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
this.suggestion
);
}
const finalResult =
this.finalResult?.length > 0
? this.finalResult
: this.diffStreamer.suggestion;
if (this.args.model.showResultAsDiff && finalResult) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
finalResult
);
}
}
<template>
<DModal
class="composer-ai-helper-modal"
@title={{i18n "discourse_ai.ai_helper.context_menu.changes"}}
@closeModal={{@closeModal}}
>
<:body>
<div
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
class="text-preview"
>
{{#if this.loading}}
<div class="composer-ai-helper-modal__loading">
{{[email protected]~}}
</div>
{{else}}
<div
class={{concatClass
"composer-ai-helper-modal__suggestion"
"streamable-content"
(if this.isStreaming "streaming")
(if @model.showResultAsDiff "inline-diff")
(if this.diffStreamer.isThinking "thinking")
}}
>
{{~#if @model.showResultAsDiff~}}
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
{{else}}
{{#if this.smoothStreamer.isStreaming}}
<CookText
@rawText={{this.smoothStreamer.renderedText}}
class="cooked"
/>
{{else}}
<div class="composer-ai-helper-modal__old-value">
{{@model.selectedText}}
</div>
<div class="composer-ai-helper-modal__new-value">
<CookText
@rawText={{this.smoothStreamer.renderedText}}
class="cooked"
/>
</div>
{{/if}}
{{/if}}
</div>
{{/if}}
</div>
</:body>
<:footer>
<DButton
class="btn-primary confirm"
@disabled={{this.primaryBtnDisabled}}
@action={{this.triggerConfirmChanges}}
@translatedLabel={{this.primaryBtnLabel}}
>
{{#if this.loading}}
<AiIndicatorWave @loading={{this.loading}} />
{{/if}}
</DButton>
<DButton
class="btn-flat discard"
@action={{@closeModal}}
@label="discourse_ai.ai_helper.context_menu.discard"
/>
<DButton
class="regenerate"
@icon="arrows-rotate"
@action={{this.suggestChanges}}
@label="discourse_ai.ai_helper.context_menu.regen"
/>
</:footer>
</DModal>
</template>
}