Compare commits

...
29 Commits
Author SHA1 Message Date
Sam Saffron 7912b5e8be this code should no longer need 2025-05-23 22:05:17 +10:00
Sam cf220c530c FIX: Improve MessageBus efficiency and correctly stop streaming (#1362)
* FIX: Improve MessageBus efficiency and correctly stop streaming

This commit enhances the message bus implementation for AI helper streaming by:

- Adding client_id targeting for message bus publications to ensure only the requesting client receives streaming updates
- Limiting MessageBus backlog size (2) and age (60 seconds) to prevent Redis bloat
- Replacing clearTimeout with Ember's cancel method for proper runloop management, we were leaking a stop
- Adding tests for client-specific message delivery

These changes improve memory usage and make streaming more reliable by ensuring messages are properly directed to the requesting client.

* composer suggestion needed a fix as well.

* backlog size of 2 is risky here cause same channel name is reused between clients
2025-05-23 16:23:06 +10:00
Keegan George 61ef1932fa DEV: Follow-up small fixes to AI composer helper (#1361)
**This update includes a variety of small fixes to the AI composer helper:**
- ensures there is no flash of no text when diff modal is triggered
- escapes selected text prior to diff streaming
- uses monospace font in diff modal since text rendered is raw markdown
2025-05-22 12:37:42 -07:00
Keegan George 11e90f5f3f DEV: Hide proofreading shortcut in toolbar title (#1360) 2025-05-22 09:36:55 -07:00
Joffrey JAFFEUX b817810d46 FIX: ensures stream update object is scoped to its initial topic (#1359)
* FIX: ensures stream update object is scoped to its initial topic

Before this commit you could end up in this situation where a `post-updater` is constructed for a specific topic, but the user changes topic mid steam and it ends up updating the same post number but in a  different topic as we were only checking for `post_number` and not the combination of `topic_id` + `post_number`.

* we should have the topic
2025-05-22 20:08:43 +10:00
Sam 55dab9c68a FIX: stabilize diff algorithm for streaming (#1358)
Previous to this fix we would diff the entire body of text, this
could lead to situations where a diff presented to a user was
wildly off matching areas in the text that should not have been
tested.

New algorithm only checks a portion of the string, this ensures
that during streaming there is no chance for wild mistakes
2025-05-22 16:31:21 +10:00
Keegan GeorgeandSam Saffron c29183fc2d FEATURE: Add diff streaming animation (#1355)
Previously we attempted to add a diff streaming animation to the AI composer helper: https://github.com/discourse/discourse-ai/pull/1332, but it resulted in issues despite attempted fixes (https://github.com/discourse/discourse-ai/pull/1338) so we temporarily suppressed the diff animation (https://github.com/discourse/discourse-ai/pull/1341).

This update makes a second attempt at implementing the diff streaming animation. Instead of creating a custom diff algorithm, we make use of a third-party library [`jsDiff`](https://github.com/kpdecker/jsdiff) (which we added to core here: https://github.com/discourse/discourse/pull/32833). While streaming, the diff animation often struggles with markdown links and images, so we make use of `markdown-it` parser to detect those cases and prevent breaking the animation.

---------

Co-authored-by: Sam Saffron <[email protected]>
2025-05-22 08:10:50 +10:00
Roman Rizzi d72ad84f8f FIX: Retry parsing escaped inner JSON to handle control chars. (#1357)
The structured output JSON comes embedded inside the API response, which is also a JSON. Since we have to parse the response to process it, any control characters inside the structured output are unescaped into regular characters, leading to invalid JSON and breaking during parsing. This change adds a retry mechanism that escapes
the string again if parsing fails, preventing the parser from breaking on malformed input and working around this issue.

For example:

```
  original = '{ "a": "{\\"key\\":\\"value with \\n newline\\"}" }'
  JSON.parse(original) => { "a" => "{\"key\":\"value with \n newline\"}" }
  # At this point, the inner JSON string contains an actual newline.
```
2025-05-21 11:25:59 -03:00
Roman Rizzi e207eba1a4 FIX: Don't dig on nil when checking for the gemini schema (#1356) 2025-05-21 08:30:47 -03:00
Kris 53905f65ac FEATURE: add participants and invite button to AI conversations (#1354) 2025-05-21 10:27:06 +10:00
Guhyoun Nam ef260be7cf DEV: Move the stop-forcing-conversations-sidebar appEvents trigger (#1353) 2025-05-20 15:06:54 -05:00
Discourse Translator Bot 0c69bb85ce Update translations (#1352) 2025-05-20 17:33:52 +02:00
Sam af18603b21 DEV: cancel manager should bypass webmock (#1350)
Webmock can be a bit flaky under certain use cases.
2025-05-20 13:01:55 +10:00
Sam 7db2589cc4 DEV: prompt engineering to improve citations (#1351) 2025-05-20 13:01:35 +10:00
Roman Rizzi 2fb691cba8 FEATURE: Triage can hide posts after adding them to the review queue (#1348) 2025-05-20 08:19:00 +10:00
Sam de0625571b FIX: add safe navigation to serializer include conditions (#1349)
In some rare cases a post can exist without a topic
2025-05-19 18:55:55 -03:00
Joffrey JAFFEUX 296aa24df1 DEV: rewrites artifact spec with capybara waiters (#1347)
Generally speaking we never want to do:

```
expect(element.text).to eq("foo")
```

As these are rspec matchers and do not add further Capybara-style waiting specifically for the text content to become present.
2025-05-20 07:27:15 +10:00
Sam 3ac2359ff1 FEATURE: allow passing in data attributes to an artifact (#1346)
Also allow artifact access to current username

Usage inside artifact is:

1. await window.discourseArtifactReady;
2. access data via window.discourseArtifactData;
2025-05-19 15:44:37 +10:00
Guhyoun Nam 925949de47 DEV: Add multiple appEvents trigger on AI Chatbot (#1324)
* DEV: Add appEvents trigger on AI Chatbot header icon

* update event name

* stop-forcing-custom-sidebar appevents trigger

* add force stop custom sidebar appEvents

* trigger name update

* trigger name update
2025-05-16 12:25:16 -05:00
David Taylor b2fbab9fad DEV: Fix linting violations (#1345) 2025-05-16 13:06:15 +01:00
David Taylor 381fa14158 DEV: Rename spec (#1344)
This has nothing to do with `assets:precompile`. Likely a copy/paste from another spec
2025-05-16 09:40:08 +01:00
David Taylor 77d3a38e49 FIX: AI share page assets via CDN on login-required sites (#1343)
AI share page assets are loaded via the app CDN, which means the requests have no authentication and will never appear to the app as "logged in". Therefore we should skip the `redirect_to_login_if_required` before_action.
2025-05-16 09:30:38 +01:00
Sam 7316058dfc FEATURE: hashtag and mention autocomplete for first bot message (#1342)
Also removes controller which is a deprecated pattern

* some comment improvements

* remove uneeded code
2025-05-16 16:19:20 +10:00
Keegan George 9ee82fd8be DEV: Temporarily suppress diff animation as we fix issues (#1341)
The diff animation introduced in https://github.com/discourse/discourse-ai/pull/1332 and with attempts to improve it in https://github.com/discourse/discourse-ai/pull/1338 still has various issues. As we work on a fix, we want to revert the animation to simply stream the diff without animation so users are not left with a janky unusable experience.
2025-05-15 14:55:30 -07:00
Keegan George dfea784fc4 DEV: Improve diff streaming accuracy with safety checker (#1338)
This update adds a safety checker which scans the streamed updates. It ensures that incomplete segments of text are not sent yet over message bus as this will cause breakage with the diff streamer. It also updates the diff streamer to handle a thinking state for when we are waiting for message bus updates.
2025-05-15 11:38:46 -07:00
Roman Rizzi ff2e18f9ca FIX: Structured output discrepancies. (#1340)
This change fixes two bugs and adds a safeguard.

The first issue is that the schema Gemini expected differed from the one sent, resulting in 400 errors when performing completions.

The second issue was that creating a new persona won't define a method
for `response_format`. This has to be explicitly defined when we wrap it inside the Persona class. Also, There was a mismatch between the default value and what we stored in the DB. Some parts of the code expected symbols as keys and others as strings.

Finally, we add a safeguard when, even if asked to, the model refuses to reply with a valid JSON. In this case, we are making a best-effort to recover and stream the raw response.
2025-05-15 11:32:10 -03:00
Sam 1b3fdad5c7 FEATURE: allow researcher to also research specific topics (#1339)
* FEATURE: allow researcher to also research specific topics

Also improve UI around research with more accurate info

* this ensures that under no conditions PMs will be included
2025-05-15 17:48:21 +10:00
SamandCopilot 2c6459429f DEV: use a proper object for tool definition (#1337)
* DEV: use a proper object for tool definition

This moves away from using a loose hash to define tools, which
is error prone.

Instead given a proper object we will also be able to coerce the
return values to match tool definition correctly

* fix xml tools

* fix anthropic tools

* fix specs... a few more to go

* specs are passing

* FIX: coerce values for XML tool calls

* Update spec/lib/completions/tool_definition_spec.rb

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>
2025-05-15 17:32:39 +10:00
Sam c34fcc8a95 FEATURE: forum researcher persona for deep research (#1313)
This commit introduces a new Forum Researcher persona specialized in deep forum content analysis along with comprehensive improvements to our AI infrastructure.

Key additions:

    New Forum Researcher persona with advanced filtering and analysis capabilities
    Robust filtering system supporting tags, categories, dates, users, and keywords
    LLM formatter to efficiently process and chunk research results

Infrastructure improvements:

    Implemented CancelManager class to centrally manage AI completion cancellations
    Replaced callback-based cancellation with a more robust pattern
    Added systematic cancellation monitoring with callbacks

Other improvements:

    Added configurable default_enabled flag to control which personas are enabled by default
    Updated translation strings for the new researcher functionality
    Added comprehensive specs for the new components

    Renames Researcher -> Web Researcher

This change makes our AI platform more stable while adding powerful research capabilities that can analyze forum trends and surface relevant content.
2025-05-14 12:36:16 +10:00
118 changed files with 4050 additions and 899 deletions
@@ -42,6 +42,21 @@ module DiscourseAi
<style>
#{artifact.css}
</style>
<script>
window._discourse_user_data = {
#{current_user ? "username: #{current_user.username.to_json}" : "username: null"}
};
window.discourseArtifactReady = new Promise(resolve => {
window._resolveArtifactData = resolve;
});
window.addEventListener('message', function(event) {
if (event.data && event.data.type === 'discourse-artifact-data') {
window.discourseArtifactData = event.data.dataset || {};
Object.assign(window.discourseArtifactData, window._discourse_user_data);
window._resolveArtifactData(window.discourseArtifactData);
}
});
</script>
</head>
<body>
#{artifact.html}
@@ -74,6 +89,19 @@ module DiscourseAi
</head>
<body>
<iframe sandbox="allow-scripts allow-forms" height="100%" width="100%" srcdoc="#{ERB::Util.html_escape(untrusted_html)}" frameborder="0"></iframe>
<script>
document.querySelector('iframe').addEventListener('load', function() {
try {
const iframeWindow = this.contentWindow;
const message = { type: 'discourse-artifact-data', dataset: {} };
if (window.frameElement && window.frameElement.dataset) {
Object.assign(message.dataset, window.frameElement.dataset);
}
iframeWindow.postMessage(message, '*');
} catch (e) { console.error('Error passing data to artifact:', e); }
});
</script>
</body>
</html>
HTML
@@ -8,7 +8,7 @@ module DiscourseAi
before_action :require_site_settings!
skip_before_action :preload_json, :check_xhr, only: %i[show asset]
skip_before_action :verify_authenticity_token, only: ["asset"]
skip_before_action :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset]
def create
ensure_allowed_create!
@@ -124,6 +124,10 @@ module DiscourseAi
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end
# to stream we must have an appropriate client_id
# otherwise we may end up streaming the data to the wrong client
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank?
if location == "composer"
Jobs.enqueue(
:stream_composer_helper,
@@ -132,6 +136,7 @@ module DiscourseAi
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
force_default_locale: params[:force_default_locale] || false,
client_id: params[:client_id],
)
else
post_id = get_post_param!
@@ -146,6 +151,7 @@ module DiscourseAi
text: text,
prompt: prompt.name,
custom_prompt: params[:custom_prompt],
client_id: params[:client_id],
)
end
@@ -8,6 +8,7 @@ module Jobs
return unless args[:prompt]
return unless user = User.find_by(id: args[:user_id])
return unless args[:text]
return unless args[:client_id]
prompt = CompletionPrompt.enabled_by_name(args[:prompt])
@@ -21,6 +22,7 @@ module Jobs
user,
"/discourse-ai/ai-helper/stream_composer_suggestion",
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
)
end
end
+1
View File
@@ -266,6 +266,7 @@ class AiPersona < ActiveRecord::Base
define_method(:top_p) { @ai_persona&.top_p }
define_method(:system_prompt) { @ai_persona&.system_prompt || "You are a helpful bot." }
define_method(:uploads) { @ai_persona&.uploads }
define_method(:response_format) { @ai_persona&.response_format }
define_method(:examples) { @ai_persona&.examples }
end
end
@@ -1,6 +1,7 @@
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 { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import htmlClass from "discourse/helpers/html-class";
@@ -88,6 +89,15 @@ export default class AiArtifactComponent extends Component {
}`;
}
@action
setDataAttributes(element) {
if (this.args.dataAttributes) {
Object.entries(this.args.dataAttributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
}
<template>
{{#if this.expanded}}
{{htmlClass "ai-artifact-expanded"}}
@@ -118,6 +128,7 @@ export default class AiArtifactComponent extends Component {
src={{this.artifactUrl}}
width="100%"
frameborder="0"
{{didInsert this.setDataAttributes}}
></iframe>
{{/if}}
{{#unless this.requireClickToRun}}
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ import { composeAiBotMessage } from "../lib/ai-bot-helper";
import { AI_CONVERSATIONS_PANEL } from "../services/ai-conversations-sidebar-manager";
export default class AiBotHeaderIcon extends Component {
@service appEvents;
@service composer;
@service currentUser;
@service navigationMenu;
@@ -51,6 +52,7 @@ export default class AiBotHeaderIcon extends Component {
}
if (this.siteSettings.ai_bot_enable_dedicated_ux) {
this.appEvents.trigger("discourse-ai:bot-header-icon-clicked");
return this.router.transitionTo("discourse-ai-bot-conversations");
}
@@ -242,6 +242,7 @@ export default class AiPostHelperMenu extends Component {
text: this.args.data.selectedText,
post_id: this.args.data.quoteState.postId,
custom_prompt: this.customPromptValue,
client_id: this.messageBus.clientId,
},
});
@@ -12,6 +12,7 @@ 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";
@@ -22,7 +23,9 @@ export default class ModalDiffModal extends Component {
@service messageBus;
@tracked loading = false;
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
@tracked finalResult = "";
@tracked selectedText = escapeExpression(this.args.model.selectedText);
@tracked diffStreamer = new DiffStreamer(this.selectedText);
@tracked suggestion = "";
@tracked
smoothStreamer = new SmoothStreamer(
@@ -35,6 +38,16 @@ export default class ModalDiffModal extends Component {
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.selectedText;
}
get isStreaming() {
return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
}
@@ -65,6 +78,14 @@ export default class ModalDiffModal extends Component {
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 {
@@ -84,9 +105,10 @@ export default class ModalDiffModal extends Component {
data: {
location: "composer",
mode: this.args.model.mode,
text: this.args.model.selectedText,
text: this.selectedText,
custom_prompt: this.args.model.customPromptValue,
force_default_locale: true,
client_id: this.messageBus.clientId,
},
});
} catch (e) {
@@ -100,16 +122,17 @@ export default class ModalDiffModal extends Component {
if (this.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
this.selectedText,
this.suggestion
);
}
if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
this.diffStreamer.suggestion
);
const finalResult =
this.finalResult?.length > 0
? this.finalResult
: this.diffStreamer.suggestion;
if (this.args.model.showResultAsDiff && finalResult) {
this.args.model.toolbarEvent.replaceText(this.selectedText, finalResult);
}
}
@@ -120,10 +143,14 @@ export default class ModalDiffModal extends Component {
@closeModal={{@closeModal}}
>
<:body>
<div {{didInsert this.subscribe}} {{willDestroy this.unsubscribe}}>
<div
{{didInsert this.subscribe}}
{{willDestroy this.unsubscribe}}
class="text-preview"
>
{{#if this.loading}}
<div class="composer-ai-helper-modal__loading">
<CookText @rawText={{@model.selectedText}} />
{{~@model.selectedText~}}
</div>
{{else}}
<div
@@ -132,10 +159,11 @@ export default class ModalDiffModal extends Component {
"streamable-content"
(if this.isStreaming "streaming")
(if @model.showResultAsDiff "inline-diff")
(if this.diffStreamer.isThinking "thinking")
}}
>
{{#if @model.showResultAsDiff}}
{{htmlSafe this.diffStreamer.diff}}
{{~#if @model.showResultAsDiff~}}
<span class="diff-inner">{{htmlSafe this.diffResult}}</span>
{{else}}
{{#if this.smoothStreamer.isStreaming}}
<CookText
@@ -7,6 +7,10 @@ import AiSummaryModal from "../../components/modal/ai-summary-modal";
export default class AiSummaryTrigger extends Component {
@service modal;
get isAiConversation() {
return this.args.outletArgs.topic.is_bot_pm;
}
@action
openAiSummaryModal() {
this.modal.show(AiSummaryModal, {
@@ -18,15 +22,17 @@ export default class AiSummaryTrigger extends Component {
}
<template>
{{#if @outletArgs.topic.summarizable}}
<section class="topic-map__additional-contents toggle-summary">
<DButton
@label="summary.buttons.generate"
@icon="discourse-sparkles"
@action={{this.openAiSummaryModal}}
class="btn-default ai-summarization-button"
/>
</section>
{{/if}}
{{#unless this.isAiConversation}}
{{#if @outletArgs.topic.summarizable}}
<section class="topic-map__additional-contents toggle-summary">
<DButton
@label="summary.buttons.generate"
@icon="discourse-sparkles"
@action={{this.openAiSummaryModal}}
class="btn-default ai-summarization-button"
/>
</section>
{{/if}}
{{/unless}}
</template>
}
@@ -0,0 +1,55 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { or } from "truth-helpers";
import DButton from "discourse/components/d-button";
import Participant from "discourse/components/header/topic/participant";
import AddPmParticipants from "discourse/components/modal/add-pm-participants";
export default class AiConversationInvite extends Component {
static shouldRender(args) {
return args.topic.is_bot_pm;
}
@service site;
@service modal;
@service header;
@service sidebarState;
get participants() {
const participants = [
...this.header.topicInfo.details.allowed_users,
...this.header.topicInfo.details.allowed_groups,
];
return participants;
}
@action
showInvite() {
this.modal.show(AddPmParticipants, {
model: {
title: "discourse_ai.ai_bot.invite_ai_conversation.title",
inviteModel: this.args.outletArgs.topic,
},
});
}
<template>
<div class="ai-conversation__participants">
<DButton
@icon="user-plus"
@label="discourse_ai.ai_bot.invite_ai_conversation.button"
@action={{this.showInvite}}
class="btn-default ai-conversations__invite-button"
/>
{{#each this.participants as |participant|}}
<Participant
@user={{participant}}
@type={{if participant.username "user" "group"}}
@username={{or participant.username participant.name}}
@avatarSize="medium"
/>
{{/each}}
</div>
</template>
}
@@ -1,200 +0,0 @@
import { tracked } from "@glimmer/tracking";
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { getOwner } from "@ember/owner";
import { service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import { popupAjaxError } from "discourse/lib/ajax-error";
import UppyUpload from "discourse/lib/uppy/uppy-upload";
import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin";
import { clipboardHelpers } from "discourse/lib/utilities";
export default class DiscourseAiBotConversations extends Controller {
@service aiBotConversationsHiddenSubmit;
@service currentUser;
@service mediaOptimizationWorker;
@service site;
@service siteSettings;
@tracked uploads = new TrackedArray();
// Don't track this directly - we'll get it from uppyUpload
textarea = null;
uppyUpload = null;
fileInputEl = null;
_handlePaste = (event) => {
if (document.activeElement !== this.textarea) {
return;
}
const { canUpload, canPasteHtml, types } = clipboardHelpers(event, {
siteSettings: this.siteSettings,
canUpload: true,
});
if (!canUpload || canPasteHtml || types.includes("text/plain")) {
return;
}
if (event && event.clipboardData && event.clipboardData.files) {
this.uppyUpload.addFiles([...event.clipboardData.files], {
pasted: true,
});
}
};
init() {
super.init(...arguments);
this.uppyUpload = new UppyUpload(getOwner(this), {
id: "ai-bot-file-uploader",
type: "ai-bot-conversation",
useMultipartUploadsIfAvailable: true,
uppyReady: () => {
if (this.siteSettings.composer_media_optimization_image_enabled) {
this.uppyUpload.uppyWrapper.useUploadPlugin(UppyMediaOptimization, {
optimizeFn: (data, opts) =>
this.mediaOptimizationWorker.optimizeImage(data, opts),
runParallel: !this.site.isMobileDevice,
});
}
this.uppyUpload.uppyWrapper.onPreProcessProgress((file) => {
const inProgressUpload = this.inProgressUploads?.find(
(upl) => upl.id === file.id
);
if (inProgressUpload && !inProgressUpload.processing) {
inProgressUpload.processing = true;
}
});
this.uppyUpload.uppyWrapper.onPreProcessComplete((file) => {
const inProgressUpload = this.inProgressUploads?.find(
(upl) => upl.id === file.id
);
if (inProgressUpload) {
inProgressUpload.processing = false;
}
});
// Setup paste listener for the textarea
this.textarea?.addEventListener("paste", this._handlePaste);
},
uploadDone: (upload) => {
this.uploads.push(upload);
},
// Fix: Don't try to set inProgressUploads directly
onProgressUploadsChanged: () => {
// This is just for UI triggers - we're already tracking inProgressUploads
this.notifyPropertyChange("inProgressUploads");
},
});
}
willDestroy() {
super.willDestroy(...arguments);
this.textarea?.removeEventListener("paste", this._handlePaste);
this.uppyUpload?.teardown();
}
get loading() {
return this.aiBotConversationsHiddenSubmit?.loading;
}
get inProgressUploads() {
return this.uppyUpload?.inProgressUploads || [];
}
get showUploadsContainer() {
return this.uploads?.length > 0 || this.inProgressUploads?.length > 0;
}
@action
setPersonaId(id) {
this.aiBotConversationsHiddenSubmit.personaId = id;
}
@action
setTargetRecipient(username) {
this.aiBotConversationsHiddenSubmit.targetUsername = username;
}
@action
updateInputValue(value) {
this._autoExpandTextarea();
this.aiBotConversationsHiddenSubmit.inputValue =
value.target?.value || value;
}
@action
handleKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
this.prepareAndSubmitToBot();
}
}
@action
setTextArea(element) {
this.textarea = element;
}
@action
registerFileInput(element) {
if (element) {
this.fileInputEl = element;
if (this.uppyUpload) {
this.uppyUpload.setup(element);
}
}
}
@action
openFileUpload() {
if (this.fileInputEl) {
this.fileInputEl.click();
}
}
@action
removeUpload(upload) {
this.uploads = new TrackedArray(this.uploads.filter((u) => u !== upload));
}
@action
cancelUpload(upload) {
this.uppyUpload.cancelSingleUpload({
fileId: upload.id,
});
}
@action
async prepareAndSubmitToBot() {
// Pass uploads to the service before submitting
this.aiBotConversationsHiddenSubmit.uploads = this.uploads;
try {
await this.aiBotConversationsHiddenSubmit.submitToBot();
this.uploads = new TrackedArray();
} catch (error) {
popupAjaxError(error);
}
}
_autoExpandTextarea() {
this.textarea.style.height = "auto";
this.textarea.style.height = this.textarea.scrollHeight + "px";
// Get the max-height value from CSS (30vh)
const maxHeight = parseInt(getComputedStyle(this.textarea).maxHeight, 10);
// Only enable scrolling if content exceeds max-height
if (this.textarea.scrollHeight > maxHeight) {
this.textarea.style.overflowY = "auto";
} else {
this.textarea.style.overflowY = "hidden";
}
}
}
@@ -2,7 +2,7 @@ import { later } from "@ember/runloop";
import PostUpdater from "./updaters/post-updater";
const PROGRESS_INTERVAL = 40;
const GIVE_UP_INTERVAL = 60000;
const GIVE_UP_INTERVAL = 600000; // 10 minutes which is our max thinking time for now
export const MIN_LETTERS_PER_INTERVAL = 6;
const MAX_FLUSH_TIME = 800;
@@ -12,13 +12,15 @@ export default class PostUpdater extends StreamUpdater {
constructor(postStream, postId) {
super();
this.postStream = postStream;
this.postId = postId;
this.post = postStream.findLoadedPost(postId);
const topicId = postStream.topic.id;
if (this.post) {
this.postElement = document.querySelector(
`#post_${this.post.post_number}`
`.topic-area[data-topic-id="${topicId}"] #post_${this.post.post_number}`
);
}
}
@@ -57,6 +59,10 @@ export default class PostUpdater extends StreamUpdater {
}
async setCooked(value) {
if (!this.postElement) {
return;
}
this.post.set("cooked", value);
(await loadMorphlex()).morphInner(
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import { ADMIN_PANEL, MAIN_PANEL } from "discourse/lib/sidebar/panels";
export const AI_CONVERSATIONS_PANEL = "ai-conversations";
export default class AiConversationsSidebarManager extends Service {
@service appEvents;
@service sidebarState;
@tracked newTopicForceSidebar = false;
@@ -26,6 +27,7 @@ export default class AiConversationsSidebarManager extends Service {
this.sidebarState.isForcingSidebar = true;
document.body.classList.add("has-ai-conversations-sidebar");
this.appEvents.trigger("discourse-ai:force-conversations-sidebar");
return true;
}
@@ -40,6 +42,7 @@ export default class AiConversationsSidebarManager extends Service {
if (this.sidebarState.isForcingSidebar && !isAdminSidebarActive) {
this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel
this.sidebarState.isForcingSidebar = false;
this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar");
}
}
}
@@ -1,102 +1,4 @@
import { fn, hash } from "@ember/helper";
import { on } from "@ember/modifier";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import RouteTemplate from "ember-route-template";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import { i18n } from "discourse-i18n";
import AiPersonaLlmSelector from "discourse/plugins/discourse-ai/discourse/components/ai-persona-llm-selector";
import AiBotConversations from "discourse/plugins/discourse-ai/discourse/components/ai-bot-conversations";
export default RouteTemplate(
<template>
<div class="ai-bot-conversations">
<AiPersonaLlmSelector
@showLabels={{true}}
@setPersonaId={{@controller.setPersonaId}}
@setTargetRecipient={{@controller.setTargetRecipient}}
/>
<div class="ai-bot-conversations__content-wrapper">
<div class="ai-bot-conversations__title">
{{i18n "discourse_ai.ai_bot.conversations.header"}}
</div>
<PluginOutlet
@name="ai-bot-conversations-above-input"
@outletArgs={{hash
[email protected]
[email protected]
}}
/>
<div class="ai-bot-conversations__input-wrapper">
<DButton
@icon="upload"
@action={{@controller.openFileUpload}}
@title="discourse_ai.ai_bot.conversations.upload_files"
class="btn btn-transparent ai-bot-upload-btn"
/>
<textarea
{{didInsert @controller.setTextArea}}
{{on "input" @controller.updateInputValue}}
{{on "keydown" @controller.handleKeyDown}}
id="ai-bot-conversations-input"
autofocus="true"
placeholder={{i18n "discourse_ai.ai_bot.conversations.placeholder"}}
minlength="10"
disabled={{@controller.loading}}
rows="1"
/>
<DButton
@action={{@controller.prepareAndSubmitToBot}}
@icon="paper-plane"
@isLoading={{@controller.loading}}
@title="discourse_ai.ai_bot.conversations.header"
class="ai-bot-button btn-transparent ai-conversation-submit"
/>
<input
type="file"
id="ai-bot-file-uploader"
class="hidden-upload-field"
multiple="multiple"
{{didInsert @controller.registerFileInput}}
/>
</div>
<p class="ai-disclaimer">
{{i18n "discourse_ai.ai_bot.conversations.disclaimer"}}
</p>
{{#if @controller.showUploadsContainer}}
<div class="ai-bot-conversations__uploads-container">
{{#each @controller.uploads as |upload|}}
<div class="ai-bot-upload">
<span class="ai-bot-upload__filename">
{{upload.original_filename}}
</span>
<DButton
@icon="xmark"
@action={{fn @controller.removeUpload upload}}
class="btn-transparent ai-bot-upload__remove"
/>
</div>
{{/each}}
{{#each @controller.inProgressUploads as |upload|}}
<div class="ai-bot-upload ai-bot-upload--in-progress">
<span class="ai-bot-upload__filename">{{upload.fileName}}</span>
<span class="ai-bot-upload__progress">
{{upload.progress}}%
</span>
<DButton
@icon="xmark"
@action={{fn @controller.cancelUpload upload}}
class="btn-flat ai-bot-upload__cancel"
/>
</div>
{{/each}}
</div>
{{/if}}
</div>
</div>
</template>
);
export default RouteTemplate(<template><AiBotConversations /></template>);
@@ -18,12 +18,24 @@ function initializeAiArtifacts(api) {
"data-ai-artifact-version"
);
const dataAttributes = {};
for (const attr of artifactElement.attributes) {
if (
attr.name.startsWith("data-") &&
attr.name !== "data-ai-artifact-id" &&
attr.name !== "data-ai-artifact-version"
) {
dataAttributes[attr.name] = attr.value;
}
}
helper.renderGlimmer(
artifactElement,
<template>
<AiArtifact
@artifactId={{artifactId}}
@artifactVersion={{artifactVersion}}
@dataAttributes={{dataAttributes}}
/>
</template>
);
@@ -30,7 +30,6 @@ export default {
);
const appEvents = api.container.lookup("service:app-events");
const messageBus = api.container.lookup("service:message-bus");
const navigationMenu = api.container.lookup("service:navigationMenu");
api.addSidebarPanel(
(BaseCustomSidebarPanel) =>
@@ -27,6 +27,7 @@ function initializeAiHelperTrigger(api) {
icon: "discourse-sparkles",
title: "discourse_ai.ai_helper.context_menu.trigger",
preventFocus: true,
hideShortcutInTitle: true,
shortcut: "ALT+P",
shortcutAction: (toolbarEvent) => {
if (toolbarEvent.getText().length === 0) {
+49
View File
@@ -79,3 +79,52 @@ article.streaming .cooked {
}
}
}
@keyframes mark-blink {
0%,
100% {
border-color: transparent;
}
50% {
border-color: var(--highlight-high);
}
}
@keyframes fade-in-highlight {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
mark.highlight {
background-color: var(--highlight-high);
animation: fade-in-highlight 0.5s ease-in-out forwards;
}
.composer-ai-helper-modal__suggestion.thinking mark.highlight {
animation: mark-blink 1s step-start 0s infinite;
animation-name: mark-blink;
}
.composer-ai-helper-modal__loading {
white-space: pre-wrap;
}
.composer-ai-helper-modal__suggestion.inline-diff {
white-space: pre-wrap;
del:last-child {
text-decoration: none;
background-color: transparent;
color: var(--primary-low-mid);
}
.diff-inner {
display: inline;
}
}
@@ -78,7 +78,6 @@ body.has-ai-conversations-sidebar {
.private-message-glyph-wrapper,
.topic-header-participants,
.topic-above-footer-buttons-outlet,
.topic-map,
#topic-footer-buttons .topic-footer-main-buttons details {
display: none;
}
@@ -455,4 +454,82 @@ body.has-ai-conversations-sidebar {
display: none;
}
}
.topic-map {
box-sizing: border-box;
width: 100%;
margin: 0 auto;
padding-block: 0.5em;
.topic-map__views-trigger,
.topic-map__likes-trigger,
.summarization-button,
&__private-message-map,
.topic-map__users-list {
display: none;
}
&.--bottom {
padding-top: 0;
}
section {
background: transparent;
border-block: 1px solid var(--primary-low);
}
&__contents {
padding: 0.5em 1.25em;
@include viewport.from(sm) {
padding: 0.5em 0.5em 0.5em 1.9em;
}
}
&__stats {
height: 100%;
flex-wrap: nowrap;
gap: 1em;
}
.ai-conversation__participants {
display: flex;
flex-wrap: wrap;
gap: 0.5em 0.25em;
align-items: center;
.avatar {
width: 2em;
height: 2em;
}
.trigger-group-card {
display: flex;
align-items: center;
border: 1px solid var(--primary-low);
border-radius: var(--d-button-border-radius);
padding: 0.25em 0.5em;
font-size: var(--font-down-1);
span {
position: relative;
top: -1px;
}
.d-icon {
position: relative;
top: 1px;
}
a {
color: var(--primary-medium);
}
}
.btn {
font-size: var(--font-down-1);
margin-right: 0.5em;
}
}
}
}
@@ -1,6 +1,9 @@
.composer-ai-helper-modal {
.text-preview,
.inline-diff {
font-family: var(--d-font-family--monospace);
font-variant-ligatures: none;
ins {
background-color: var(--success-low);
text-decoration: none;
+13
View File
@@ -320,9 +320,18 @@ de:
rag_conversation_chunks_help: "Die Anzahl der Chunks, die für die RAG-Modell-Suche verwendet werden. Erhöhen, um die Menge des Kontexts zu steigern, den die KI verwenden kann."
persona_description: "Personas sind eine leistungsstarke Funktion, mit der du das Verhalten der KI-Engine in deinem Discourse-Forum anpassen kannst. Sie fungieren als „Systemnachricht“, welche die Antworten und Interaktionen der KI steuert und dazu beiträgt, ein persönlicheres und ansprechenderes Erlebnis für Benutzer zu schaffen."
response_format:
title: "JSON-Antwortformat"
no_format: "Kein JSON-Format angegeben"
open_modal: "Bearbeiten"
modal:
root_title: "Antwortstruktur"
key_title: "Schlüssel"
examples:
title: Beispiele
new: Neues Beispiel
remove: Beispiel löschen
collapsable_title: "Beispiel #%{number}"
model: "Antwort des Modells"
list:
enabled: "KI-Bot?"
ai_bot:
@@ -514,6 +523,7 @@ de:
disable_streaming: "Streaming-Vervollständigung deaktivieren (Streaming-Anfragen in Nicht-Streaming-Anfragen umwandeln)"
reasoning_effort: "Argumentationsaufwand (gilt nur für Argumentationsmodelle)"
enable_reasoning: "Argumentation aktivieren (gilt nur für Sonnet 3.7)"
thinking_tokens: "Anzahl der zum Denken verwendeten Token"
reasoning_tokens: "Anzahl der für die Argumentation verwendeten Token"
disable_temperature: "Temperatur deaktivieren (einige Denkmodelle unterstützen keine Temperatur)"
disable_top_p: "Top P deaktivieren (einige Denkmodelle unterstützen Top P nicht)"
@@ -701,6 +711,9 @@ de:
disclaimer: "Generative KI kann Fehler machen. Überprüfe wichtige Informationen."
placeholder: "Stell eine Frage ..."
new: "Neue Frage"
min_input_length_message:
one: "Nachricht muss mindestens 1 Zeichen lang sein"
other: "Nachricht muss mindestens %{count} Zeichen lang sein"
messages_sidebar_title: "Unterhaltungen"
today: "Heute"
last_7_days: "Letzte 7 Tage"
+4
View File
@@ -712,6 +712,10 @@ en:
name: "Share AI conversation"
title: "Share this AI conversation publicly"
invite_ai_conversation:
button: "Invite"
title: "Invite to AI conversation"
ai_label: "AI"
ai_title: "Conversation with AI"
+8
View File
@@ -309,6 +309,13 @@ he:
modal:
root_title: "מבנה תגובה"
key_title: "מפתח"
examples:
title: דוגמאות
new: דוגמה חדשה
remove: מחיקת דוגמה
collapsable_title: "דוגמה מס׳ %{number}"
user: "הודעת משתמש"
model: "תגובת המודל"
list:
enabled: "בוט בינה מלאכותית?"
ai_bot:
@@ -714,6 +721,7 @@ he:
discobot_discoveries:
regular_results: "נושאים"
tell_me_more: "אשמח לדעת עוד…"
continue_convo: "המשך בשיחה…"
collapse: "צמצום"
tooltip:
header: "חיפוש מופעל בינה מלאכותית"
+3 -3
View File
@@ -261,9 +261,6 @@ ar:
settings_explorer:
name: مستكشف الإعدادات
description: "روبوت ذكاء اصطناعي متخصص في المساعدة في استكشاف إعدادات موقع Discourse"
researcher:
name: الباحث
description: "روبوت ذكاء اصطناعي مزوَّد بإمكانية الوصول إلى Google، ويمكنه البحث عن المعلومات نيابةً عنك"
creative:
name: المبدع
description: "روبوت ذكاء اصطناعي من دون تكاملات خارجية متخصصة في المهام الإبداعية"
@@ -280,6 +277,9 @@ ar:
summarizing: "جارٍ تلخيص الموضوع"
searching: "جارٍ البحث عن: '%{query}'"
tool_options:
researcher:
max_results:
name: "الحد الأقصى لعدد النتائج"
google:
base_query:
name: "استعلام البحث الأساسي"
+28 -2
View File
@@ -106,6 +106,7 @@ de:
ai_discord_search_mode: "Wähle den Suchmodus für die Discord-Suche aus"
ai_discord_search_persona: "Die Persona, die für die Discord-Suche verwendet wird."
ai_discord_allowed_guilds: "Discord-Gilden (Server), auf denen der Bot suchen darf"
ai_bot_enable_dedicated_ux: "Erlaube eine Vollbild-Bot-Oberfläche anstelle einer PM"
reviewables:
reasons:
flagged_by_toxicity: Das KI-Plug-in meldete dies nach der Klassifizierung als toxisch.
@@ -278,6 +279,11 @@ de:
artist:
name: Künstler
description: "KI-Bot, spezialisiert auf die Erstellung von Bildern"
designer:
name: Designer
forum_researcher:
name: Forum-Rechercheur
description: "KI-Bot, spezialisiert auf gründliche Recherche für das Forum"
sql_helper:
name: SQL-Helfer
description: "KI-Bot, der darauf spezialisiert ist, bei der Erstellung von SQL-Abfragen auf dieser Discourse-Instanz zu helfen"
@@ -285,8 +291,8 @@ de:
name: Einstellungs-Explorer
description: "KI-Bot, der darauf spezialisiert ist, die Einstellungen der Discourse-Website zu erkunden"
researcher:
name: Forscher
description: "KI-Bot mit Google-Zugang, der Informationen für dich recherchieren kann"
name: Web-Rechercheur
description: "KI-Bot mit Google-Zugriff, der Webseiten sowohl suchen als auch lesen kann"
creative:
name: Kreativ
description: "KI-Bot ohne externe Integrationen, spezialisiert auf kreative Aufgaben"
@@ -309,6 +315,16 @@ de:
summarizing: "Thema zusammenfassen"
searching: "Suche nach: „%{query}“"
tool_options:
researcher:
max_results:
name: "Maximale Anzahl von Ergebnissen"
description: "Maximale Anzahl von Ergebnissen, die in einen Filter aufgenommen werden"
include_private:
name: "Private einschließen"
description: "Private Themen in die Filter einbeziehen"
max_tokens_per_post:
name: "Maximale Token pro Beitrag"
description: "Maximale Anzahl von Token, die für jeden Beitrag im Filter verwendet werden"
create_artifact:
creator_llm:
name: "LLM"
@@ -365,6 +381,7 @@ de:
dall_e: "Bild generieren"
search_meta_discourse: "Meta-Discourse durchsuchen"
javascript_evaluator: "JavaScript auswerten"
researcher: "Rechercheur"
tool_help:
read_artifact: "Lesen eines Webartefakts mit dem KI-Bot"
update_artifact: "Aktualisiere ein Web-Artefakt mit dem KI-Bot"
@@ -389,6 +406,7 @@ de:
dall_e: "Bild mit DALL-E 3 generieren"
search_meta_discourse: "Meta-Discourse durchsuchen"
javascript_evaluator: "JavaScript auswerten"
researcher: "Foreninformationen mit dem KI-Bot recherchieren"
tool_description:
read_artifact: "Lesen eines Webartefakts mit dem KI-Bot"
update_artifact: "Ein Web-Artefakt wurde mit dem KI-Bot aktualisiert"
@@ -423,6 +441,12 @@ de:
other: "%{count} <a href='%{url}'>Ergebnisse</a> für „%{query}“ gefunden"
setting_context: "Kontext wird gelesen für: %{setting_name}"
schema: "%{tables}"
researcher_dry_run:
one: "Vorgeschlagene Ziele: %{goals}\n\n%{count} Beitrag passend zu „%{filter}“ gefunden"
other: "Vorgeschlagene Ziele: %{goals}\n\n%{count} Beiträge passend zu „%{filter}“ gefunden"
researcher:
one: "Recherche: %{goals}\n\n%{count} Beitrag passend zu „%{filter}“ gefunden"
other: "Recherche: %{goals}\n\n%{count} Beiträge passend zu „%{filter}“ gefunden"
search_settings:
one: "%{count} Ergebnis für „%{query}“ gefunden"
other: "%{count} Ergebnisse für „%{query}“ gefunden"
@@ -468,6 +492,8 @@ de:
one: "Wir konnten dieses Modell nicht löschen, weil es von %{settings} verwendet wird. Aktualisiere die Einstellung und versuche es erneut."
other: "Wir konnten dieses Modell nicht löschen, weil %{settings} es verwenden. Aktualisiere die Einstellungen und versuche es erneut."
cannot_edit_builtin: "Du kannst ein integriertes Modell nicht bearbeiten."
personas:
malformed_examples: "Die angegebenen Beispiele haben das falsche Format."
embeddings:
delete_failed: "Dieses Modell wird derzeit verwendet. Aktualisiere zuerst `ai embeddings selected model`."
cannot_edit_builtin: "Du kannst ein integriertes Modell nicht bearbeiten."
+25 -2
View File
@@ -3,6 +3,7 @@ en:
ai:
flag_types:
review: "Add post to review queue"
review_hide: "Add post to review queue and hide post"
spam: "Flag as spam and hide post"
spam_silence: "Flag as spam, hide post and silence user"
scriptables:
@@ -296,6 +297,9 @@ en:
designer:
name: Designer
description: "AI Bot specialized in generating and editing images"
forum_researcher:
name: Forum Researcher
description: "AI Bot specialized in deep research for the forum"
sql_helper:
name: SQL Helper
description: "AI Bot specialized in helping craft SQL queries on this Discourse instance"
@@ -303,8 +307,8 @@ en:
name: Settings Explorer
description: "AI Bot specialized in helping explore Discourse site settings"
researcher:
name: Researcher
description: "AI Bot with Google access that can research information for you"
name: Web Researcher
description: "AI Bot with Google access that can both search and read web pages"
creative:
name: Creative
description: "AI Bot with no external integrations specialized in creative tasks"
@@ -327,6 +331,16 @@ en:
summarizing: "Summarizing topic"
searching: "Searching for: '%{query}'"
tool_options:
researcher:
max_results:
name: "Maximum number of results"
description: "Maximum number of results to include in a filter"
include_private:
name: "Include private"
description: "Include private topics in the filters"
max_tokens_per_post:
name: "Maximum tokens per post"
description: "Maximum number of tokens to use for each post in the filter"
create_artifact:
creator_llm:
name: "LLM"
@@ -385,6 +399,8 @@ en:
javascript_evaluator: "Evaluate JavaScript"
create_image: "Creating image"
edit_image: "Editing image"
researcher: "Researching"
researcher_dry_run: "Preparing research"
tool_help:
read_artifact: "Read a web artifact using the AI Bot"
update_artifact: "Update a web artifact using the AI Bot"
@@ -411,6 +427,7 @@ en:
dall_e: "Generate image using DALL-E 3"
search_meta_discourse: "Search Meta Discourse"
javascript_evaluator: "Evaluate JavaScript"
researcher: "Research forum information using the AI Bot"
tool_description:
read_artifact: "Read a web artifact using the AI Bot"
update_artifact: "Updated a web artifact using the AI Bot"
@@ -445,6 +462,12 @@ en:
other: "Found %{count} <a href='%{url}'>results</a> for '%{query}'"
setting_context: "Reading context for: %{setting_name}"
schema: "%{tables}"
researcher_dry_run:
one: "Proposed goals: %{goals}\n\nFound %{count} post matching '%{filter}'"
other: "Proposed goals: %{goals}\n\nFound %{count} posts matching '%{filter}'"
researcher:
one: "Researching: %{goals}\n\nFound %{count} post matching '%{filter}'"
other: "Researching: %{goals}\n\nFound %{count} posts matching '%{filter}'"
search_settings:
one: "Found %{count} result for '%{query}'"
other: "Found %{count} results for '%{query}'"

Some files were not shown because too many files have changed in this diff Show More