Compare commits

..
Author SHA1 Message Date
Penar Musaraj 059da39a4a DEV: output raw response in dev mode
Makes it easier to debug issues, for example if rate limits are hit.
2025-05-13 16:54:10 -04:00
88 changed files with 736 additions and 3299 deletions
@@ -42,21 +42,6 @@ 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}
@@ -89,19 +74,6 @@ 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 :redirect_to_login_if_required, :verify_authenticity_token, only: %i[asset]
skip_before_action :verify_authenticity_token, only: ["asset"]
def create
ensure_allowed_create!
-1
View File
@@ -266,7 +266,6 @@ 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,7 +1,6 @@
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";
@@ -89,15 +88,6 @@ 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"}}
@@ -128,7 +118,6 @@ 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,7 +10,6 @@ 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;
@@ -52,7 +51,6 @@ 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");
}
@@ -1,29 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import AddPmParticipants from "discourse/components/modal/add-pm-participants";
export default class AiConversationsInvite extends Component {
@service site;
@service modal;
@action
showInvite() {
this.modal.show(AddPmParticipants, {
model: {
title: "discourse_ai.ai_bot.invite_ai_conversation.title",
inviteModel: this.args.topic,
},
});
}
<template>
<DButton
@icon="user-plus"
@label="discourse_ai.ai_bot.invite_ai_conversation.button"
@action={{this.showInvite}}
class="ai-conversations__invite-button"
/>
</template>
}
@@ -22,8 +22,6 @@ export default class ModalDiffModal extends Component {
@service messageBus;
@tracked loading = false;
@tracked finalResult = "";
@tracked showcasedDiff = "";
@tracked diffStreamer = new DiffStreamer(this.args.model.selectedText);
@tracked suggestion = "";
@tracked
@@ -31,13 +29,16 @@ export default class ModalDiffModal extends Component {
() => this.suggestion,
(newValue) => (this.suggestion = newValue)
);
@tracked isStreaming = false;
constructor() {
super(...arguments);
this.suggestChanges();
}
get isStreaming() {
return this.diffStreamer.isStreaming || this.smoothStreamer.isStreaming;
}
get primaryBtnLabel() {
return this.loading
? i18n("discourse_ai.ai_helper.context_menu.loading")
@@ -62,24 +63,7 @@ export default class ModalDiffModal extends Component {
@action
async updateResult(result) {
// TODO(@keegan)
// Temporarily we are removing the animation using the diff streamer
// and simply showing the diff streamed without a proper animation
// while we figure things out
// so that things are not too janky in the meantime.
this.loading = false;
this.isStreaming = true;
if (result.done) {
this.finalResult = result.result;
}
this.showcasedDiff = result.diff;
if (result.done) {
this.loading = false;
this.isStreaming = false;
}
if (this.args.model.showResultAsDiff) {
this.diffStreamer.updateResult(result, "result");
@@ -121,14 +105,10 @@ export default class ModalDiffModal extends Component {
);
}
const finalResult =
this.finalResult?.length > 0
? this.finalResult
: this.diffStreamer.suggestion;
if (this.args.model.showResultAsDiff && finalResult) {
if (this.args.model.showResultAsDiff && this.diffStreamer.suggestion) {
this.args.model.toolbarEvent.replaceText(
this.args.model.selectedText,
finalResult
this.diffStreamer.suggestion
);
}
}
@@ -155,7 +135,7 @@ export default class ModalDiffModal extends Component {
}}
>
{{#if @model.showResultAsDiff}}
{{htmlSafe this.showcasedDiff}}
{{htmlSafe this.diffStreamer.diff}}
{{else}}
{{#if this.smoothStreamer.isStreaming}}
<CookText
@@ -1,10 +0,0 @@
import Component from "@glimmer/component";
import AiConversationsInvite from "../../components/ai-conversations-invite";
export default class AiConversationsInviteConnector extends Component {
static shouldRender(args) {
return args.topic.is_bot_pm;
}
<template><AiConversationsInvite @topic={{@outletArgs.topic}} /></template>
}
@@ -0,0 +1,200 @@
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 = 600000; // 10 minutes which is our max thinking time for now
const GIVE_UP_INTERVAL = 60000;
export const MIN_LETTERS_PER_INTERVAL = 6;
const MAX_FLUSH_TIME = 800;
@@ -12,8 +12,6 @@ export default class DiffStreamer {
@tracked lastResultText = "";
@tracked diff = "";
@tracked suggestion = "";
@tracked isDone = false;
@tracked isThinking = false;
typingTimer = null;
currentWordIndex = 0;
@@ -37,7 +35,6 @@ export default class DiffStreamer {
const newText = result[newTextKey];
const diffText = newText.slice(this.lastResultText.length).trim();
const newWords = diffText.split(/\s+/).filter(Boolean);
this.isDone = result?.done;
if (newWords.length > 0) {
this.isStreaming = true;
@@ -67,12 +64,7 @@ export default class DiffStreamer {
* Highlights the current word if streaming is ongoing.
*/
#streamNextWord() {
if (this.currentWordIndex === this.words.length && !this.isDone) {
this.isThinking = true;
}
if (this.currentWordIndex === this.words.length && this.isDone) {
this.isThinking = false;
if (this.currentWordIndex === this.words.length) {
this.diff = this.#compareText(this.selectedText, this.suggestion, {
markLastWord: false,
});
@@ -80,7 +72,6 @@ export default class DiffStreamer {
}
if (this.currentWordIndex < this.words.length) {
this.isThinking = false;
this.suggestion += this.words[this.currentWordIndex] + " ";
this.diff = this.#compareText(this.selectedText, this.suggestion, {
markLastWord: true,
@@ -108,36 +99,22 @@ export default class DiffStreamer {
const oldWords = oldText.trim().split(/\s+/);
const newWords = newText.trim().split(/\s+/);
// Track where the line breaks are in the original oldText
const lineBreakMap = (() => {
const lines = oldText.trim().split("\n");
const map = new Set();
let wordIndex = 0;
for (const line of lines) {
const wordsInLine = line.trim().split(/\s+/);
wordIndex += wordsInLine.length;
map.add(wordIndex - 1); // Mark the last word in each line
}
return map;
})();
const diff = [];
let i = 0;
while (i < oldWords.length || i < newWords.length) {
while (i < oldWords.length) {
const oldWord = oldWords[i];
const newWord = newWords[i];
let wordHTML = "";
let originalWordHTML = `<span class="ghost">${oldWord}</span>`;
if (newWord === undefined) {
wordHTML = `<span class="ghost">${oldWord}</span>`;
wordHTML = originalWordHTML;
} else if (oldWord === newWord) {
wordHTML = `<span class="same-word">${newWord}</span>`;
} else if (oldWord !== newWord) {
wordHTML = `<del>${oldWord ?? ""}</del> <ins>${newWord ?? ""}</ins>`;
wordHTML = `<del>${oldWord}</del> <ins>${newWord}</ins>`;
}
if (i === newWords.length - 1 && opts.markLastWord) {
@@ -145,12 +122,6 @@ export default class DiffStreamer {
}
diff.push(wordHTML);
// Add a line break after this word if it ended a line in the original text
if (lineBreakMap.has(i)) {
diff.push("<br>");
}
i++;
}
@@ -5,7 +5,6 @@ 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;
@@ -27,7 +26,6 @@ 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;
}
@@ -43,7 +41,5 @@ export default class AiConversationsSidebarManager extends Service {
this.sidebarState.setPanel(MAIN_PANEL); // Return to main sidebar panel
this.sidebarState.isForcingSidebar = false;
}
this.appEvents.trigger("discourse-ai:stop-forcing-conversations-sidebar");
}
}
@@ -1,4 +1,102 @@
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 AiBotConversations from "discourse/plugins/discourse-ai/discourse/components/ai-bot-conversations";
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";
export default RouteTemplate(<template><AiBotConversations /></template>);
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>
);
@@ -18,24 +18,12 @@ 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,6 +30,7 @@ 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) =>
-15
View File
@@ -79,18 +79,3 @@ article.streaming .cooked {
}
}
}
@keyframes mark-blink {
0%,
100% {
border-color: var(--highlight-high);
}
50% {
border-color: transparent;
}
}
.composer-ai-helper-modal__suggestion.thinking mark.highlight {
animation: mark-blink 1s step-start 0s infinite;
}
@@ -450,8 +450,7 @@ body.has-ai-conversations-sidebar {
button:not(
.create,
.share-ai-conversation-button,
.topic-admin-menu-trigger,
.ai-conversations__invite-button
.topic-admin-menu-trigger
) {
display: none;
}
+1 -5
View File
@@ -709,13 +709,9 @@ en:
delete: "Delete share"
share_ai_conversation:
name: "Share"
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"
+2 -24
View File
@@ -296,9 +296,6 @@ 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"
@@ -306,8 +303,8 @@ en:
name: Settings Explorer
description: "AI Bot specialized in helping explore Discourse site settings"
researcher:
name: Web Researcher
description: "AI Bot with Google access that can both search and read web pages"
name: Researcher
description: "AI Bot with Google access that can research information for you"
creative:
name: Creative
description: "AI Bot with no external integrations specialized in creative tasks"
@@ -330,16 +327,6 @@ 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"
@@ -398,8 +385,6 @@ 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"
@@ -426,7 +411,6 @@ 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"
@@ -461,12 +445,6 @@ 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}'"
+1 -1
View File
@@ -33,7 +33,7 @@ DiscourseAi::Personas::Persona.system_personas.each do |persona_class, id|
persona.allowed_group_ids = [Group::AUTO_GROUPS[:trust_level_0]]
end
persona.enabled = persona_class.default_enabled
persona.enabled = !summarization_personas.include?(persona_class)
persona.priority = true if persona_class == DiscourseAi::Personas::General
end
+5 -13
View File
@@ -6,23 +6,16 @@
module DiscourseAi
module AiBot
class ChatStreamer
attr_accessor :cancel
attr_reader :reply,
:guardian,
:thread_id,
:force_thread,
:in_reply_to_id,
:channel,
:cancel_manager
:cancelled
def initialize(
message:,
channel:,
guardian:,
thread_id:,
in_reply_to_id:,
force_thread:,
cancel_manager: nil
)
def initialize(message:, channel:, guardian:, thread_id:, in_reply_to_id:, force_thread:)
@message = message
@channel = channel
@guardian = guardian
@@ -42,8 +35,6 @@ module DiscourseAi
guardian: guardian,
thread_id: thread_id,
)
@cancel_manager = cancel_manager
end
def <<(partial)
@@ -120,7 +111,8 @@ module DiscourseAi
streaming = ChatSDK::Message.stream(message_id: reply.id, raw: buffer, guardian: guardian)
if !streaming
@cancel_manager.cancel! if @cancel_manager
cancel.call
@cancelled = true
end
end
end
+16 -20
View File
@@ -331,7 +331,6 @@ module DiscourseAi
),
user: message.user,
skip_tool_details: true,
cancel_manager: DiscourseAi::Completions::CancelManager.new,
)
reply = nil
@@ -348,14 +347,15 @@ module DiscourseAi
thread_id: message.thread_id,
in_reply_to_id: in_reply_to_id,
force_thread: force_thread,
cancel_manager: context.cancel_manager,
)
new_prompts =
bot.reply(context) do |partial, placeholder, type|
bot.reply(context) do |partial, cancel, placeholder, type|
# no support for tools or thinking by design
next if type == :thinking || type == :tool_details || type == :partial_tool
streamer.cancel = cancel
streamer << partial
break if streamer.cancelled
end
reply = streamer.reply
@@ -383,7 +383,6 @@ module DiscourseAi
auto_set_title: true,
silent_mode: false,
feature_name: nil,
cancel_manager: nil,
&blk
)
# this is a multithreading issue
@@ -472,26 +471,16 @@ module DiscourseAi
redis_stream_key = "gpt_cancel:#{reply_post.id}"
Discourse.redis.setex(redis_stream_key, MAX_STREAM_DELAY_SECONDS, 1)
cancel_manager ||= DiscourseAi::Completions::CancelManager.new
context.cancel_manager = cancel_manager
context
.cancel_manager
.start_monitor(delay: 0.2) do
context.cancel_manager.cancel! if !Discourse.redis.get(redis_stream_key)
end
context.cancel_manager.add_callback(
lambda { reply_post.update!(raw: reply, cooked: PrettyText.cook(reply)) },
)
end
context.skip_tool_details ||= !bot.persona.class.tool_details
post_streamer = PostStreamer.new(delay: Rails.env.test? ? 0 : 0.5) if stream_reply
started_thinking = false
new_custom_prompts =
bot.reply(context) do |partial, placeholder, type|
bot.reply(context) do |partial, cancel, placeholder, type|
if type == :thinking && !started_thinking
reply << "<details><summary>#{I18n.t("discourse_ai.ai_bot.thinking")}</summary>"
started_thinking = true
@@ -510,6 +499,15 @@ module DiscourseAi
blk.call(partial)
end
if stream_reply && !Discourse.redis.get(redis_stream_key)
cancel&.call
reply_post.update!(raw: reply, cooked: PrettyText.cook(reply))
# we do not break out, cause if we do
# we will not get results from bot
# leading to broken context
# we need to trust it to cancel at the endpoint
end
if post_streamer
post_streamer.run_later do
Discourse.redis.expire(redis_stream_key, MAX_STREAM_DELAY_SECONDS)
@@ -570,8 +568,6 @@ module DiscourseAi
end
raise e
ensure
context.cancel_manager.stop_monitor if context&.cancel_manager
# since we are skipping validations and jobs we
# may need to fix participant count
if reply_post && reply_post.topic && reply_post.topic.private_message? &&
@@ -653,7 +649,7 @@ module DiscourseAi
payload,
user_ids: bot_reply_post.topic.allowed_user_ids,
max_backlog_size: 2,
max_backlog_age: MAX_STREAM_DELAY_SECONDS,
max_backlog_age: 60,
)
end
end
+5 -4
View File
@@ -181,11 +181,12 @@ module DiscourseAi
streamed_diff = parse_diff(input, partial_response) if completion_prompt.diff?
# Throttle updates and check for safe stream points
# Throttle the updates and
# checking length prevents partial tags
# that aren't sanitized correctly yet (i.e. '<output')
# from being sent in the stream
if (streamed_result.length > 10 && (Time.now - start > 0.3)) || Rails.env.test?
sanitized = sanitize_result(streamed_result)
payload = { result: sanitized, diff: streamed_diff, done: false }
payload = { result: sanitize_result(streamed_result), diff: streamed_diff, done: false }
publish_update(channel, payload, user)
start = Time.now
end
-109
View File
@@ -1,109 +0,0 @@
# frozen_string_literal: true
# special object that can be used to cancel completions and http requests
module DiscourseAi
module Completions
class CancelManager
attr_reader :cancelled
attr_reader :callbacks
def initialize
@cancelled = false
@callbacks = Concurrent::Array.new
@mutex = Mutex.new
@monitor_thread = nil
end
def monitor_thread
@mutex.synchronize { @monitor_thread }
end
def start_monitor(delay: 0.5, &block)
@mutex.synchronize do
raise "Already monitoring" if @monitor_thread
raise "Expected a block" if !block
db = RailsMultisite::ConnectionManagement.current_db
@stop_monitor = false
@monitor_thread =
Thread.new do
begin
loop do
done = false
@mutex.synchronize { done = true if @stop_monitor }
break if done
sleep delay
@mutex.synchronize { done = true if @stop_monitor }
@mutex.synchronize { done = true if cancelled? }
break if done
should_cancel = false
RailsMultisite::ConnectionManagement.with_connection(db) do
should_cancel = block.call
end
@mutex.synchronize { cancel! if should_cancel }
break if cancelled?
end
ensure
@mutex.synchronize { @monitor_thread = nil }
end
end
end
end
def stop_monitor
monitor_thread = nil
@mutex.synchronize { monitor_thread = @monitor_thread }
if monitor_thread
@mutex.synchronize { @stop_monitor = true }
# so we do not deadlock
monitor_thread.wakeup
monitor_thread.join(2)
# should not happen
if monitor_thread.alive?
Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time")
monitor_thread.kill if monitor_thread.alive?
end
@monitor_thread = nil
end
end
def cancelled?
@cancelled
end
def add_callback(cb)
@callbacks << cb
end
def remove_callback(cb)
@callbacks.delete(cb)
end
def cancel!
@cancelled = true
monitor_thread = @monitor_thread
if monitor_thread && monitor_thread != Thread.current
monitor_thread.wakeup
monitor_thread.join(2)
if monitor_thread.alive?
Rails.logger.warn("DiscourseAI: CancelManager monitor thread did not stop in time")
monitor_thread.kill if monitor_thread.alive?
end
end
@callbacks.each do |cb|
begin
cb.call
rescue StandardError
# ignore cause this may have already been cancelled
end
end
end
end
end
end
+26 -1
View File
@@ -9,8 +9,33 @@ module DiscourseAi
end
def translated_tools
# Transform the raw tools into the required Anthropic Claude API format
raw_tools.map do |t|
{ name: t.name, description: t.description, input_schema: t.parameters_json_schema }
properties = {}
required = []
if t[:parameters]
properties = {}
t[:parameters].each do |param|
mapped = { type: param[:type], description: param[:description] }
mapped[:items] = { type: param[:item_type] } if param[:item_type]
mapped[:enum] = param[:enum] if param[:enum]
properties[param[:name]] = mapped
end
required =
t[:parameters].select { |param| param[:required] }.map { |param| param[:name] }
end
{
name: t[:name],
description: t[:description],
input_schema: {
type: "object",
properties: properties,
required: required,
},
}
end
end
+17 -15
View File
@@ -38,24 +38,27 @@ module DiscourseAi
end
def translated_tools
raw_tools.map do |tool|
defs = {}
raw_tools.map do |t|
tool = t.dup
tool.parameters.each do |p|
name = p.name
defs[name] = {
description: p.description,
type: cohere_type(p.type, p.item_type),
required: p.required,
}
tool[:parameter_definitions] = t[:parameters]
.to_a
.reduce({}) do |memo, p|
name = p[:name]
memo[name] = {
description: p[:description],
type: cohere_type(p[:type], p[:item_type]),
required: p[:required],
}
#defs[name][:default] = p.default if p.default
end
memo[name][:default] = p[:default] if p[:default]
memo
end
{
name: tool.name == "search" ? "search_local" : tool.name,
description: tool.description,
parameter_definitions: defs,
name: tool[:name] == "search" ? "search_local" : tool[:name],
description: tool[:description],
parameter_definitions: tool[:parameter_definitions],
}
end
end
@@ -69,7 +72,6 @@ module DiscourseAi
attr_reader :raw_tools
def cohere_type(type, item_type)
type = type.to_s
case type
when "string"
"str"
+16 -2
View File
@@ -47,8 +47,22 @@ module DiscourseAi
translated_tools =
prompt.tools.map do |t|
tool = { name: t.name, description: t.description }
tool[:parameters] = t.parameters_json_schema if t.parameters
tool = t.slice(:name, :description)
if t[:parameters]
tool[:parameters] = t[:parameters].reduce(
{ type: "object", required: [], properties: {} },
) do |memo, p|
name = p[:name]
memo[:required] << name if p[:required]
memo[:properties][name] = p.except(:name, :required, :item_type)
memo[:properties][name][:items] = { type: p[:item_type] } if p[:item_type]
memo
end
end
tool
end
+29 -3
View File
@@ -17,10 +17,10 @@ module DiscourseAi
@raw_tools.map do |tool|
{
toolSpec: {
name: tool.name,
description: tool.description,
name: tool[:name],
description: tool[:description],
inputSchema: {
json: tool.parameters_json_schema,
json: convert_tool_to_input_schema(tool),
},
},
}
@@ -51,6 +51,32 @@ module DiscourseAi
},
}
end
private
def convert_tool_to_input_schema(tool)
tool = tool.transform_keys(&:to_sym)
properties = {}
tool[:parameters].each do |param|
schema = {}
type = param[:type]
type = "string" if !%w[string number boolean integer array].include?(type)
schema[:type] = type
if enum = param[:enum]
schema[:enum] = enum
end
schema[:items] = { type: param[:item_type] } if type == "array"
schema[:required] = true if param[:required]
properties[param[:name]] = schema
end
{ type: "object", properties: properties }
end
end
end
end
+17 -9
View File
@@ -14,15 +14,23 @@ module DiscourseAi
end
def translated_tools
raw_tools.map do |tool|
{
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters_json_schema,
},
}
raw_tools.map do |t|
tool = t.dup
tool[:parameters] = t[:parameters]
.to_a
.reduce({ type: "object", properties: {}, required: [] }) do |memo, p|
name = p[:name]
memo[:required] << name if p[:required]
except = %i[name required item_type]
except << :enum if p[:enum].blank?
memo[:properties][name] = p.except(*except)
memo
end
{ type: "function", function: tool }
end
end

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