Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059da39a4a |
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
-10
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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}'"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user